detached 388d3be

passkeys_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close