web/5: drive the ladder in a real browser
The HTTP tests prove the handoff relays the right bytes. These prove the part a person actually experiences, which only a real engine shows. - A browser that is signed in but has forgotten its Account Key — exactly the state a new laptop is in — is offered the ladder instead of the dead end the vault gate used to show. - Typing the code off the card gets the key in, and the vault then opens with the passphrase alone. That is the promise the whole Account Key design rests on, and it had never been executed end to end. - One mistyped character still lands: the checksum fails, the passphrase field appears, repairCode enumerates the candidates and the KCV picks the real one. The most intricate path in the app and the least likely to be tried by hand. - The reissue button is disabled until the consequence is acknowledged, and the nag shows for the only enrolled device. forgetTheAccountKey drops the cache without dropping the session, which is the state a fresh device is really in — signed in, and unable to decrypt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+188
−0
internal/contratest/browser_ladder_test.go+188 −0
Intent
Attributed to this commit: 648 g CO₂e (75418681 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on web/device-ladder, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.
diff --git a/internal/contratest/browser_ladder_test.go b/internal/contratest/browser_ladder_test.go| new file mode 100644 |
| index 0000000..df884c1 |
| --- /dev/null |
| +++ b/internal/contratest/browser_ladder_test.go |
| @@ -0,0 +1,188 @@ |
| +//go:build browser |
| + |
| +package contratest |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| + |
| + "github.com/chromedp/chromedp" |
| +) |
| + |
| +// The fresh-device ladder in a real engine. |
| +// |
| +// The HTTP tests prove the handoff relays the right bytes. What only a real |
| +// browser can show is the part a person actually experiences: that a browser |
| +// with no cached Account Key is offered a way out instead of a dead end, that |
| +// typing the code off the card gets it in, and that a single mistyped |
| +// character still lands — which is the whole reason repairCode exists. |
| + |
| +// forgetTheAccountKey turns the current browser into a fresh device without |
| +// signing it out: the session survives, the cached key does not. That is |
| +// exactly the state a person is in on a new laptop, and it is the state the |
| +// vault used to dead-end in. |
| +func forgetTheAccountKey(t *testing.T, rig interface { |
| + Run(...chromedp.Action) |
| +}) { |
| + t.Helper() |
| + rig.Run(chromedp.Evaluate(`(() => { |
| + for (const k of Object.keys(localStorage)) { |
| + if (k.startsWith("contra/v1/account-key/")) localStorage.removeItem(k); |
| + } |
| + return true; |
| + })()`, nil)) |
| +} |
| + |
| +func TestBrowserFreshDeviceIsNotADeadEnd(t *testing.T) { |
| + rig, box := newRig(t) |
| + const email = "fresh-device@example.invalid" |
| + const passphrase = "a generated test passphrase 8f3a1c" |
| + |
| + signUpInBrowser(t, rig, box, email, passphrase) |
| + |
| + // Read the printed Account Key off the kit screen — the same string that |
| + // is on the card and inside the QR — then forget it, as a new machine |
| + // would never have had it. |
| + var printed string |
| + rig.Run(chromedp.Text(`[data-kit="account-key"]`, &printed, chromedp.ByQuery)) |
| + printed = strings.TrimSpace(printed) |
| + if !strings.HasPrefix(printed, "A1") { |
| + t.Fatalf("the kit did not show an Account Key: %q", printed) |
| + } |
| + |
| + forgetTheAccountKey(t, rig) |
| + |
| + // The vault gate must send them somewhere rather than explaining that a |
| + // feature is missing. |
| + rig.Run( |
| + chromedp.Navigate(rig.Origin+"/vault"), |
| + chromedp.WaitVisible(`[data-unlock]`, chromedp.ByQuery), |
| + chromedp.SendKeys(`[data-unlock] input[name=passphrase]`, passphrase, chromedp.ByQuery), |
| + chromedp.Click(`[data-unlock] button[type=submit]`, chromedp.ByQuery), |
| + chromedp.WaitVisible(`[data-unlock] [data-status] a[href="/device"]`, chromedp.ByQuery), |
| + ) |
| + rig.Screen("[data-unlock]", "a device with no Account Key is offered the ladder") |
| + |
| + // And the ladder itself offers everything it can. |
| + rig.Run( |
| + chromedp.Navigate(rig.Origin+"/device"), |
| + chromedp.WaitVisible(`[data-rung="handoff"]`, chromedp.ByQuery), |
| + chromedp.WaitVisible(`[data-rung="type"]`, chromedp.ByQuery), |
| + ) |
| + rig.Screen("[data-need]", "the fresh-device ladder") |
| + |
| + // Rung 3: type it off the card. This is the rung that always works, so it |
| + // is the one that must never be broken. |
| + rig.Run( |
| + chromedp.SendKeys(`#account-key`, printed, chromedp.ByQuery), |
| + chromedp.Click(`[data-type-form] button[type=submit]`, chromedp.ByQuery), |
| + chromedp.WaitVisible(`[data-unlock]`, chromedp.ByQuery), |
| + ) |
| + |
| + // It landed on the vault, and the key really is cached now: the vault |
| + // opens with the passphrase alone, which is the promise the whole |
| + // Account Key design is built around. |
| + rig.Run( |
| + chromedp.SendKeys(`[data-unlock] input[name=passphrase]`, passphrase, chromedp.ByQuery), |
| + chromedp.Click(`[data-unlock] button[type=submit]`, chromedp.ByQuery), |
| + chromedp.WaitVisible(`[data-item-form]`, chromedp.ByQuery), |
| + ) |
| + rig.Screen("[data-vault]", "unlocked after typing the Account Key") |
| +} |
| + |
| +// One character wrong, on paper, at the worst moment. The checksum is twenty |
| +// bits and cannot identify which character, so the repair enumerates about a |
| +// thousand candidates and the KCV picks the real one — which is why the |
| +// passphrase field appears only after the first attempt fails. |
| +// |
| +// This is the most intricate path in the app and the least likely to be |
| +// exercised by hand. |
| +func TestBrowserMistypedAccountKeyStillWorks(t *testing.T) { |
| + rig, box := newRig(t) |
| + const email = "typo@example.invalid" |
| + const passphrase = "a generated test passphrase 4d2e9b" |
| + |
| + signUpInBrowser(t, rig, box, email, passphrase) |
| + |
| + var printed string |
| + rig.Run(chromedp.Text(`[data-kit="account-key"]`, &printed, chromedp.ByQuery)) |
| + printed = strings.TrimSpace(printed) |
| + forgetTheAccountKey(t, rig) |
| + |
| + // Change one payload character to something else in the alphabet. The |
| + // tag stays, so this is a mistyped key rather than a mistyped format. |
| + runes := []rune(printed) |
| + pos := len(runes) - 3 |
| + if runes[pos] == 'X' { |
| + runes[pos] = 'Y' |
| + } else { |
| + runes[pos] = 'X' |
| + } |
| + mistyped := string(runes) |
| + if mistyped == printed { |
| + t.Fatal("failed to introduce a typo") |
| + } |
| + |
| + rig.Run( |
| + chromedp.Navigate(rig.Origin+"/device"), |
| + chromedp.WaitVisible(`[data-type-form]`, chromedp.ByQuery), |
| + chromedp.SendKeys(`#account-key`, mistyped, chromedp.ByQuery), |
| + chromedp.Click(`[data-type-form] button[type=submit]`, chromedp.ByQuery), |
| + // The passphrase field is revealed, not present from the start: the |
| + // common case is a code typed correctly and should not be taxed. |
| + chromedp.WaitVisible(`[data-repair]`, chromedp.ByQuery), |
| + ) |
| + rig.Screen("[data-need]", "a mistyped Account Key asks for the passphrase") |
| + |
| + rig.Run( |
| + chromedp.SendKeys(`#repair-passphrase`, passphrase, chromedp.ByQuery), |
| + chromedp.Click(`[data-type-form] button[type=submit]`, chromedp.ByQuery), |
| + chromedp.WaitVisible(`[data-unlock]`, chromedp.ByQuery), |
| + ) |
| + |
| + // Repaired, cached, and the vault opens — so the candidate the KCV chose |
| + // was the real key and not a twenty-bit collision. |
| + rig.Run( |
| + chromedp.SendKeys(`[data-unlock] input[name=passphrase]`, passphrase, chromedp.ByQuery), |
| + chromedp.Click(`[data-unlock] button[type=submit]`, chromedp.ByQuery), |
| + chromedp.WaitVisible(`[data-item-form]`, chromedp.ByQuery), |
| + ) |
| +} |
| + |
| +// The reprint page, which is the mitigation for the one loss mode the Account |
| +// Key introduced. The Account Key card reprints; the recovery sheet cannot, |
| +// and the screen has to be honest about that before it offers the button. |
| +func TestBrowserKitReprintsTheCardAndGuardsTheSheet(t *testing.T) { |
| + rig, box := newRig(t) |
| + const email = "reprint@example.invalid" |
| + const passphrase = "a generated test passphrase 1a7c5e" |
| + |
| + signUpInBrowser(t, rig, box, email, passphrase) |
| + |
| + rig.Run( |
| + chromedp.Navigate(rig.Origin+"/kit"), |
| + chromedp.WaitVisible(`[data-card-ready]`, chromedp.ByQuery), |
| + ) |
| + rig.Screen("[data-card]", "the reprint page") |
| + |
| + // The reissue button is disabled until the consequence is acknowledged. |
| + var disabled bool |
| + rig.Run(chromedp.Evaluate( |
| + `document.querySelector('[data-regenerate]').disabled`, &disabled)) |
| + if !disabled { |
| + t.Error("the recovery reissue is live before anything is acknowledged") |
| + } |
| + |
| + rig.Run( |
| + chromedp.Click(`[data-confirm-recovery]`, chromedp.ByQuery), |
| + chromedp.Poll(`!document.querySelector('[data-regenerate]').disabled`, nil), |
| + ) |
| + |
| + // And the nag is there, because this browser is the only one holding the |
| + // key and has no way to know otherwise. |
| + var nagHidden bool |
| + rig.Run(chromedp.Evaluate(`document.querySelector('[data-nag]').hidden`, &nagHidden)) |
| + if nagHidden { |
| + t.Error("the only enrolled device is not being nagged to enrol another") |
| + } |
| +} |