1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package kvstore_test
4
5import (
6 "bytes"
7 "context"
8 "sync"
9 "testing"
10 "time"
11
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"
14
15 "bnema.dev/ermite/internal/domain"
16)
17
18func newPasskey(id string, at time.Time) domain.Passkey {
19 return domain.Passkey{
20 ID: []byte(id),
21 Name: "key " + id,
22 PublicKey: []byte("cose-" + id),
23 AttestationType: "none",
24 Transports: []string{"internal", "hybrid"},
25 SignCount: 7,
26 BackupEligible: true,
27 BackupState: true,
28 CreatedAt: at,
29 LastUsedAt: at.Add(time.Hour),
30 }
31}
32
33func TestPasskeysRoundTrip(t *testing.T) {
34 ctx := context.Background()
35 ps := openStore(t, t.TempDir()).Passkeys()
36
37 p := newPasskey("cred-1", created)
38 require.NoError(t, ps.Create(ctx, p))
39 got, err := ps.Get(ctx, p.ID)
40 require.NoError(t, err)
41 assert.Equal(t, p, got)
42
43 // Minimal passkey: no transports, never used. A binary id works too.
44 min := domain.Passkey{ID: []byte{0, 1, 0xff, '/'}, Name: "min", PublicKey: []byte("k"), CreatedAt: created}
45 require.NoError(t, ps.Create(ctx, min))
46 got, err = ps.Get(ctx, min.ID)
47 require.NoError(t, err)
48 assert.Empty(t, got.Transports)
49 assert.True(t, got.LastUsedAt.IsZero())
50 assert.Equal(t, min.ID, got.ID)
51
52 long := domain.Passkey{ID: bytes.Repeat([]byte{7}, domain.MaxPasskeyIDLen), Name: "long", PublicKey: []byte("k"), CreatedAt: created}
53 require.NoError(t, ps.Create(ctx, long))
54 tooLong := domain.Passkey{ID: bytes.Repeat([]byte{7}, domain.MaxPasskeyIDLen+1), Name: "x", PublicKey: []byte("k")}
55 assert.ErrorIs(t, ps.Create(ctx, tooLong), domain.ErrInvalid)
56 assert.ErrorIs(t, ps.Create(ctx, domain.Passkey{Name: "no id"}), domain.ErrInvalid)
57}
58
59func TestPasskeysDuplicateAndUnknown(t *testing.T) {
60 ctx := context.Background()
61 ps := openStore(t, t.TempDir()).Passkeys()
62 p := newPasskey("cred-1", created)
63 require.NoError(t, ps.Create(ctx, p))
64
65 assert.ErrorIs(t, ps.Create(ctx, p), domain.ErrAlreadyExists)
66
67 unknown := newPasskey("nope", created)
68 _, err := ps.Get(ctx, unknown.ID)
69 assert.ErrorIs(t, err, domain.ErrNotFound)
70 assert.ErrorIs(t, ps.Update(ctx, unknown), domain.ErrNotFound)
71 assert.ErrorIs(t, ps.Delete(ctx, unknown.ID), domain.ErrNotFound)
72 _, err = ps.Get(ctx, nil)
73 assert.ErrorIs(t, err, domain.ErrNotFound)
74 assert.ErrorIs(t, ps.Delete(ctx, nil), domain.ErrNotFound)
75}
76
77func TestPasskeysUpdateDelete(t *testing.T) {
78 ctx := context.Background()
79 ps := openStore(t, t.TempDir()).Passkeys()
80 p := newPasskey("cred-1", created)
81 require.NoError(t, ps.Create(ctx, p))
82
83 p.SignCount = 42
84 p.BackupState = false
85 p.LastUsedAt = created.Add(48 * time.Hour)
86 require.NoError(t, ps.Update(ctx, p))
87 got, err := ps.Get(ctx, p.ID)
88 require.NoError(t, err)
89 assert.Equal(t, p, got)
90
91 require.NoError(t, ps.Delete(ctx, p.ID))
92 _, err = ps.Get(ctx, p.ID)
93 assert.ErrorIs(t, err, domain.ErrNotFound)
94 assert.ErrorIs(t, ps.Delete(ctx, p.ID), domain.ErrNotFound)
95 list, err := ps.List(ctx)
96 require.NoError(t, err)
97 assert.Empty(t, list)
98}
99
100// List orders by creation time, then by id.
101func TestPasskeysListOrder(t *testing.T) {
102 ctx := context.Background()
103 ps := openStore(t, t.TempDir()).Passkeys()
104 for _, p := range []domain.Passkey{
105 newPasskey("c", created.Add(2*time.Hour)),
106 newPasskey("b", created),
107 newPasskey("a", created),
108 newPasskey("d", created.Add(time.Hour)),
109 } {
110 require.NoError(t, ps.Create(ctx, p))
111 }
112 list, err := ps.List(ctx)
113 require.NoError(t, err)
114 var ids []string
115 for _, p := range list {
116 ids = append(ids, string(p.ID))
117 }
118 assert.Equal(t, []string{"a", "b", "d", "c"}, ids)
119}
120
121// The handle is generated once, stays stable across a restart, and concurrent
122// first calls agree on it.
123func TestPasskeysUserHandle(t *testing.T) {
124 ctx := context.Background()
125 dir := t.TempDir()
126 s := openStore(t, dir)
127
128 var wg sync.WaitGroup
129 handles := make([][]byte, 8)
130 for i := range handles {
131 wg.Add(1)
132 go func() {
133 defer wg.Done()
134 h, err := s.Passkeys().UserHandle(ctx)
135 assert.NoError(t, err)
136 handles[i] = h
137 }()
138 }
139 wg.Wait()
140 handle := handles[0]
141 assert.Len(t, handle, 32)
142 for _, h := range handles[1:] {
143 assert.Equal(t, handle, h)
144 }
145 require.NoError(t, s.Close(ctx))
146
147 s = openStore(t, dir)
148 again, err := s.Passkeys().UserHandle(ctx)
149 require.NoError(t, err)
150 assert.Equal(t, handle, again, "a restart keeps the handle")
151}