v0.6.0 d5cc8fe

tokens_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package kvstore_test
4
5import (
6	"context"
7	"fmt"
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 newToken(t *testing.T, id, secret string) domain.Token {
19	t.Helper()
20	return domain.Token{
21		ID:        id,
22		Label:     "label " + id,
23		Hash:      domain.HashTokenSecret(secret),
24		Scopes:    []domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite},
25		Repos:     []domain.RepoName{name(t, "a"), name(t, "b")},
26		ExpiresAt: created.Add(24 * time.Hour),
27		CreatedAt: created,
28	}
29}
30
31func TestTokensRoundTrip(t *testing.T) {
32	ctx := context.Background()
33	s := openStore(t, t.TempDir())
34	ts := s.Tokens()
35
36	tok := newToken(t, "t1", "secret-1")
37	require.NoError(t, ts.Create(ctx, tok))
38
39	got, err := ts.FindByHash(ctx, tok.Hash)
40	require.NoError(t, err)
41	assert.Equal(t, tok, got)
42
43	_, err = ts.FindByHash(ctx, domain.HashTokenSecret("other"))
44	assert.ErrorIs(t, err, domain.ErrNotFound)
45
46	// Minimal token: no repos, no expiry.
47	min := domain.Token{ID: "t2", Hash: domain.HashTokenSecret("secret-2"), Scopes: []domain.Scope{domain.ScopeRepoRead}, CreatedAt: created.Add(time.Minute)}
48	require.NoError(t, ts.Create(ctx, min))
49	got, err = ts.FindByHash(ctx, min.Hash)
50	require.NoError(t, err)
51	assert.Empty(t, got.Repos)
52	assert.True(t, got.ExpiresAt.IsZero())
53	assert.True(t, got.LastUsedAt.IsZero())
54
55	list, err := ts.List(ctx)
56	require.NoError(t, err)
57	require.Len(t, list, 2)
58	assert.Equal(t, "t1", list[0].ID)
59	assert.Equal(t, "t2", list[1].ID)
60}
61
62func TestTokensCreateRejects(t *testing.T) {
63	ctx := context.Background()
64	s := openStore(t, t.TempDir())
65	ts := s.Tokens()
66	require.NoError(t, ts.Create(ctx, newToken(t, "t1", "s1")))
67
68	assert.ErrorIs(t, ts.Create(ctx, newToken(t, "t1", "other")), domain.ErrAlreadyExists, "same id")
69	assert.ErrorIs(t, ts.Create(ctx, newToken(t, "t9", "s1")), domain.ErrAlreadyExists, "same hash")
70
71	// A failed create leaves no half-written index.
72	_, err := ts.FindByHash(ctx, domain.HashTokenSecret("other"))
73	assert.ErrorIs(t, err, domain.ErrNotFound)
74	list, err := ts.List(ctx)
75	require.NoError(t, err)
76	assert.Len(t, list, 1)
77
78	bad := newToken(t, "t3", "s3")
79	bad.Scopes = nil
80	assert.ErrorIs(t, ts.Create(ctx, bad), domain.ErrInvalidToken)
81	bad = newToken(t, "t3", "s3")
82	bad.Scopes = []domain.Scope{"nope"}
83	assert.ErrorIs(t, ts.Create(ctx, bad), domain.ErrInvalidScope)
84	bad = newToken(t, "a/b", "s3")
85	assert.ErrorIs(t, ts.Create(ctx, bad), domain.ErrInvalidToken)
86	bad = newToken(t, "", "s3")
87	assert.ErrorIs(t, ts.Create(ctx, bad), domain.ErrInvalidToken)
88}
89
90func TestTokensRevokeKeepsIndexConsistent(t *testing.T) {
91	ctx := context.Background()
92	dir := t.TempDir()
93	s := openStore(t, dir)
94	ts := s.Tokens()
95	a, b := newToken(t, "a", "sa"), newToken(t, "b", "sb")
96	require.NoError(t, ts.Create(ctx, a))
97	require.NoError(t, ts.Create(ctx, b))
98
99	require.NoError(t, ts.Revoke(ctx, "a"))
100	_, err := ts.FindByHash(ctx, a.Hash)
101	assert.ErrorIs(t, err, domain.ErrNotFound)
102	got, err := ts.FindByHash(ctx, b.Hash)
103	require.NoError(t, err)
104	assert.Equal(t, "b", got.ID)
105	assert.ErrorIs(t, ts.Revoke(ctx, "a"), domain.ErrNotFound)
106	assert.ErrorIs(t, ts.Revoke(ctx, "../x"), domain.ErrNotFound)
107
108	// The hash of a revoked token can be used again, and the index survives a restart.
109	require.NoError(t, ts.Create(ctx, newToken(t, "c", "sa")))
110	require.NoError(t, s.Close(ctx))
111	s2 := openStore(t, dir)
112	got, err = s2.Tokens().FindByHash(ctx, a.Hash)
113	require.NoError(t, err)
114	assert.Equal(t, "c", got.ID)
115	list, err := s2.Tokens().List(ctx)
116	require.NoError(t, err)
117	assert.Len(t, list, 2)
118}
119
120func TestTokensGet(t *testing.T) {
121	ctx := context.Background()
122	ts := openStore(t, t.TempDir()).Tokens()
123	tok := newToken(t, "t1", "s1")
124	require.NoError(t, ts.Create(ctx, tok))
125
126	got, err := ts.Get(ctx, "t1")
127	require.NoError(t, err)
128	assert.Equal(t, tok, got)
129
130	for _, id := range []string{"gone", "", "../t1"} {
131		_, err = ts.Get(ctx, id)
132		assert.ErrorIs(t, err, domain.ErrNotFound, id)
133	}
134	require.NoError(t, ts.Revoke(ctx, "t1"))
135	_, err = ts.Get(ctx, "t1")
136	assert.ErrorIs(t, err, domain.ErrNotFound)
137}
138
139func TestTokensTouch(t *testing.T) {
140	ctx := context.Background()
141	s := openStore(t, t.TempDir())
142	ts := s.Tokens()
143	tok := newToken(t, "t1", "s1")
144	require.NoError(t, ts.Create(ctx, tok))
145
146	at := created.Add(time.Hour)
147	require.NoError(t, ts.Touch(ctx, "t1", at))
148	got, err := ts.FindByHash(ctx, tok.Hash)
149	require.NoError(t, err)
150	assert.True(t, at.Equal(got.LastUsedAt))
151
152	// An older time does not move it back.
153	require.NoError(t, ts.Touch(ctx, "t1", created))
154	got, err = ts.FindByHash(ctx, tok.Hash)
155	require.NoError(t, err)
156	assert.True(t, at.Equal(got.LastUsedAt))
157
158	assert.ErrorIs(t, ts.Touch(ctx, "gone", at), domain.ErrNotFound)
159
160	// Everything else is untouched.
161	got.LastUsedAt = time.Time{}
162	assert.Equal(t, tok, got)
163}
164
165func TestTokensConcurrentTouchAndRevoke(t *testing.T) {
166	ctx := context.Background()
167	s := openStore(t, t.TempDir())
168	ts := s.Tokens()
169	tok := newToken(t, "t1", "s1")
170	require.NoError(t, ts.Create(ctx, tok))
171
172	var wg sync.WaitGroup
173	for i := range 8 {
174		wg.Add(1)
175		go func() {
176			defer wg.Done()
177			_ = ts.Touch(ctx, "t1", created.Add(time.Duration(i+1)*time.Second))
178		}()
179	}
180	wg.Add(1)
181	go func() { defer wg.Done(); _ = ts.Revoke(ctx, "t1") }()
182	wg.Wait()
183
184	// Whatever the interleaving, token and index are both there or both gone.
185	_, errTok := ts.FindByHash(ctx, tok.Hash)
186	list, err := ts.List(ctx)
187	require.NoError(t, err)
188	if errTok != nil {
189		assert.ErrorIs(t, errTok, domain.ErrNotFound, fmt.Sprint(errTok))
190		assert.Empty(t, list)
191	} else {
192		assert.Len(t, list, 1)
193	}
194}

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 2

Tags 45

↑↓ moveenter openesc close