carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "path/filepath"
5 "strings"
6 "testing"
7 "time"
8)
9
10func TestCLIAuthApproveThenPollIssuesOneToken(t *testing.T) {
11 s := openTestStore(t)
12 now := time.Now()
13
14 userCode, deviceCode, err := s.NewCLIAuth("paul@laptop", "192.0.2.7", now)
15 if err != nil {
16 t.Fatal(err)
17 }
18 if len(userCode) != userCodeLen+1 || !strings.Contains(userCode, "-") {
19 t.Fatalf("user code %q: want the grouped XXXX-XXXX shape", userCode)
20 }
21 if strings.ContainsAny(userCode, "01ILO") {
22 t.Fatalf("user code %q contains an ambiguous character", userCode)
23 }
24 if deviceCode == userCode || len(deviceCode) < 40 {
25 t.Fatalf("device code %q must be its own high-entropy secret", deviceCode)
26 }
27
28 req, err := s.CLIAuthByUserCode(userCode, now)
29 if err != nil {
30 t.Fatal(err)
31 }
32 if req.Label != "paul@laptop" || req.RemoteAddr != "192.0.2.7" {
33 t.Fatalf("request = %+v, want the label and address the CLI arrived with", req)
34 }
35
36 // Before approval the CLI is told to keep waiting and gets nothing.
37 if status, tok, _, err := s.PollCLIAuth(deviceCode, now); err != nil || status != CLIAuthPending || tok != "" {
38 t.Fatalf("poll before approval = %q %q %v, want pending and no token", status, tok, err)
39 }
40
41 if err := s.ApproveCLIAuth(userCode, "paul@keymail.dev", now); err != nil {
42 t.Fatal(err)
43 }
44 status, raw, who, err := s.PollCLIAuth(deviceCode, now)
45 if err != nil {
46 t.Fatal(err)
47 }
48 if status != CLIAuthApproved || raw == "" {
49 t.Fatalf("poll after approval = %q %q, want a token", status, raw)
50 }
51 if who != "paul@keymail.dev" {
52 t.Fatalf("poll returned address %q, want the approver's", who)
53 }
54 // Issuing is not using: a token nobody has spent yet must SAY so on the
55 // tokens page, or "last used" is worthless as a way to spot a stolen one.
56 toks, err := s.CLITokens("paul@keymail.dev")
57 if err != nil || len(toks) != 1 {
58 t.Fatalf("CLITokens = %v %v, want one", toks, err)
59 }
60 if !toks[0].LastUsed.IsZero() {
61 t.Fatalf("a freshly issued token reports last used %v", toks[0].LastUsed)
62 }
63
64 // The token is bound to the address that approved it.
65 addr, ok := s.CLITokenAddress(raw, now)
66 if !ok || addr != "paul@keymail.dev" {
67 t.Fatalf("CLITokenAddress = %q %v, want paul@keymail.dev", addr, ok)
68 }
69
70 // The request is single-use: a replayed device code issues nothing.
71 if status, tok, _, _ := s.PollCLIAuth(deviceCode, now); status != CLIAuthUnknown || tok != "" {
72 t.Fatalf("replayed poll = %q %q, want unknown and no token", status, tok)
73 }
74 if _, err := s.CLIAuthByUserCode(userCode, now); err == nil {
75 t.Fatal("the user code still resolves after the token was collected")
76 }
77}
78
79func TestCLIAuthDenyIsFinal(t *testing.T) {
80 s := openTestStore(t)
81 now := time.Now()
82 userCode, deviceCode, err := s.NewCLIAuth("paul@laptop", "192.0.2.7", now)
83 if err != nil {
84 t.Fatal(err)
85 }
86 if err := s.DenyCLIAuth(userCode, now); err != nil {
87 t.Fatal(err)
88 }
89 if status, tok, _, _ := s.PollCLIAuth(deviceCode, now); status != CLIAuthDenied || tok != "" {
90 t.Fatalf("poll after deny = %q %q, want denied", status, tok)
91 }
92 // Approving a denied request must not resurrect it.
93 if err := s.ApproveCLIAuth(userCode, "paul@keymail.dev", now); err == nil {
94 t.Fatal("a denied request was approvable")
95 }
96}
97
98func TestCLIAuthExpires(t *testing.T) {
99 s := openTestStore(t)
100 now := time.Now()
101 userCode, deviceCode, err := s.NewCLIAuth("paul@laptop", "192.0.2.7", now)
102 if err != nil {
103 t.Fatal(err)
104 }
105 later := now.Add(CLIAuthTTL + time.Second)
106 if _, err := s.CLIAuthByUserCode(userCode, later); err == nil {
107 t.Fatal("an expired user code still resolves for approval")
108 }
109 if err := s.ApproveCLIAuth(userCode, "paul@keymail.dev", later); err == nil {
110 t.Fatal("an expired request was approvable")
111 }
112 if status, tok, _, _ := s.PollCLIAuth(deviceCode, later); status != CLIAuthExpired || tok != "" {
113 t.Fatalf("poll after expiry = %q %q, want expired", status, tok)
114 }
115}
116
117// An approval that is never collected must not mint a token that outlives the
118// ten-minute window: the human approved a CLI that was already gone.
119func TestCLIAuthApprovedButUncollectedExpires(t *testing.T) {
120 s := openTestStore(t)
121 now := time.Now()
122 userCode, deviceCode, err := s.NewCLIAuth("paul@laptop", "192.0.2.7", now)
123 if err != nil {
124 t.Fatal(err)
125 }
126 if err := s.ApproveCLIAuth(userCode, "paul@keymail.dev", now); err != nil {
127 t.Fatal(err)
128 }
129 if status, tok, _, _ := s.PollCLIAuth(deviceCode, now.Add(CLIAuthTTL+time.Second)); status != CLIAuthExpired || tok != "" {
130 t.Fatalf("late collection = %q %q, want expired with no token", status, tok)
131 }
132}
133
134func TestCLITokenLifecycle(t *testing.T) {
135 s := openTestStore(t)
136 now := time.Now()
137 mint := func(label string) string {
138 t.Helper()
139 userCode, deviceCode, err := s.NewCLIAuth(label, "192.0.2.7", now)
140 if err != nil {
141 t.Fatal(err)
142 }
143 if err := s.ApproveCLIAuth(userCode, "paul@keymail.dev", now); err != nil {
144 t.Fatal(err)
145 }
146 _, raw, _, err := s.PollCLIAuth(deviceCode, now)
147 if err != nil || raw == "" {
148 t.Fatalf("mint %s: %v", label, err)
149 }
150 return raw
151 }
152 laptop := mint("paul@laptop")
153 desktop := mint("paul@desktop")
154
155 toks, err := s.CLITokens("paul@keymail.dev")
156 if err != nil {
157 t.Fatal(err)
158 }
159 if len(toks) != 2 {
160 t.Fatalf("CLITokens = %d entries, want 2", len(toks))
161 }
162 for _, tok := range toks {
163 if tok.ID == "" || tok.Address != "paul@keymail.dev" {
164 t.Fatalf("token row %+v is missing an id or address", tok)
165 }
166 if strings.Contains(laptop, tok.ID) || strings.Contains(desktop, tok.ID) {
167 t.Fatal("the listing id contains the bearer secret")
168 }
169 }
170 // Somebody else's listing is empty, and they cannot revoke what is not theirs.
171 if other, err := s.CLITokens("bob@keymail.dev"); err != nil || len(other) != 0 {
172 t.Fatalf("CLITokens(bob) = %v %v, want empty", other, err)
173 }
174 if err := s.RevokeCLIToken("bob@keymail.dev", toks[0].ID); err == nil {
175 t.Fatal("bob revoked paul's token")
176 }
177 if _, ok := s.CLITokenAddress(laptop, now); !ok {
178 t.Fatal("the laptop token stopped working after a refused revocation")
179 }
180
181 // Revoking by id (the console page) and by value (carlos auth logout).
182 var laptopID string
183 for _, tok := range toks {
184 if tok.Label == "paul@laptop" {
185 laptopID = tok.ID
186 }
187 }
188 if err := s.RevokeCLIToken("paul@keymail.dev", laptopID); err != nil {
189 t.Fatal(err)
190 }
191 if _, ok := s.CLITokenAddress(laptop, now); ok {
192 t.Fatal("a revoked token still authenticates")
193 }
194 if err := s.RevokeCLITokenValue(desktop); err != nil {
195 t.Fatal(err)
196 }
197 if _, ok := s.CLITokenAddress(desktop, now); ok {
198 t.Fatal("a token revoked by value still authenticates")
199 }
200
201 // Expiry is enforced on use, not just on listing.
202 fresh := mint("paul@ci")
203 if _, ok := s.CLITokenAddress(fresh, now.Add(CLITokenTTL+time.Second)); ok {
204 t.Fatal("an expired token still authenticates")
205 }
206 if _, ok := s.CLITokenAddress("not-a-token", now); ok {
207 t.Fatal("a made-up token authenticates")
208 }
209 if _, ok := s.CLITokenAddress("", now); ok {
210 t.Fatal("an empty token authenticates")
211 }
212}
213
214// mintUserToken issues an ordinary user-kind CLI token the way a human does:
215// open a login, approve it, collect it.
216func mintUserToken(t *testing.T, s *Store, address string, now time.Time) string {
217 t.Helper()
218 userCode, deviceCode, err := s.NewCLIAuth("paul@laptop", "192.0.2.7", now)
219 if err != nil {
220 t.Fatal(err)
221 }
222 if err := s.ApproveCLIAuth(userCode, address, now); err != nil {
223 t.Fatal(err)
224 }
225 _, raw, _, err := s.PollCLIAuth(deviceCode, now)
226 if err != nil || raw == "" {
227 t.Fatalf("collect token: %v", err)
228 }
229 return raw
230}
231
232// The containment property, both directions: an app token is invisible to
233// every user-token door, and a user token never resolves as a service.
234//
235// The app-token side is asserted through CLIPrincipal, which is the resolver
236// that exists — CLIAppToken was deleted once apiInstancePrincipal collapsed
237// into the shared principal path (service-principals ruling C27). Every
238// property it used to prove is proved here against its successor: the
239// binding it reads back, the kind check that stands alone from expiry, a
240// real expiry still honoured, and the four no-answers (a user token, a
241// made-up one, an empty one, a revoked one).
242func TestAppTokenContainment(t *testing.T) {
243 s := openTestStore(t)
244 now := time.Now()
245
246 app, err := s.NewAppToken(7, "tito", "paul@keymail.dev", "tito signup", now)
247 if err != nil {
248 t.Fatal(err)
249 }
250 if app == "" || len(app) < 40 {
251 t.Fatalf("app token %q: want a high-entropy secret", app)
252 }
253 p, ok := s.CLIPrincipal(app, now)
254 if !ok || p.Kind != PrincipalService || p.AccountID != 7 || p.App != "tito" {
255 t.Fatalf("CLIPrincipal = %+v %v, want the service principal bound to 7/tito", p, ok)
256 }
257 // The whole point: presented at a user door it must look like nothing.
258 if addr, ok := s.CLITokenAddress(app, now); ok {
259 t.Fatalf("an app token authenticated at a user door as %q", addr)
260 }
261 // And on the kind ALONE — an app token with a live expiry (the column
262 // still honours one) must be refused because of what it is, not because
263 // the "never" sentinel happens to read as long expired.
264 dated, err := randomToken()
265 if err != nil {
266 t.Fatal(err)
267 }
268 if _, err := s.db.Exec(`INSERT INTO cli_tokens
269(token_hash, address, label, remote_addr, created_at, expires_at, kind, account_id, app)
270VALUES (?, 'paul@keymail.dev', 'dated', '', ?, ?, 'app', 9, 'tito')`,
271 hashToken(dated), now.Unix(), now.Add(CLITokenTTL).Unix()); err != nil {
272 t.Fatal(err)
273 }
274 if addr, ok := s.CLITokenAddress(dated, now); ok {
275 t.Fatalf("an unexpired app token authenticated at a user door as %q", addr)
276 }
277 if p, ok := s.CLIPrincipal(dated, now); !ok || p.Kind != PrincipalService || p.AccountID != 9 || p.App != "tito" {
278 t.Fatalf("CLIPrincipal(dated) = %+v %v, want the service principal bound to 9/tito", p, ok)
279 }
280 if _, ok := s.CLIPrincipal(dated, now.Add(CLITokenTTL+time.Second)); ok {
281 t.Fatal("an app token with a real expiry outlived it")
282 }
283
284 user := mintUserToken(t, s, "paul@keymail.dev", now)
285 if addr, ok := s.CLITokenAddress(user, now); !ok || addr != "paul@keymail.dev" {
286 t.Fatalf("CLITokenAddress = %q %v, want the approver", addr, ok)
287 }
288 // The other direction. CLIPrincipal answers for a user token — it is the
289 // one resolver for every kind now — so the containment it holds is that
290 // a person never reads back as a SERVICE, which is the property the app
291 // gate used to hold by refusing them outright.
292 if p, ok := s.CLIPrincipal(user, now); !ok || p.Kind != PrincipalUser || p.Address != "paul@keymail.dev" {
293 t.Fatalf("CLIPrincipal(user) = %+v %v, want paul as a person", p, ok)
294 }
295
296 // Unknown and empty tokens answer no at both doors.
297 if _, ok := s.CLIPrincipal("not-a-token", now); ok {
298 t.Fatal("a made-up token resolved to a principal")
299 }
300 if _, ok := s.CLIPrincipal("", now); ok {
301 t.Fatal("an empty token resolved to a principal")
302 }
303}
304
305// App tokens never expire (spec §8: a production signup path must not die on
306// day 91); user tokens still do, at CLITokenTTL.
307func TestAppTokenNeverExpires(t *testing.T) {
308 s := openTestStore(t)
309 now := time.Now()
310 app, err := s.NewAppToken(7, "tito", "paul@keymail.dev", "tito signup", now)
311 if err != nil {
312 t.Fatal(err)
313 }
314 user := mintUserToken(t, s, "paul@keymail.dev", now)
315
316 decade := now.Add(10 * 365 * 24 * time.Hour)
317 if _, ok := s.CLIPrincipal(app, decade); !ok {
318 t.Fatal("an app token expired")
319 }
320 if _, ok := s.CLITokenAddress(user, now.Add(CLITokenTTL+time.Second)); ok {
321 t.Fatal("a user token outlived CLITokenTTL")
322 }
323
324 // A "never" expiry must survive the housekeeping sweep, which deletes by
325 // expires_at — the sweep is where an unguarded 0 quietly kills the fleet.
326 lastCLIPurge.Store(0)
327 defer lastCLIPurge.Store(0)
328 s.PurgeCLIAuth(decade)
329 if _, ok := s.CLIPrincipal(app, decade); !ok {
330 t.Fatal("the purge swept an app token that never expires")
331 }
332}
333
334// An app token lists and revokes under the address that minted it — that
335// listing is the only control over a credential with no expiry.
336func TestAppTokenRevocation(t *testing.T) {
337 s := openTestStore(t)
338 now := time.Now()
339 app, err := s.NewAppToken(7, "tito", "paul@keymail.dev", "tito signup", now)
340 if err != nil {
341 t.Fatal(err)
342 }
343 toks, err := s.CLITokens("paul@keymail.dev")
344 if err != nil || len(toks) != 1 {
345 t.Fatalf("CLITokens = %v %v, want the app token under its minter", toks, err)
346 }
347 if toks[0].Label != "tito signup" || toks[0].ID == "" {
348 t.Fatalf("token row %+v is missing its label or id", toks[0])
349 }
350 if strings.Contains(app, toks[0].ID) {
351 t.Fatal("the listing id contains the bearer secret")
352 }
353 if err := s.RevokeCLIToken("bob@keymail.dev", toks[0].ID); err == nil {
354 t.Fatal("bob revoked paul's app token")
355 }
356 if err := s.RevokeCLIToken("paul@keymail.dev", toks[0].ID); err != nil {
357 t.Fatal(err)
358 }
359 if _, ok := s.CLIPrincipal(app, now); ok {
360 t.Fatal("a revoked app token still authenticates")
361 }
362}
363
364// The migration is additive and idempotent: reopening an existing database
365// re-runs the ALTERs (duplicate column swallowed) and rows written before the
366// change read back as the empty (user) kind — still user tokens, still working.
367func TestCLITokenMigrationReopens(t *testing.T) {
368 path := filepath.Join(t.TempDir(), "console.db")
369 now := time.Now()
370
371 first, err := Open(path)
372 if err != nil {
373 t.Fatal(err)
374 }
375 user := mintUserToken(t, first, "paul@keymail.dev", now)
376 app, err := first.NewAppToken(7, "tito", "paul@keymail.dev", "tito signup", now)
377 if err != nil {
378 t.Fatal(err)
379 }
380 if err := first.Close(); err != nil {
381 t.Fatal(err)
382 }
383
384 second, err := Open(path)
385 if err != nil {
386 t.Fatalf("reopen: %v", err)
387 }
388 defer second.Close()
389 if addr, ok := second.CLITokenAddress(user, now); !ok || addr != "paul@keymail.dev" {
390 t.Fatalf("after reopen CLITokenAddress = %q %v, want the approver", addr, ok)
391 }
392 if p, ok := second.CLIPrincipal(app, now); !ok || p.Kind != PrincipalService || p.AccountID != 7 || p.App != "tito" {
393 t.Fatalf("after reopen CLIPrincipal = %+v %v, want the service principal bound to 7/tito", p, ok)
394 }
395 if _, ok := second.CLITokenAddress(app, now); ok {
396 t.Fatal("after reopen an app token authenticated at a user door")
397 }
398}
399
400// TestCLITokensReportsKind: the listing an owner acts on must say WHAT each
401// row is. An app token has no expiry (expires_at 0), so a page that reads
402// only ExpiresAt renders it as 1 Jan 1970 — a credential that reads as
403// long-expired is one nobody thinks to rotate. Kind and App are what make
404// the row honest.
405func TestCLITokensReportsKind(t *testing.T) {
406 s := openTestStore(t)
407 now := time.Now()
408 mintUserToken(t, s, "paul@keymail.dev", now)
409 if _, err := s.NewAppToken(7, "tito", "paul@keymail.dev", "tito signup", now); err != nil {
410 t.Fatal(err)
411 }
412 toks, err := s.CLITokens("paul@keymail.dev")
413 if err != nil {
414 t.Fatal(err)
415 }
416 if len(toks) != 2 {
417 t.Fatalf("CLITokens = %d rows, want the user token and the app token", len(toks))
418 }
419 var user, app *CLIToken
420 for i := range toks {
421 if toks[i].Kind == cliKindApp {
422 app = &toks[i]
423 } else {
424 user = &toks[i]
425 }
426 }
427 if user == nil || app == nil {
428 t.Fatalf("CLITokens = %+v, want one row of each kind", toks)
429 }
430 if app.App != "tito" || app.AccountID != 7 {
431 t.Errorf("app token row = %+v, want it bound to account 7 / tito", app)
432 }
433 if !app.NeverExpires() {
434 t.Errorf("app token row = %+v, want NeverExpires (expires_at 0 is \"never\")", app)
435 }
436 if user.NeverExpires() {
437 t.Errorf("user token row = %+v, want a real expiry", user)
438 }
439}
440