1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6 "context"
7 "strings"
8 "sync"
9 "sync/atomic"
10 "testing"
11 "time"
12
13 "github.com/golang-jwt/jwt/v5"
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/mock"
16 "github.com/stretchr/testify/require"
17
18 "bnema.dev/ermite/internal/domain"
19 "bnema.dev/ermite/internal/ports/mocks"
20 "bnema.dev/ermite/internal/usecase"
21)
22
23type sessionFixture struct {
24 store *mocks.SessionStore
25 clock *mocks.Clock
26 at atomic.Pointer[time.Time] // what the clock reads; the test moves it
27 sessions *usecase.Sessions
28 key []byte
29}
30
31// advance moves the clock forward by d.
32func (f *sessionFixture) advance(d time.Duration) {
33 next := f.at.Load().Add(d)
34 f.at.Store(&next)
35}
36
37func newSessionFixture(t *testing.T) *sessionFixture {
38 t.Helper()
39 f := &sessionFixture{
40 store: mocks.NewSessionStore(t),
41 clock: mocks.NewClock(t),
42 key: []byte("0123456789abcdef0123456789abcdef"),
43 }
44 start := time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)
45 f.at.Store(&start)
46 f.clock.EXPECT().Now().RunAndReturn(func() time.Time { return *f.at.Load() }).Maybe()
47
48 var epoch atomic.Uint64
49 f.store.EXPECT().Key(mock.Anything).Return(f.key, nil).Maybe()
50 f.store.EXPECT().Epoch(mock.Anything).RunAndReturn(func(context.Context) (uint64, error) { return epoch.Load(), nil }).Maybe()
51 f.store.EXPECT().BumpEpoch(mock.Anything).RunAndReturn(func(context.Context) (uint64, error) { return epoch.Add(1), nil }).Maybe()
52
53 s, err := usecase.NewSessions(context.Background(), f.store, f.clock, "https://forge.example")
54 require.NoError(t, err)
55 f.sessions = s
56 return f
57}
58
59func (f *sessionFixture) nonce(t *testing.T) string {
60 t.Helper()
61 link, err := f.sessions.CreateLoginLink(context.Background(), domain.Root())
62 require.NoError(t, err)
63 n, ok := strings.CutPrefix(link.URL, "https://forge.example/-/login/")
64 require.True(t, ok, link.URL)
65 return n
66}
67
68func allScopeToken(repos ...domain.RepoName) domain.Principal {
69 return domain.TokenPrincipal(domain.Token{ID: "t", Scopes: domain.AllScopes(), Repos: repos})
70}
71
72// Only the owner creates a link: root, or a token with every scope and no
73// repository list.
74func TestLoginLinkIsOwnerOnly(t *testing.T) {
75 t.Parallel()
76 ctx := context.Background()
77 f := newSessionFixture(t)
78 partial := domain.TokenPrincipal(domain.Token{ID: "p", Scopes: domain.AllScopes()[:8]})
79 limited := allScopeToken(mustRepo(t, "a"))
80
81 for name, tc := range map[string]struct {
82 p domain.Principal
83 err error
84 }{
85 "root": {domain.Root(), nil},
86 "owner session": {domain.Owner(), nil},
87 "every scope": {allScopeToken(), nil},
88 "one scope short": {partial, domain.ErrForbidden},
89 "repo list": {limited, domain.ErrForbidden},
90 "anonymous": {domain.Anonymous(), domain.ErrUnauthenticated},
91 } {
92 _, err := f.sessions.CreateLoginLink(ctx, tc.p)
93 if tc.err == nil {
94 assert.NoError(t, err, name)
95 } else {
96 assert.ErrorIs(t, err, tc.err, name)
97 }
98 err = f.sessions.SignOutEverywhere(ctx, tc.p)
99 if tc.err == nil {
100 assert.NoError(t, err, name)
101 } else {
102 assert.ErrorIs(t, err, tc.err, name)
103 }
104 }
105}
106
107func mustRepo(t *testing.T, s string) domain.RepoName {
108 t.Helper()
109 n, err := domain.ParseRepoName(s)
110 require.NoError(t, err)
111 return n
112}
113
114// AC1: checking a link does not use it; logging in uses it once; an expired or
115// replayed link fails.
116func TestLoginLinkLifecycle(t *testing.T) {
117 t.Parallel()
118 ctx := context.Background()
119 f := newSessionFixture(t)
120
121 link, err := f.sessions.CreateLoginLink(ctx, domain.Root())
122 require.NoError(t, err)
123 assert.Equal(t, f.clock.Now().Add(usecase.LoginLinkTTL), link.ExpiresAt)
124 nonce := strings.TrimPrefix(link.URL, "https://forge.example/-/login/")
125 assert.Len(t, nonce, 43, "256 bits")
126
127 for range 3 {
128 assert.True(t, f.sessions.LoginLinkValid(nonce), "looking never consumes")
129 }
130 token, exp, err := f.sessions.Login(ctx, nonce)
131 require.NoError(t, err)
132 assert.NotEmpty(t, token)
133 assert.Equal(t, f.clock.Now().Add(usecase.SessionTTL), exp)
134 assert.False(t, f.sessions.LoginLinkValid(nonce))
135 _, _, err = f.sessions.Login(ctx, nonce)
136 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "replay")
137
138 // Expired.
139 nonce = f.nonce(t)
140 f.advance(usecase.LoginLinkTTL)
141 assert.False(t, f.sessions.LoginLinkValid(nonce))
142 _, _, err = f.sessions.Login(ctx, nonce)
143 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "expired")
144
145 // Unknown.
146 _, _, err = f.sessions.Login(ctx, strings.Repeat("a", 43))
147 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
148
149 // Two links are independent.
150 a, b := f.nonce(t), f.nonce(t)
151 assert.NotEqual(t, a, b)
152 _, _, err = f.sessions.Login(ctx, a)
153 require.NoError(t, err)
154 assert.True(t, f.sessions.LoginLinkValid(b))
155}
156
157// GO-003a: the session of a link made with a token never outlives that token;
158// root and a token that never expires leave it at SessionTTL.
159func TestSessionIsCappedByTheTokenThatMadeTheLink(t *testing.T) {
160 t.Parallel()
161 ctx := context.Background()
162 f := newSessionFixture(t)
163 tokenExpiring := func(in time.Duration) domain.Principal {
164 return domain.TokenPrincipal(domain.Token{ID: "t", Scopes: domain.AllScopes(), ExpiresAt: f.clock.Now().Add(in)})
165 }
166 login := func(p domain.Principal) (string, time.Time, error) {
167 link, err := f.sessions.CreateLoginLink(ctx, p)
168 require.NoError(t, err)
169 return f.sessions.Login(ctx, strings.TrimPrefix(link.URL, "https://forge.example/-/login/"))
170 }
171 start := f.clock.Now()
172
173 for name, tc := range map[string]struct {
174 p domain.Principal
175 want time.Time
176 }{
177 "root": {domain.Root(), start.Add(usecase.SessionTTL)},
178 "owner session": {domain.Owner(), start.Add(usecase.SessionTTL)},
179 "never expires": {allScopeToken(), start.Add(usecase.SessionTTL)},
180 "expires sooner": {tokenExpiring(2 * time.Hour), start.Add(2 * time.Hour)},
181 "expires later": {tokenExpiring(48 * time.Hour), start.Add(usecase.SessionTTL)},
182 "expires exactly TTL": {tokenExpiring(usecase.SessionTTL), start.Add(usecase.SessionTTL)},
183 } {
184 tok, exp, err := login(tc.p)
185 require.NoError(t, err, name)
186 assert.Equal(t, tc.want, exp, name)
187 _, err = f.sessions.Verify(ctx, tok)
188 assert.NoError(t, err, name)
189 // The session token itself carries the cap as its exp claim.
190 var c claims
191 _, _, err = jwt.NewParser().ParseUnverified(tok, &c)
192 require.NoError(t, err, name)
193 assert.Equal(t, tc.want, c.ExpiresAt.UTC(), name)
194 }
195
196 // It stops working when the token would have.
197 tok, _, err := login(tokenExpiring(2 * time.Hour))
198 require.NoError(t, err)
199 f.advance(2*time.Hour - time.Second)
200 _, err = f.sessions.Verify(ctx, tok)
201 assert.NoError(t, err)
202 f.advance(time.Second)
203 _, err = f.sessions.Verify(ctx, tok)
204 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "the token expired, so did the session")
205
206 // A token that expires before the link is used opens nothing.
207 link, err := f.sessions.CreateLoginLink(ctx, tokenExpiring(30*time.Second))
208 require.NoError(t, err)
209 f.advance(31 * time.Second)
210 _, _, err = f.sessions.Login(ctx, strings.TrimPrefix(link.URL, "https://forge.example/-/login/"))
211 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
212}
213
214// A single use also holds under concurrent confirmations.
215func TestLoginLinkConsumedOnceConcurrently(t *testing.T) {
216 t.Parallel()
217 f := newSessionFixture(t)
218 nonce := f.nonce(t)
219 var wg sync.WaitGroup
220 var mu sync.Mutex
221 won := 0
222 for range 16 {
223 wg.Add(1)
224 go func() {
225 defer wg.Done()
226 if _, _, err := f.sessions.Login(context.Background(), nonce); err == nil {
227 mu.Lock()
228 won++
229 mu.Unlock()
230 }
231 }()
232 }
233 wg.Wait()
234 assert.Equal(t, 1, won)
235}
236
237func TestLoginLinksAreBounded(t *testing.T) {
238 t.Parallel()
239 ctx := context.Background()
240 f := newSessionFixture(t)
241 for range usecase.MaxPendingLoginLinks {
242 f.nonce(t)
243 }
244 _, err := f.sessions.CreateLoginLink(ctx, domain.Root())
245 assert.ErrorIs(t, err, domain.ErrResourceExhausted)
246
247 f.advance(usecase.LoginLinkTTL)
248 _, err = f.sessions.CreateLoginLink(ctx, domain.Root())
249 assert.NoError(t, err, "expired links make room")
250}
251
252func sign(t *testing.T, m jwt.SigningMethod, key any, claims jwt.Claims) string {
253 t.Helper()
254 s, err := jwt.NewWithClaims(m, claims).SignedString(key)
255 require.NoError(t, err)
256 return s
257}
258
259type claims struct {
260 Epoch *uint64 `json:"epoch"`
261 jwt.RegisteredClaims
262}
263
264// AC1: a valid session is the owner; none, alg none, another algorithm, another
265// key, an old epoch, expiry, a missing claim and garbage are rejected.
266func TestSessionTokenVerification(t *testing.T) {
267 t.Parallel()
268 ctx := context.Background()
269 f := newSessionFixture(t)
270 now := f.clock.Now()
271 zero, one := uint64(0), uint64(1)
272 reg := func(sub string, iat, exp time.Time) jwt.RegisteredClaims {
273 return jwt.RegisteredClaims{Subject: sub, IssuedAt: jwt.NewNumericDate(iat), ExpiresAt: jwt.NewNumericDate(exp)}
274 }
275 good := reg("owner", now, now.Add(time.Hour))
276
277 token, _, err := f.sessions.Login(ctx, f.nonce(t))
278 require.NoError(t, err)
279 p, err := f.sessions.Verify(ctx, token)
280 require.NoError(t, err)
281 assert.True(t, p.IsOwner())
282 assert.Equal(t, "owner", p.Label())
283
284 cases := map[string]string{
285 "alg none": sign(t, jwt.SigningMethodNone, jwt.UnsafeAllowNoneSignatureType, claims{&zero, good}),
286 "hs384": sign(t, jwt.SigningMethodHS384, f.key, claims{&zero, good}),
287 "hs512": sign(t, jwt.SigningMethodHS512, f.key, claims{&zero, good}),
288 "wrong key": sign(t, jwt.SigningMethodHS256, []byte("another key of 32 bytes.........."), claims{&zero, good}),
289 "old epoch": sign(t, jwt.SigningMethodHS256, f.key, claims{&one, good}),
290 "no epoch": sign(t, jwt.SigningMethodHS256, f.key, claims{nil, good}),
291 "expired": sign(t, jwt.SigningMethodHS256, f.key, claims{&zero, reg("owner", now.Add(-2*time.Hour), now.Add(-time.Hour))}),
292 "no expiry": sign(t, jwt.SigningMethodHS256, f.key, claims{&zero, jwt.RegisteredClaims{Subject: "owner", IssuedAt: jwt.NewNumericDate(now)}}),
293 "future iat": sign(t, jwt.SigningMethodHS256, f.key, claims{&zero, reg("owner", now.Add(time.Hour), now.Add(2*time.Hour))}),
294 "other subject": sign(t, jwt.SigningMethodHS256, f.key, claims{&zero, reg("someone", now, now.Add(time.Hour))}),
295 "empty": "",
296 "garbage": "not.a.jwt",
297 "truncated": token[:len(token)-3],
298 "signature swap": token[:strings.LastIndex(token, ".")] + ".AAAA",
299 "payload only": strings.Split(token, ".")[1],
300 "three empty": "..",
301 }
302 for name, tok := range cases {
303 _, err := f.sessions.Verify(ctx, tok)
304 assert.ErrorIs(t, err, domain.ErrUnauthenticated, name)
305 }
306 // The control: the same claims signed right are accepted.
307 _, err = f.sessions.Verify(ctx, sign(t, jwt.SigningMethodHS256, f.key, claims{&zero, good}))
308 assert.NoError(t, err)
309
310 // Expiry after the session lifetime; and the epoch ends every session.
311 f.advance(usecase.SessionTTL)
312 _, err = f.sessions.Verify(ctx, token)
313 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "expired after 12 h")
314}
315
316func TestSignOutEverywhereEndsSessions(t *testing.T) {
317 t.Parallel()
318 ctx := context.Background()
319 f := newSessionFixture(t)
320 a, _, err := f.sessions.Login(ctx, f.nonce(t))
321 require.NoError(t, err)
322 b, _, err := f.sessions.Login(ctx, f.nonce(t))
323 require.NoError(t, err)
324 pending := f.nonce(t)
325
326 require.NoError(t, f.sessions.SignOutEverywhere(ctx, domain.Root()))
327 for _, tok := range []string{a, b} {
328 _, err := f.sessions.Verify(ctx, tok)
329 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
330 }
331 assert.True(t, f.sessions.LoginLinkValid(pending), "pending links are not affected")
332 c, _, err := f.sessions.Login(ctx, pending)
333 require.NoError(t, err)
334 _, err = f.sessions.Verify(ctx, c)
335 assert.NoError(t, err, "a new session works")
336}
337
338// The owner signed in on the web signs what it writes as "owner", where root
339// signs as "root" although both hold every right.
340func TestAuthorOfOwnerSession(t *testing.T) {
341 t.Parallel()
342 assert.Equal(t, "owner", usecase.AuthorOf(domain.Owner()))
343 assert.Equal(t, "root", usecase.AuthorOf(domain.Root()))
344 assert.Equal(t, "ci", usecase.AuthorOf(domain.TokenPrincipal(domain.Token{Label: "ci", Scopes: domain.AllScopes()})))
345}