| 1 | //go:build browser |
| 2 | |
| 3 | package contratest |
| 4 | |
| 5 | import ( |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 9 | "github.com/chromedp/chromedp" |
| 10 | ) |
| 11 | |
| 12 | // The fresh-device ladder in a real engine. |
| 13 | // |
| 14 | // The HTTP tests prove the handoff relays the right bytes. What only a real |
| 15 | // browser can show is the part a person actually experiences: that a browser |
| 16 | // with no cached Account Key is offered a way out instead of a dead end, that |
| 17 | // typing the code off the card gets it in, and that a single mistyped |
| 18 | // character still lands — which is the whole reason repairCode exists. |
| 19 | |
| 20 | // forgetTheAccountKey turns the current browser into a fresh device without |
| 21 | // signing it out: the session survives, the cached key does not. That is |
| 22 | // exactly the state a person is in on a new laptop, and it is the state the |
| 23 | // vault used to dead-end in. |
| 24 | func forgetTheAccountKey(t *testing.T, rig interface { |
| 25 | Run(...chromedp.Action) |
| 26 | }) { |
| 27 | t.Helper() |
| 28 | rig.Run(chromedp.Evaluate(`(() => { |
| 29 | for (const k of Object.keys(localStorage)) { |
| 30 | if (k.startsWith("contra/v1/account-key/")) localStorage.removeItem(k); |
| 31 | } |
| 32 | return true; |
| 33 | })()`, nil)) |
| 34 | } |
| 35 | |
| 36 | func TestBrowserFreshDeviceIsNotADeadEnd(t *testing.T) { |
| 37 | rig, box := newRig(t) |
| 38 | const email = "fresh-device@example.invalid" |
| 39 | const passphrase = "a generated test passphrase 8f3a1c" |
| 40 | |
| 41 | signUpInBrowser(t, rig, box, email, passphrase) |
| 42 | |
| 43 | // Read the printed Account Key off the kit screen — the same string that |
| 44 | // is on the card and inside the QR — then forget it, as a new machine |
| 45 | // would never have had it. |
| 46 | var printed string |
| 47 | rig.Run(chromedp.Text(`[data-kit="account-key"]`, &printed, chromedp.ByQuery)) |
| 48 | printed = strings.TrimSpace(printed) |
| 49 | if !strings.HasPrefix(printed, "A1") { |
| 50 | t.Fatalf("the kit did not show an Account Key: %q", printed) |
| 51 | } |
| 52 | |
| 53 | forgetTheAccountKey(t, rig) |
| 54 | |
| 55 | // The vault gate must send them somewhere rather than explaining that a |
| 56 | // feature is missing. |
| 57 | rig.Run( |
| 58 | chromedp.Navigate(rig.Origin+"/vault"), |
| 59 | chromedp.WaitVisible(`[data-unlock]`, chromedp.ByQuery), |
| 60 | chromedp.SendKeys(`[data-unlock] input[name=passphrase]`, passphrase, chromedp.ByQuery), |
| 61 | chromedp.Click(`[data-unlock] button[type=submit]`, chromedp.ByQuery), |
| 62 | chromedp.WaitVisible(`[data-unlock] [data-status] a[href="/device"]`, chromedp.ByQuery), |
| 63 | ) |
| 64 | rig.Screen("[data-unlock]", "a device with no Account Key is offered the ladder") |
| 65 | |
| 66 | // And the ladder itself offers everything it can. |
| 67 | rig.Run( |
| 68 | chromedp.Navigate(rig.Origin+"/device"), |
| 69 | chromedp.WaitVisible(`[data-rung="handoff"]`, chromedp.ByQuery), |
| 70 | chromedp.WaitVisible(`[data-rung="type"]`, chromedp.ByQuery), |
| 71 | ) |
| 72 | rig.Screen("[data-need]", "the fresh-device ladder") |
| 73 | |
| 74 | // Rung 3: type it off the card. This is the rung that always works, so it |
| 75 | // is the one that must never be broken. |
| 76 | rig.Run( |
| 77 | chromedp.SendKeys(`#account-key`, printed, chromedp.ByQuery), |
| 78 | chromedp.Click(`[data-type-form] button[type=submit]`, chromedp.ByQuery), |
| 79 | chromedp.WaitVisible(`[data-unlock]`, chromedp.ByQuery), |
| 80 | ) |
| 81 | |
| 82 | // It landed on the vault, and the key really is cached now: the vault |
| 83 | // opens with the passphrase alone, which is the promise the whole |
| 84 | // Account Key design is built around. |
| 85 | rig.Run( |
| 86 | chromedp.SendKeys(`[data-unlock] input[name=passphrase]`, passphrase, chromedp.ByQuery), |
| 87 | chromedp.Click(`[data-unlock] button[type=submit]`, chromedp.ByQuery), |
| 88 | chromedp.WaitVisible(`[data-item-form]`, chromedp.ByQuery), |
| 89 | ) |
| 90 | rig.Screen("[data-vault]", "unlocked after typing the Account Key") |
| 91 | } |
| 92 | |
| 93 | // One character wrong, on paper, at the worst moment. The checksum is twenty |
| 94 | // bits and cannot identify which character, so the repair enumerates about a |
| 95 | // thousand candidates and the KCV picks the real one — which is why the |
| 96 | // passphrase field appears only after the first attempt fails. |
| 97 | // |
| 98 | // This is the most intricate path in the app and the least likely to be |
| 99 | // exercised by hand. |
| 100 | func TestBrowserMistypedAccountKeyStillWorks(t *testing.T) { |
| 101 | rig, box := newRig(t) |
| 102 | const email = "typo@example.invalid" |
| 103 | const passphrase = "a generated test passphrase 4d2e9b" |
| 104 | |
| 105 | signUpInBrowser(t, rig, box, email, passphrase) |
| 106 | |
| 107 | var printed string |
| 108 | rig.Run(chromedp.Text(`[data-kit="account-key"]`, &printed, chromedp.ByQuery)) |
| 109 | printed = strings.TrimSpace(printed) |
| 110 | forgetTheAccountKey(t, rig) |
| 111 | |
| 112 | // Change one payload character to something else in the alphabet. The |
| 113 | // tag stays, so this is a mistyped key rather than a mistyped format. |
| 114 | runes := []rune(printed) |
| 115 | pos := len(runes) - 3 |
| 116 | if runes[pos] == 'X' { |
| 117 | runes[pos] = 'Y' |
| 118 | } else { |
| 119 | runes[pos] = 'X' |
| 120 | } |
| 121 | mistyped := string(runes) |
| 122 | if mistyped == printed { |
| 123 | t.Fatal("failed to introduce a typo") |
| 124 | } |
| 125 | |
| 126 | rig.Run( |
| 127 | chromedp.Navigate(rig.Origin+"/device"), |
| 128 | chromedp.WaitVisible(`[data-type-form]`, chromedp.ByQuery), |
| 129 | chromedp.SendKeys(`#account-key`, mistyped, chromedp.ByQuery), |
| 130 | chromedp.Click(`[data-type-form] button[type=submit]`, chromedp.ByQuery), |
| 131 | // The passphrase field is revealed, not present from the start: the |
| 132 | // common case is a code typed correctly and should not be taxed. |
| 133 | chromedp.WaitVisible(`[data-repair]`, chromedp.ByQuery), |
| 134 | ) |
| 135 | rig.Screen("[data-need]", "a mistyped Account Key asks for the passphrase") |
| 136 | |
| 137 | rig.Run( |
| 138 | chromedp.SendKeys(`#repair-passphrase`, passphrase, chromedp.ByQuery), |
| 139 | chromedp.Click(`[data-type-form] button[type=submit]`, chromedp.ByQuery), |
| 140 | chromedp.WaitVisible(`[data-unlock]`, chromedp.ByQuery), |
| 141 | ) |
| 142 | |
| 143 | // Repaired, cached, and the vault opens — so the candidate the KCV chose |
| 144 | // was the real key and not a twenty-bit collision. |
| 145 | rig.Run( |
| 146 | chromedp.SendKeys(`[data-unlock] input[name=passphrase]`, passphrase, chromedp.ByQuery), |
| 147 | chromedp.Click(`[data-unlock] button[type=submit]`, chromedp.ByQuery), |
| 148 | chromedp.WaitVisible(`[data-item-form]`, chromedp.ByQuery), |
| 149 | ) |
| 150 | } |
| 151 | |
| 152 | // The reprint page, which is the mitigation for the one loss mode the Account |
| 153 | // Key introduced. The Account Key card reprints; the recovery sheet cannot, |
| 154 | // and the screen has to be honest about that before it offers the button. |
| 155 | func TestBrowserKitReprintsTheCardAndGuardsTheSheet(t *testing.T) { |
| 156 | rig, box := newRig(t) |
| 157 | const email = "reprint@example.invalid" |
| 158 | const passphrase = "a generated test passphrase 1a7c5e" |
| 159 | |
| 160 | signUpInBrowser(t, rig, box, email, passphrase) |
| 161 | |
| 162 | rig.Run( |
| 163 | chromedp.Navigate(rig.Origin+"/kit"), |
| 164 | chromedp.WaitVisible(`[data-card-ready]`, chromedp.ByQuery), |
| 165 | ) |
| 166 | rig.Screen("[data-card]", "the reprint page") |
| 167 | |
| 168 | // The reissue button is disabled until the consequence is acknowledged. |
| 169 | var disabled bool |
| 170 | rig.Run(chromedp.Evaluate( |
| 171 | `document.querySelector('[data-regenerate]').disabled`, &disabled)) |
| 172 | if !disabled { |
| 173 | t.Error("the recovery reissue is live before anything is acknowledged") |
| 174 | } |
| 175 | |
| 176 | rig.Run( |
| 177 | chromedp.Click(`[data-confirm-recovery]`, chromedp.ByQuery), |
| 178 | chromedp.Poll(`!document.querySelector('[data-regenerate]').disabled`, nil), |
| 179 | ) |
| 180 | |
| 181 | // And the nag is there, because this browser is the only one holding the |
| 182 | // key and has no way to know otherwise. |
| 183 | var nagHidden bool |
| 184 | rig.Run(chromedp.Evaluate(`document.querySelector('[data-nag]').hidden`, &nagHidden)) |
| 185 | if nagHidden { |
| 186 | t.Error("the only enrolled device is not being nagged to enrol another") |
| 187 | } |
| 188 | } |
| 189 | |