oficina / home Public

Clone
git clone https://amadan.net/oficina/home

Plain git — no account needed to clone.

Download

Download this file

1package linkstore
2
3import (
4 "bytes"
5 "context"
6 "database/sql"
7 "encoding/base64"
8 "errors"
9 "net/url"
10 "os"
11 "path/filepath"
12 "strings"
13 "sync"
14 "sync/atomic"
15 "testing"
16 "time"
17
18 _ "modernc.org/sqlite"
19)
20
21var ctx = context.Background()
22var testNow = time.Unix(1788865200, 0)
23
24const origin = "https://test.docs.oficina.works"
25const issuer = "https://id.oficina.works"
26
27func fixture(t *testing.T) (*Store, string) {
28 t.Helper()
29 path := filepath.Join(t.TempDir(), "docs.db")
30 d, err := sql.Open("sqlite", (&url.URL{Scheme: "file", Path: path}).String()+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)")
31 if err != nil {
32 t.Fatal(err)
33 }
34 t.Cleanup(func() { d.Close() })
35 _, err = d.Exec(`CREATE TABLE people (id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE); INSERT INTO people VALUES (1,'one@example.com'),(2,'two@example.com'); CREATE TABLE old_content (owner_id INTEGER, bytes BLOB); INSERT INTO old_content VALUES (1,x'00010203'),(2,x'04050607')`)
36 if err != nil {
37 t.Fatal(err)
38 }
39 for _, name := range []string{"0001_links.sql", "0002_person_binding.sql"} {
40 migration, err := migrations.ReadFile("migrations/" + name)
41 if err != nil {
42 t.Fatal(err)
43 }
44 if _, err := d.Exec(string(migration)); err != nil {
45 t.Fatal(err)
46 }
47 }
48 s, err := New(d, bytes.Repeat([]byte{42}, 32), origin, issuer, "oficina/docs", testLookup)
49 if err != nil {
50 t.Fatal(err)
51 }
52 if err := s.Sweep(ctx, testNow); err != nil {
53 t.Fatal(err)
54 }
55 return s, path
56}
57func local(id int64) Local {
58 return Local{PersonID: id, SessionToken: strings.Repeat(string(rune('a'+id)), 43), AuthTime: testNow.Add(-time.Minute)}
59}
60func nonce(n byte) string { return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{n}, 16)) }
61func start(t *testing.T, s *Store, l Local, n string) string {
62 t.Helper()
63 secret, err := s.Start(ctx, l, "/d/private-document?view=reading", testNow)
64 if err != nil {
65 t.Fatal(err)
66 }
67 if err := s.BindNonce(ctx, secret, l, n, testNow); err != nil {
68 t.Fatal(err)
69 }
70 return secret
71}
72func complete(t *testing.T, s *Store, l Local, subject string, n byte) string {
73 t.Helper()
74 secret := start(t, s, l, nonce(n))
75 pending, err := s.Complete(ctx, secret, l, Home{Issuer: issuer, Subject: subject, Nonce: nonce(n), AuthTime: testNow.Add(-30 * time.Second)}, testNow)
76 if err != nil {
77 t.Fatal(err)
78 }
79 return pending
80}
81func refused(t *testing.T, err error) {
82 t.Helper()
83 if !errors.Is(err, ErrRefused) {
84 t.Fatalf("error = %v, want generic refusal", err)
85 }
86}
87func flowCount(t *testing.T, s *Store) int {
88 t.Helper()
89 var count int
90 if err := s.db.QueryRow("SELECT COUNT(*) FROM home_link_flows").Scan(&count); err != nil {
91 t.Fatal(err)
92 }
93 return count
94}
95
96func TestExplicitLinkPreservesPeopleAndContent(t *testing.T) {
97 s, _ := fixture(t)
98 for _, id := range []int64{1, 2} {
99 l := local(id)
100 subject := "opaque-home-" + string(rune('a'+id))
101 cookie := complete(t, s, l, subject, byte(id))
102 p, err := s.Pending(ctx, cookie, l, testNow)
103 if err != nil || p.PersonID != id || p.ReturnPath != "/d/private-document?view=reading" {
104 t.Fatalf("pending=%+v error=%v", p, err)
105 }
106 _, _, err = s.Resolve(ctx, issuer, subject)
107 refused(t, err)
108 got, err := s.Confirm(ctx, cookie, l, testNow)
109 if err != nil || got.PersonID != id {
110 t.Fatalf("confirm=%+v error=%v", got, err)
111 }
112 linked, address, err := s.Resolve(ctx, issuer, subject)
113 if err != nil || linked != id || address == "" {
114 t.Fatalf("resolve=%d,%q,%v", linked, address, err)
115 }
116 _, err = s.Confirm(ctx, cookie, l, testNow)
117 refused(t, err)
118 }
119 var count int
120 if err := s.db.QueryRow("SELECT COUNT(*) FROM people").Scan(&count); err != nil || count != 2 {
121 t.Fatalf("people changed: %d %v", count, err)
122 }
123 var content string
124 if err := s.db.QueryRow("SELECT group_concat(hex(bytes), ',') FROM old_content ORDER BY owner_id").Scan(&content); err != nil || content != "00010203,04050607" {
125 t.Fatalf("legacy content changed: %q %v", content, err)
126 }
127 if _, err := s.db.Exec("UPDATE people SET email = 'new@example.com' WHERE id = 1"); err != nil {
128 t.Fatal(err)
129 }
130 _, _, err := s.Resolve(ctx, issuer, "opaque-home-b")
131 refused(t, err)
132 _, _, err = s.Resolve(ctx, "https://other.example", "opaque-home-b")
133 refused(t, err)
134 if flowCount(t, s) != 0 {
135 t.Fatal("consumed rows remain")
136 }
137}
138
139func TestInitiationAndCompletionBindingsBurnFailures(t *testing.T) {
140 for _, which := range []string{"missing-init", "unbound-nonce", "nonce", "person", "session", "local-auth", "issuer", "home-stale", "home-future", "local-stale", "local-future"} {
141 t.Run(which, func(t *testing.T) {
142 s, _ := fixture(t)
143 l := local(1)
144 n := nonce(1)
145 secret, err := s.Start(ctx, l, "/", testNow)
146 if err != nil {
147 t.Fatal(err)
148 }
149 if which != "unbound-nonce" {
150 if err := s.BindNonce(ctx, secret, l, n, testNow); err != nil {
151 t.Fatal(err)
152 }
153 }
154 home := Home{Issuer: issuer, Subject: "opaque", Nonce: n, AuthTime: testNow}
155 switch which {
156 case "missing-init":
157 secret = base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{80}, 32))
158 case "nonce":
159 home.Nonce = nonce(2)
160 case "person":
161 l.PersonID = 2
162 case "session":
163 l.SessionToken = "different-actual-cookie"
164 case "local-auth":
165 l.AuthTime = testNow
166 case "issuer":
167 home.Issuer = "https://evil.example"
168 case "home-stale":
169 home.AuthTime = testNow.Add(-301 * time.Second)
170 case "home-future":
171 home.AuthTime = testNow.Add(time.Second)
172 case "local-stale":
173 l.AuthTime = testNow.Add(-301 * time.Second)
174 case "local-future":
175 l.AuthTime = testNow.Add(time.Second)
176 }
177 got, err := s.Complete(ctx, secret, l, home, testNow)
178 refused(t, err)
179 if got != "" {
180 t.Fatal("rejected completion returned cookie")
181 }
182 _, err = s.Complete(ctx, secret, local(1), Home{Issuer: issuer, Subject: "opaque", Nonce: n, AuthTime: testNow}, testNow)
183 refused(t, err)
184 if which != "missing-init" && flowCount(t, s) != 0 {
185 t.Fatal("failed initiation was reusable")
186 }
187 })
188 }
189}
190
191func TestBindNonceOnceAndExactLocalSession(t *testing.T) {
192 for _, which := range []string{"duplicate", "person", "session", "invalid-nonce"} {
193 t.Run(which, func(t *testing.T) {
194 s, _ := fixture(t)
195 l := local(1)
196 secret, err := s.Start(ctx, l, "/", testNow)
197 if err != nil {
198 t.Fatal(err)
199 }
200 n := nonce(1)
201 if which == "duplicate" {
202 if err := s.BindNonce(ctx, secret, l, n, testNow); err != nil {
203 t.Fatal(err)
204 }
205 }
206 if which == "person" {
207 l.PersonID = 2
208 }
209 if which == "session" {
210 l.SessionToken = "changed"
211 }
212 if which == "invalid-nonce" {
213 n += "="
214 }
215 refused(t, s.BindNonce(ctx, secret, l, n, testNow))
216 refused(t, s.BindNonce(ctx, secret, local(1), nonce(2), testNow))
217 })
218 }
219}
220
221func TestConfirmationFreshnessAndLocalBinding(t *testing.T) {
222 for _, which := range []string{"person", "session", "auth-time", "expired", "future-after-rollback"} {
223 t.Run(which, func(t *testing.T) {
224 s, _ := fixture(t)
225 l := local(1)
226 cookie := complete(t, s, l, "opaque", 1)
227 now := testNow
228 switch which {
229 case "person":
230 l.PersonID = 2
231 case "session":
232 l.SessionToken = "new-cookie"
233 case "auth-time":
234 l.AuthTime = testNow
235 case "expired":
236 now = now.Add(241 * time.Second)
237 case "future-after-rollback":
238 // Complete a proof with a genuinely current Home gesture, then roll back
239 // within the accepted clock tolerance. It must not earn future credit.
240 init := start(t, s, l, nonce(2))
241 var err error
242 cookie, err = s.Complete(ctx, init, l, Home{Issuer: issuer, Subject: "opaque", Nonce: nonce(2), AuthTime: testNow}, testNow)
243 if err != nil {
244 t.Fatal(err)
245 }
246 now = now.Add(-time.Second)
247 }
248 _, err := s.Confirm(ctx, cookie, l, now)
249 refused(t, err)
250 _, err = s.Confirm(ctx, cookie, local(1), now)
251 refused(t, err)
252 _, _, err = s.Resolve(ctx, issuer, "opaque")
253 refused(t, err)
254 })
255 }
256}
257
258func TestConflictConsumedWithoutReplacingEitherMapping(t *testing.T) {
259 s, _ := fixture(t)
260 l1, l2 := local(1), local(2)
261 first := complete(t, s, l1, "subject-one", 1)
262 if _, err := s.Confirm(ctx, first, l1, testNow); err != nil {
263 t.Fatal(err)
264 }
265 identical := complete(t, s, l1, "subject-one", 2)
266 if _, err := s.Confirm(ctx, identical, l1, testNow); err != nil {
267 t.Fatal("separate identical proof should be idempotent", err)
268 }
269 for _, tc := range []struct {
270 local Local
271 sub string
272 }{{l2, "subject-one"}, {l1, "subject-two"}} {
273 cookie := complete(t, s, tc.local, tc.sub, 3)
274 _, err := s.Confirm(ctx, cookie, tc.local, testNow)
275 refused(t, err)
276 // Remove the competing mapping only in the fixture: an unconsumed failed
277 // proof would now succeed, making this a direct replay regression test.
278 if _, err := s.db.Exec("DELETE FROM home_identity_links"); err != nil {
279 t.Fatal(err)
280 }
281 _, err = s.Confirm(ctx, cookie, tc.local, testNow)
282 refused(t, err)
283 if _, err := s.db.Exec("INSERT INTO home_identity_links VALUES (?,?,?,?)", issuer, "subject-one", 1, s.personBinding(1, "one@example.com")); err != nil {
284 t.Fatal(err)
285 }
286 }
287 id, _, err := s.Resolve(ctx, issuer, "subject-one")
288 if err != nil || id != 1 {
289 t.Fatalf("mapping replaced: %d %v", id, err)
290 }
291}
292
293func TestConcurrentConfirmationAndCapacity(t *testing.T) {
294 s, _ := fixture(t)
295 l := local(1)
296 cookie := complete(t, s, l, "subject", 1)
297 var winners atomic.Int32
298 var wg sync.WaitGroup
299 for i := 0; i < 12; i++ {
300 wg.Add(1)
301 go func() {
302 defer wg.Done()
303 _, err := s.Confirm(ctx, cookie, l, testNow)
304 if err == nil {
305 winners.Add(1)
306 } else if !errors.Is(err, ErrRefused) {
307 t.Errorf("unexpected concurrent failure: %v", err)
308 }
309 }()
310 }
311 wg.Wait()
312 if winners.Load() != 1 {
313 t.Fatalf("winners=%d", winners.Load())
314 }
315 _, err := s.db.Exec(`WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x < 9999) INSERT INTO home_link_flows SELECT printf('%064d',x),'init',?,x'00' FROM n`, testNow.Unix()+300)
316 if err != nil {
317 t.Fatal(err)
318 }
319 winners.Store(0)
320 for i := 0; i < 8; i++ {
321 wg.Add(1)
322 go func() {
323 defer wg.Done()
324 _, err := s.Start(ctx, l, "/", testNow)
325 if err == nil {
326 winners.Add(1)
327 } else if !errors.Is(err, ErrRefused) {
328 t.Errorf("unexpected capacity failure: %v", err)
329 }
330 }()
331 }
332 wg.Wait()
333 if winners.Load() != 1 || flowCount(t, s) != 10000 {
334 t.Fatalf("capacity winners=%d count=%d", winners.Load(), flowCount(t, s))
335 }
336 if err := s.Sweep(ctx, testNow.Add(301*time.Second)); err != nil {
337 t.Fatal(err)
338 }
339 if flowCount(t, s) != 0 {
340 t.Fatal("expired rows survived sweep")
341 }
342}
343
344func TestSealedPendingAndTamperRefusal(t *testing.T) {
345 for _, which := range []string{"ciphertext", "row", "origin", "expiry", "key"} {
346 t.Run(which, func(t *testing.T) {
347 s, _ := fixture(t)
348 l := local(1)
349 cookie := complete(t, s, l, "private-subject", 1)
350 switch which {
351 case "ciphertext":
352 if _, err := s.db.Exec("UPDATE home_link_flows SET payload = zeroblob(length(payload))"); err != nil {
353 t.Fatal(err)
354 }
355 case "row":
356 replacement, _ := newSecret()
357 if _, err := s.db.Exec("UPDATE home_link_flows SET id_hash = ?", hash(replacement)); err != nil {
358 t.Fatal(err)
359 }
360 cookie = replacement
361 case "origin":
362 s.origin = "https://other.docs.oficina.works"
363 case "expiry":
364 if _, err := s.db.Exec("UPDATE home_link_flows SET expires_at = expires_at + 300"); err != nil {
365 t.Fatal(err)
366 }
367 case "key":
368 var err error
369 s, err = New(s.db, bytes.Repeat([]byte{43}, 32), origin, issuer, "oficina/docs", testLookup)
370 if err != nil {
371 t.Fatal(err)
372 }
373 }
374 _, err := s.Confirm(ctx, cookie, l, testNow)
375 refused(t, err)
376 if flowCount(t, s) != 0 {
377 t.Fatal("tampered row remains reusable")
378 }
379 })
380 }
381}
382
383func TestNoPendingSecretsInSQLiteOrWAL(t *testing.T) {
384 s, path := fixture(t)
385 l := local(1)
386 n := nonce(8)
387 init := start(t, s, l, n)
388 cookie, err := s.Complete(ctx, init, l, Home{Issuer: issuer, Subject: "private-subject-should-be-sealed", Nonce: n, AuthTime: testNow}, testNow)
389 if err != nil {
390 t.Fatal(err)
391 }
392 for _, file := range []string{path, path + "-wal"} {
393 raw, err := os.ReadFile(file)
394 if err != nil {
395 t.Fatal(err)
396 }
397 for _, secret := range []string{init, cookie, l.SessionToken, n, "private-subject-should-be-sealed", "/d/private-document?view=reading"} {
398 if bytes.Contains(raw, []byte(secret)) {
399 t.Fatalf("plaintext in %s", filepath.Base(file))
400 }
401 }
402 }
403 reopened, err := New(s.db, bytes.Repeat([]byte{42}, 32), origin, issuer, "oficina/docs", testLookup)
404 if err != nil {
405 t.Fatal(err)
406 }
407 if _, err := reopened.Confirm(ctx, cookie, l, testNow); err != nil {
408 t.Fatal("sealed pending must survive store restart", err)
409 }
410}
411
412func TestClockHighWaterPreventsRevivedAuthentication(t *testing.T) {
413 s, _ := fixture(t)
414 l := local(1)
415 cookie := complete(t, s, l, "subject", 1)
416 if err := s.Sweep(ctx, testNow.Add(241*time.Second)); err != nil {
417 t.Fatal(err)
418 }
419 _, err := s.Confirm(ctx, cookie, l, testNow.Add(220*time.Second))
420 refused(t, err)
421 if err := s.Sweep(ctx, testNow); !errors.Is(err, ErrRefused) {
422 t.Fatalf("large clock rollback=%v", err)
423 }
424}
425
426func TestSafePathsAndInvalidInputs(t *testing.T) {
427 for _, path := range []string{"", "https://evil.example", "//evil.example", "/auth/finish", "/%2fhost", "/d/../auth/finish", "/d\\evil", "/d/x\n"} {
428 t.Run(path, func(t *testing.T) {
429 s, _ := fixture(t)
430 l := local(1)
431 init, err := s.Start(ctx, l, path, testNow)
432 if err != nil {
433 t.Fatal(err)
434 }
435 if err := s.BindNonce(ctx, init, l, nonce(1), testNow); err != nil {
436 t.Fatal(err)
437 }
438 cookie, err := s.Complete(ctx, init, l, Home{Issuer: issuer, Subject: "subject", Nonce: nonce(1), AuthTime: testNow}, testNow)
439 if err != nil {
440 t.Fatal(err)
441 }
442 p, err := s.Pending(ctx, cookie, l, testNow)
443 if err != nil || p.ReturnPath != "/" {
444 t.Fatalf("unsafe return=%q %v", p.ReturnPath, err)
445 }
446 })
447 }
448 s, _ := fixture(t)
449 for _, l := range []Local{{}, {PersonID: 99, SessionToken: "actual", AuthTime: testNow}, {PersonID: 1, SessionToken: "actual", AuthTime: testNow.Add(time.Second)}, {PersonID: 1, SessionToken: "actual", AuthTime: testNow.Add(-301 * time.Second)}} {
450 _, err := s.Start(ctx, l, "/", testNow)
451 refused(t, err)
452 }
453 for _, cookie := range []string{"", "short", strings.Repeat("A", 43) + "=", strings.Repeat("A", 42) + "B"} {
454 _, err := s.Confirm(ctx, cookie, local(1), testNow)
455 refused(t, err)
456 }
457}
458
459func TestMappingStorageFailureRollsBackProofConsumption(t *testing.T) {
460 s, _ := fixture(t)
461 l := local(1)
462 cookie := complete(t, s, l, "subject", 1)
463 if _, err := s.db.Exec(`CREATE TRIGGER block_link BEFORE INSERT ON home_identity_links BEGIN SELECT RAISE(ABORT, 'private internal storage detail'); END`); err != nil {
464 t.Fatal(err)
465 }
466 _, err := s.Confirm(ctx, cookie, l, testNow)
467 if !errors.Is(err, ErrStorage) || strings.Contains(err.Error(), "private") {
468 t.Fatalf("storage error = %v", err)
469 }
470 if flowCount(t, s) != 1 {
471 t.Fatal("storage failure consumed proof without mapping")
472 }
473 if _, err := s.db.Exec("DROP TRIGGER block_link"); err != nil {
474 t.Fatal(err)
475 }
476 if _, err := s.Confirm(ctx, cookie, l, testNow); err != nil {
477 t.Fatal("retry after storage recovery", err)
478 }
479}
480
481func TestConcurrentConflictingSubjectsCannotReplacePersonLink(t *testing.T) {
482 s, _ := fixture(t)
483 l := local(1)
484 a, b := complete(t, s, l, "subject-a", 1), complete(t, s, l, "subject-b", 2)
485 var winners atomic.Int32
486 var wg sync.WaitGroup
487 for _, cookie := range []string{a, b} {
488 wg.Add(1)
489 go func() {
490 defer wg.Done()
491 _, err := s.Confirm(ctx, cookie, l, testNow)
492 if err == nil {
493 winners.Add(1)
494 } else if !errors.Is(err, ErrRefused) {
495 t.Errorf("conflicting confirmation: %v", err)
496 }
497 }()
498 }
499 wg.Wait()
500 if winners.Load() != 1 || flowCount(t, s) != 0 {
501 t.Fatalf("conflict winners=%d remaining=%d", winners.Load(), flowCount(t, s))
502 }
503 for _, cookie := range []string{a, b} {
504 _, err := s.Confirm(ctx, cookie, l, testNow)
505 refused(t, err)
506 }
507}
508
509func TestPendingReadConsumesChangedSession(t *testing.T) {
510 s, _ := fixture(t)
511 l := local(1)
512 cookie := complete(t, s, l, "subject", 1)
513 changed := l
514 changed.SessionToken = "rotated-cookie"
515 _, err := s.Pending(ctx, cookie, changed, testNow)
516 refused(t, err)
517 _, err = s.Confirm(ctx, cookie, l, testNow)
518 refused(t, err)
519}
520
521func TestFreshnessBoundaryProducesNoExpiredNewRows(t *testing.T) {
522 s, _ := fixture(t)
523 l := local(1)
524 l.AuthTime = testNow.Add(-300 * time.Second)
525 _, err := s.Start(ctx, l, "/", testNow)
526 refused(t, err)
527 l = local(1)
528 init := start(t, s, l, nonce(1))
529 _, err = s.Complete(ctx, init, l, Home{Issuer: issuer, Subject: "subject", Nonce: nonce(1), AuthTime: testNow.Add(-300 * time.Second)}, testNow)
530 refused(t, err)
531 if flowCount(t, s) != 0 {
532 t.Fatal("expired state minted at exact five-minute boundary")
533 }
534}
535
536func testLookup(ctx context.Context, q Queryer, id int64) (string, error) {
537 var label string
538 err := q.QueryRowContext(ctx, "SELECT email FROM people WHERE id=? AND email<>''", id).Scan(&label)
539 return label, err
540}
541
542func TestLookupUsesLinkTransactionAndRechecksAdmission(t *testing.T) {
543 s, _ := fixture(t)
544 calls := 0
545 allowed := true
546 s.lookup = func(ctx context.Context, q Queryer, id int64) (string, error) {
547 if _, ok := q.(*sql.Tx); !ok {
548 t.Fatal("link proof lookup escaped transaction")
549 }
550 calls++
551 if !allowed {
552 return "", sql.ErrNoRows
553 }
554 return testLookup(ctx, q, id)
555 }
556 l := local(1)
557 proof := complete(t, s, l, "subject-one", 41)
558 allowed = false
559 _, err := s.Confirm(ctx, proof, l, testNow)
560 refused(t, err)
561 allowed = true
562 _, err = s.Confirm(ctx, proof, l, testNow)
563 refused(t, err)
564 var count int
565 if err = s.db.QueryRow("SELECT COUNT(*) FROM home_identity_links").Scan(&count); err != nil || count != 0 {
566 t.Fatal("revoked local account linked", count, err)
567 }
568 if calls < 3 {
569 t.Fatal("missing current account rechecks", calls)
570 }
571}
572
573func TestResolveRechecksAppAdmissionAndDoesNotRevealLookupErrors(t *testing.T) {
574 s, _ := fixture(t)
575 l := local(1)
576 proof := complete(t, s, l, "subject-one", 42)
577 if _, err := s.Confirm(ctx, proof, l, testNow); err != nil {
578 t.Fatal(err)
579 }
580 s.lookup = func(context.Context, Queryer, int64) (string, error) { return "", sql.ErrNoRows }
581 _, _, err := s.Resolve(ctx, issuer, "subject-one")
582 refused(t, err)
583 s.lookup = func(context.Context, Queryer, int64) (string, error) {
584 return "", errors.New("private-address@example.invalid")
585 }
586 _, _, err = s.Resolve(ctx, issuer, "subject-one")
587 if err != ErrStorage || strings.Contains(err.Error(), "private-address") {
588 t.Fatal("lookup error escaped", err)
589 }
590}
591
592func TestPersonBindingRefusesReusedIDAtEveryStage(t *testing.T) {
593 for _, stage := range []string{"bind", "complete", "pending", "confirm", "resolve"} {
594 t.Run(stage, func(t *testing.T) {
595 s, _ := fixture(t)
596 l := local(1)
597 secret, err := s.Start(ctx, l, "/", testNow)
598 if err != nil {
599 t.Fatal(err)
600 }
601 nonce := nonce(42)
602 if stage != "bind" {
603 if err := s.BindNonce(ctx, secret, l, nonce, testNow); err != nil {
604 t.Fatal(err)
605 }
606 }
607 if stage != "bind" && stage != "complete" {
608 secret, err = s.Complete(ctx, secret, l, Home{Issuer: issuer, Subject: "person", Nonce: nonce, AuthTime: testNow}, testNow)
609 if err != nil {
610 t.Fatal(err)
611 }
612 }
613 if stage == "resolve" {
614 if _, err := s.Confirm(ctx, secret, l, testNow); err != nil {
615 t.Fatal(err)
616 }
617 }
618 if _, err := s.db.Exec("DELETE FROM people WHERE id=1; INSERT INTO people VALUES(1,'replacement@example.com')"); err != nil {
619 t.Fatal(err)
620 }
621 switch stage {
622 case "bind":
623 err = s.BindNonce(ctx, secret, l, nonce, testNow)
624 case "complete":
625 _, err = s.Complete(ctx, secret, l, Home{Issuer: issuer, Subject: "person", Nonce: nonce, AuthTime: testNow}, testNow)
626 case "pending":
627 _, err = s.Pending(ctx, secret, l, testNow)
628 case "confirm":
629 _, err = s.Confirm(ctx, secret, l, testNow)
630 case "resolve":
631 _, _, err = s.Resolve(ctx, issuer, "person")
632 }
633 refused(t, err)
634 if stage != "resolve" && flowCount(t, s) != 0 {
635 t.Fatal("rejected proof was not consumed")
636 }
637 })
638 }
639}
640func TestUnboundLegacyMappingFailsClosed(t *testing.T) {
641 s, _ := fixture(t)
642 if _, err := s.db.Exec("INSERT INTO home_identity_links(issuer,subject,person_id) VALUES(?,?,?)", issuer, "legacy", 1); err != nil {
643 t.Fatal(err)
644 }
645 _, _, err := s.Resolve(ctx, issuer, "legacy")
646 refused(t, err)
647}
648