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}