Public: anyone can read this; the crew can change it.
You change your password from the terminal's Sessions screen with `p`: current and new asked, never echoed, through the action Settings uses #8196
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8127-you-change-your-password-from-the-termin → main 4 commits fork 2 threads · 2 unresolved
6 files
changed +334
−27
README.md +1 −1
| @@ -543,7 +543,7 @@ This page is the front door. Everything else is a file of its own under | ||
| 543 | 543 | |
| 544 | 544 | What the crew's tokens bought is on the board (`/board/returns`), in MCP `returns`, and in the terminal: the palette's Usage carries a Returns line (dollars, runs, changes landed, cost per landed change; `[`/`]` move between 1, 7, 30 and 90 days), and Usage of a repository (`owner/name`) narrows it to one app. On the forge-wide Usage, `f` opens the fleet graph (what `/fleet/graph` shows): one sparkline row per repository for runs and for spend, each with its total at the right, over 24 h by hour or 14 d by day (`w` toggles); past eight series the smallest share "N others", and a short terminal ends with a "+N more rows" line. Who may see spend is the action's rule (`src/actions/fleet-graph.ts`), not the screen's. Owner facts only (`src/actions/returns.ts`); see [](docs/OPERATING.md). |
| 545 | 545 | |
| 546 | The terminal's palette has `Signed in` for anyone signed in: your browsers, tokens and this terminal's key, with when each was last seen; `d` signs the selected browser out after a confirm, as `/settings` and MCP `sessions` do, and on a token revokes it after a confirm (Keep is the default) through the same `deleteToken` action as Settings and the API, so it stops working at once (`docs/CREW.md`, "Where you are signed in"). | |
| 546 | The terminal's palette has `Signed in` for anyone signed in: your browsers, tokens and this terminal's key, with when each was last seen; `d` signs the selected browser out after a confirm, as `/settings` and MCP `sessions` do, and on a token revokes it after a confirm (Keep is the default) through the same `deleteToken` action as Settings and the API, so it stops working at once (`docs/CREW.md`, "Where you are signed in"). `p` there changes your password: it asks the current one, the new one and the new one again, each masked and never echoed, and changes it through `changePassword`, the action behind Settings, so a wrong current password, a throttled account, a new one under 10 characters or over the cap is refused in Settings' words (the length is refused at the new-password prompt, before the confirm); an agent signs in with a key and is not offered `p`. | |
| 547 | 547 | |
| 548 | 548 | The release keeper (`src/keeper/`, #7557) cuts, promotes and verifies releases on the forge's own one-minute timer, with no model. It is off until `FORGE_RELEASE_KEEPER=on`, and the pod's service account may `get` and `patch` the forge Deployment. |
| 549 | 549 |
src/actions/users.ts +16 −1
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | * an account authenticates with over the SSH listener. |
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | import { withinPasswordCap } from "../core/hash-gate.ts"; | |
| 6 | import { PASSWORD_TOO_LONG, withinPasswordCap } from "../core/hash-gate.ts"; | |
| 7 | 7 | import { emailChanged, welcome } from "./verify.ts"; |
| 8 | 8 | import type { App } from "../app.ts"; |
| 9 | 9 | import type { Credential } from "../auth/webauthn.ts"; |
| @@ -362,6 +362,21 @@ export type ChangePasswordError = | ||
| 362 | 362 | | "weak_password" |
| 363 | 363 | | "password_too_long"; |
| 364 | 364 | |
| 365 | /** | |
| 366 | * What a refused password change reads as, one sentence each: Settings › | |
| 367 | * Password and the terminal's Sessions screen say the same words. | |
| 368 | */ | |
| 369 | export const CHANGE_PASSWORD_WORDS: Record<ChangePasswordError, string> = { | |
| 370 | unauthorized: "Something went wrong.", | |
| 371 | bad_credentials: "That isn't your current password.", | |
| 372 | throttled: "Too many attempts. Try again in a minute.", | |
| 373 | weak_password: "Passwords need at least 10 characters.", | |
| 374 | password_too_long: PASSWORD_TOO_LONG, | |
| 375 | }; | |
| 376 | ||
| 377 | /** What a changed password reads as, on every surface. */ | |
| 378 | export const PASSWORD_CHANGED_WORDS = "Password changed. Existing sessions stay signed in."; | |
| 379 | ||
| 365 | 380 | /** Replace the password; the current one must be presented and verified. */ |
| 366 | 381 | export async function changePassword( |
| 367 | 382 | app: App, |
src/core/users.ts +1 −1
| @@ -91,7 +91,7 @@ export const hiddenFrom = (user: User, viewer: User | undefined): boolean => | ||
| 91 | 91 | |
| 92 | 92 | // The minimum counts characters, the cap (`hash-gate.ts`) counts bytes: the |
| 93 | 93 | // hash reads bytes, and a person never meets the difference at these sizes. |
| 94 | const MIN_PASSWORD = 10; | |
| 94 | export const MIN_PASSWORD = 10; | |
| 95 | 95 | const MAX_EMAIL = 254; |
| 96 | 96 | const MAX_DISPLAY_NAME = 80; |
| 97 | 97 |
src/tui/screens/signins.ts +77 −3
| @@ -11,14 +11,28 @@ | ||
| 11 | 11 | * a scope, and for admin a second explicit `y`. The secret is shown once on a |
| 12 | 12 | * screen of its own and forgotten when that screen is left. Only a person |
| 13 | 13 | * minting from a terminal session may: an agent is not offered `m`. |
| 14 | * `p` changes your password through the action `changePassword` (what | |
| 15 | * Settings › Password and the API use, so the sign-in throttle applies and | |
| 16 | * `user.updated` is emitted): the current one, the new one and the new one | |
| 17 | * again, each in a masked prompt. The typed passwords live only in the | |
| 18 | * prompts' closures for the call; refusals read in Settings' words. An agent | |
| 19 | * is not offered `p`, as it is not offered `m`. | |
| 14 | 20 | * This terminal is held by its key, not a browser session, and cannot be |
| 15 | 21 | * ended from here. |
| 16 | 22 | */ |
| 17 | 23 | |
| 18 | 24 | import { accountSignIns, endAccountSession } from "../../actions/signins.ts"; |
| 19 | import { createToken, deleteToken } from "../../actions/users.ts"; | |
| 25 | import { | |
| 26 | CHANGE_PASSWORD_WORDS, | |
| 27 | PASSWORD_CHANGED_WORDS, | |
| 28 | changePassword, | |
| 29 | createToken, | |
| 30 | deleteToken, | |
| 31 | } from "../../actions/users.ts"; | |
| 32 | import { withinPasswordCap } from "../../core/hash-gate.ts"; | |
| 20 | 33 | import { listKeys } from "../../core/keys.ts"; |
| 21 | 34 | import { ADMIN_TOKEN_WORDS, type TokenScope } from "../../core/tokens.ts"; |
| 35 | import { MIN_PASSWORD } from "../../core/users.ts"; | |
| 22 | 36 | import type { Ctx, Screen } from "../app/app.ts"; |
| 23 | 37 | import { binding } from "../app/keys.ts"; |
| 24 | 38 | import { style } from "../term/style.ts"; |
| @@ -222,9 +236,63 @@ export function signInsScreen(f: Session, ctx: Ctx): Screen { | ||
| 222 | 236 | ctx.push(askName(user.isAdmin)); |
| 223 | 237 | }; |
| 224 | 238 | |
| 239 | /** Calls the action; the passwords are arguments only, never kept here. */ | |
| 240 | const change = async (current: string, next: string): Promise<void> => { | |
| 241 | const changed = await changePassword(f.app, f.user, { current, next, source: f.identity.remoteAddress }); | |
| 242 | note = changed.ok ? PASSWORD_CHANGED_WORDS : CHANGE_PASSWORD_WORDS[changed.error]; | |
| 243 | ctx.invalidate(); | |
| 244 | }; | |
| 245 | ||
| 246 | const askAgain = (current: string, next: string) => | |
| 247 | later(ctx, () => | |
| 248 | promptScreen(ctx, screen, { | |
| 249 | title: "Change your password", | |
| 250 | label: "New password, again", | |
| 251 | mask: true, | |
| 252 | onSubmit: (again) => (again === next ? change(current, next) : "The two new passwords don't match."), | |
| 253 | }), | |
| 254 | ); | |
| 255 | ||
| 256 | const askNext = (current: string) => | |
| 257 | later(ctx, () => | |
| 258 | promptScreen(ctx, screen, { | |
| 259 | title: "Change your password", | |
| 260 | label: "New password", | |
| 261 | mask: true, | |
| 262 | // Refused here, in Settings' words, so a weak one is not typed twice; | |
| 263 | // the action checks again. | |
| 264 | onSubmit: (next) => { | |
| 265 | if (next === "") return "Type a new password."; | |
| 266 | if (next.length < MIN_PASSWORD) return CHANGE_PASSWORD_WORDS.weak_password; | |
| 267 | if (!withinPasswordCap(next)) return CHANGE_PASSWORD_WORDS.password_too_long; | |
| 268 | return askAgain(current, next); | |
| 269 | }, | |
| 270 | }), | |
| 271 | ); | |
| 272 | ||
| 273 | const askCurrent = () => | |
| 274 | promptScreen(ctx, screen, { | |
| 275 | title: "Change your password", | |
| 276 | label: "Current password", | |
| 277 | mask: true, | |
| 278 | onSubmit: (current) => (current === "" ? "Type your current password." : askNext(current)), | |
| 279 | }); | |
| 280 | ||
| 281 | const rotate = (): void => { | |
| 282 | const user = f.user; | |
| 283 | // An agent signs in with a key, not a password; a guest has no account. | |
| 284 | if (user === undefined || user.kind === "agent") return; | |
| 285 | ctx.push(askCurrent()); | |
| 286 | }; | |
| 287 | ||
| 225 | 288 | const screen: Screen = { |
| 226 | 289 | title: "Signed in", |
| 227 | keys: [binding(["j", "k"], "move cursor"), binding(["d"], "sign a browser out or revoke a token"), binding(["m"], "make a token")], | |
| 290 | keys: [ | |
| 291 | binding(["j", "k"], "move cursor"), | |
| 292 | binding(["d"], "sign a browser out or revoke a token"), | |
| 293 | binding(["m"], "make a token"), | |
| 294 | binding(["p"], "change your password"), | |
| 295 | ], | |
| 228 | 296 | render(area, s) { |
| 229 | 297 | const c = ctx.theme.c; |
| 230 | 298 | const all = signInsOf(f); |
| @@ -234,7 +302,9 @@ export function signInsScreen(f: Session, ctx: Ctx): Screen { | ||
| 234 | 302 | ? note |
| 235 | 303 | : f.user === undefined |
| 236 | 304 | ? "Sign in to see where you are signed in." |
| 237 | : "Where you are signed in. d signs a browser out or revokes a token, m makes a token."; | |
| 305 | : f.user.kind === "agent" | |
| 306 | ? "Where you are signed in. d signs a browser out or revokes a token." | |
| 307 | : "Where you are signed in. d signs a browser out or revokes a token, m makes a token, p changes your password."; | |
| 238 | 308 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 239 | 309 | drawList(s, body!, c, state, rows(all)); |
| 240 | 310 | }, |
| @@ -247,6 +317,10 @@ export function signInsScreen(f: Session, ctx: Ctx): Screen { | ||
| 247 | 317 | make(); |
| 248 | 318 | return true; |
| 249 | 319 | } |
| 320 | if (e.key === "p") { | |
| 321 | rotate(); | |
| 322 | return true; | |
| 323 | } | |
| 250 | 324 | if (e.key !== "d") return false; |
| 251 | 325 | const picked = all[state.index]; |
| 252 | 326 | if (picked === undefined) return false; |
src/web/pages/account.ts +7 −21
| @@ -9,16 +9,17 @@ | ||
| 9 | 9 | * has been read once. |
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | import { PASSWORD_TOO_LONG } from "../../core/hash-gate.ts"; | |
| 13 | 12 | import { resendVerification } from "../../actions/verify.ts"; |
| 14 | 13 | import { clientAddress } from "../../http/peer.ts"; |
| 15 | 14 | import { emailVerified } from "../../core/verify.ts"; |
| 16 | 15 | import { |
| 17 | 16 | addSshKey, |
| 17 | CHANGE_PASSWORD_WORDS, | |
| 18 | 18 | changePassword, |
| 19 | 19 | createToken, |
| 20 | 20 | deleteSshKey, |
| 21 | 21 | deleteToken, |
| 22 | PASSWORD_CHANGED_WORDS, | |
| 22 | 23 | updateProfile, |
| 23 | 24 | } from "../../actions/users.ts"; |
| 24 | 25 | import { |
| @@ -509,29 +510,14 @@ export async function passwordUpdate(ctx: Ctx): Promise<Response> { | ||
| 509 | 510 | source: clientAddress(ctx.request), |
| 510 | 511 | }); |
| 511 | 512 | if (!changed.ok) { |
| 512 | const error = | |
| 513 | changed.error === "bad_credentials" | |
| 514 | ? { | |
| 515 | field: "current" as const, | |
| 516 | text: "That isn't your current password.", | |
| 517 | } | |
| 518 | : changed.error === "throttled" | |
| 519 | ? { | |
| 520 | field: "current" as const, | |
| 521 | text: "Too many attempts. Try again in a minute.", | |
| 522 | } | |
| 523 | : changed.error === "password_too_long" | |
| 524 | ? { field: "next" as const, text: PASSWORD_TOO_LONG } | |
| 525 | : changed.error === "weak_password" | |
| 526 | ? { | |
| 527 | field: "next" as const, | |
| 528 | text: "Passwords need at least 10 characters.", | |
| 529 | } | |
| 530 | : { field: "current" as const, text: "Something went wrong." }; | |
| 513 | const error = { | |
| 514 | field: changed.error === "weak_password" || changed.error === "password_too_long" ? ("next" as const) : ("current" as const), | |
| 515 | text: CHANGE_PASSWORD_WORDS[changed.error], | |
| 516 | }; | |
| 531 | 517 | return accountShell(ctx, "password", "Password", passwordView(error), 422); |
| 532 | 518 | } |
| 533 | 519 | return flashTo(ctx, "/settings/password", { |
| 534 | message: "Password changed. Existing sessions stay signed in.", | |
| 520 | message: PASSWORD_CHANGED_WORDS, | |
| 535 | 521 | }); |
| 536 | 522 | } |
| 537 | 523 |
test/tui-signins-password.test.ts added +232
| @@ -0,0 +1,232 @@ | ||
| 1 | /** | |
| 2 | * Changing your password from the terminal (#8127): `p` on the Sessions | |
| 3 | * screen asks the current password, the new one and the new one again, each | |
| 4 | * masked, and changes it through the action Settings uses — after which the | |
| 5 | * new password signs in and the old one does not. A wrong current password | |
| 6 | * reads in Settings' words and changes nothing; a new password under 10 | |
| 7 | * characters or over the cap is refused at its own prompt, and a throttled | |
| 8 | * account reads as Settings does; the typed text never appears in any frame a | |
| 9 | * run painted; an agent is not offered `p`. | |
| 10 | */ | |
| 11 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 12 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 13 | import { tmpdir } from "node:os"; | |
| 14 | import { join } from "node:path"; | |
| 15 | import { CHANGE_PASSWORD_WORDS, PASSWORD_CHANGED_WORDS } from "../src/actions/users.ts"; | |
| 16 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 17 | import { MAX_PASSWORD } from "../src/core/hash-gate.ts"; | |
| 18 | import { admitSignin } from "../src/core/signin-window.ts"; | |
| 19 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 20 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 21 | import { signInsScreen } from "../src/tui/screens/signins.ts"; | |
| 22 | import { Session } from "../src/tui/session.ts"; | |
| 23 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 24 | import { quietLogs } from "./helpers/globals.ts"; | |
| 25 | import { settleApp } from "./helpers/tui.ts"; | |
| 26 | ||
| 27 | quietLogs(); | |
| 28 | ||
| 29 | const OLD = "a-good-passphrase"; | |
| 30 | const NEW = "zebra-quokka-lantern-91"; | |
| 31 | const WRONG = "not-my-password-x7"; | |
| 32 | ||
| 33 | describe("changing your password from the terminal", () => { | |
| 34 | let dataDir: string; | |
| 35 | let forge: Forge; | |
| 36 | ||
| 37 | function boot(username: string) { | |
| 38 | // Every frame this run painted, so a leak in any of them is caught. | |
| 39 | const frames: string[] = []; | |
| 40 | const terminal = headlessTerminal(140, 32); | |
| 41 | const user = getUserByName(forge.db, username)!; | |
| 42 | const { app } = createForgeApp(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user }, { terminal, exitHooks: false }); | |
| 43 | app.start(); | |
| 44 | const key = (k: string, ctrl = false) => app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 45 | const text = () => { | |
| 46 | const t = app.screen.toText(); | |
| 47 | frames.push(t); | |
| 48 | return t; | |
| 49 | }; | |
| 50 | return { | |
| 51 | frames, | |
| 52 | text, | |
| 53 | press: async (...keys: string[]) => { | |
| 54 | keys.forEach((k) => key(k)); | |
| 55 | await settleApp(app); | |
| 56 | text(); | |
| 57 | }, | |
| 58 | type: async (word: string) => { | |
| 59 | for (const ch of word) { | |
| 60 | key(ch); | |
| 61 | await settleApp(app); | |
| 62 | text(); | |
| 63 | } | |
| 64 | }, | |
| 65 | open: async () => { | |
| 66 | key("k", true); | |
| 67 | await settleApp(app); | |
| 68 | for (const ch of "Signed in") key(ch); | |
| 69 | await settleApp(app); | |
| 70 | key("enter"); | |
| 71 | await settleApp(app); | |
| 72 | text(); | |
| 73 | }, | |
| 74 | stop: () => app.stop(), | |
| 75 | }; | |
| 76 | } | |
| 77 | ||
| 78 | /** No typed password, nor a long enough piece of one, in any frame. */ | |
| 79 | const noLeak = (frames: readonly string[], ...typed: string[]) => { | |
| 80 | expect(frames.length).toBeGreaterThan(5); | |
| 81 | for (const frame of frames) | |
| 82 | for (const word of typed) { | |
| 83 | expect(frame).not.toContain(word); | |
| 84 | expect(frame).not.toContain(word.slice(0, 8)); | |
| 85 | } | |
| 86 | }; | |
| 87 | ||
| 88 | const signsIn = async (username: string, password: string) => | |
| 89 | (await new Session(forge, { sshUsername: "t", remoteAddress: "10.0.0.9" }).signIn(username, password)).ok; | |
| 90 | ||
| 91 | beforeAll(async () => { | |
| 92 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-signins-password-")); | |
| 93 | forge = createApp({ dataDir, port: 0 }); | |
| 94 | const made = await createUser(forge.db, { | |
| 95 | origin: "seed", | |
| 96 | trusted: true, | |
| 97 | username: "alice", | |
| 98 | email: "[email protected]", | |
| 99 | password: OLD, | |
| 100 | adminIfFirst: false, | |
| 101 | }); | |
| 102 | if (!made.ok) throw new Error(made.error); | |
| 103 | const bob = await createUser(forge.db, { | |
| 104 | origin: "seed", | |
| 105 | trusted: true, | |
| 106 | username: "bob", | |
| 107 | email: "[email protected]", | |
| 108 | password: OLD, | |
| 109 | adminIfFirst: false, | |
| 110 | }); | |
| 111 | if (!bob.ok) throw new Error(bob.error); | |
| 112 | }); | |
| 113 | afterAll(async () => { | |
| 114 | forge.close(); | |
| 115 | await rm(dataDir, { recursive: true, force: true }); | |
| 116 | }); | |
| 117 | ||
| 118 | test("a wrong current password is refused in Settings' words, and the password is unchanged", async () => { | |
| 119 | const h = boot("alice"); | |
| 120 | await h.open(); | |
| 121 | expect(h.text()).toContain("p changes your password"); | |
| 122 | await h.press("p"); | |
| 123 | expect(h.text()).toContain("Current password"); | |
| 124 | await h.type(WRONG); | |
| 125 | expect(h.text()).toContain("•".repeat(WRONG.length)); | |
| 126 | await h.press("enter"); | |
| 127 | await h.type(NEW); | |
| 128 | await h.press("enter"); | |
| 129 | await h.type(NEW); | |
| 130 | await h.press("enter"); | |
| 131 | expect(h.text()).toContain(CHANGE_PASSWORD_WORDS.bad_credentials); | |
| 132 | h.stop(); | |
| 133 | noLeak(h.frames, WRONG, NEW); | |
| 134 | expect(await signsIn("alice", OLD)).toBe(true); | |
| 135 | expect(await signsIn("alice", NEW)).toBe(false); | |
| 136 | }); | |
| 137 | ||
| 138 | test("new passwords that differ keep the prompt open and change nothing", async () => { | |
| 139 | const h = boot("alice"); | |
| 140 | await h.open(); | |
| 141 | await h.press("p"); | |
| 142 | await h.type(OLD); | |
| 143 | await h.press("enter"); | |
| 144 | await h.type(NEW); | |
| 145 | await h.press("enter"); | |
| 146 | await h.type(`${NEW}-typo`); | |
| 147 | await h.press("enter"); | |
| 148 | expect(h.text()).toContain("The two new passwords don't match."); | |
| 149 | await h.press("esc"); | |
| 150 | h.stop(); | |
| 151 | noLeak(h.frames, OLD, NEW); | |
outdated
This test relies on frames pushed by earlier tests in the file, so it fails or passes on order. Drive its own run, or fold the frame assertion into each test. Also add a weak-password case from the terminal. |
||
| 152 | expect(await signsIn("alice", OLD)).toBe(true); | |
| 153 | }); | |
| 154 | ||
| 155 | test("current, new and new again change it: the new one signs in, the old one does not", async () => { | |
| 156 | const h = boot("alice"); | |
| 157 | await h.open(); | |
| 158 | await h.press("p"); | |
| 159 | await h.type(OLD); | |
| 160 | await h.press("enter"); | |
| 161 | expect(h.text()).toContain("New password"); | |
| 162 | await h.type(NEW); | |
| 163 | await h.press("enter"); | |
| 164 | expect(h.text()).toContain("New password, again"); | |
| 165 | await h.type(NEW); | |
| 166 | await h.press("enter"); | |
| 167 | expect(h.text()).toContain(PASSWORD_CHANGED_WORDS); | |
| 168 | h.stop(); | |
| 169 | noLeak(h.frames, OLD, NEW); | |
| 170 | expect(await signsIn("alice", NEW)).toBe(true); | |
| 171 | expect(await signsIn("alice", OLD)).toBe(false); | |
| 172 | }); | |
| 173 | ||
| 174 | test("a new password under 10 characters is refused at its own prompt, and nothing changes", async () => { | |
| 175 | const h = boot("bob"); | |
| 176 | await h.open(); | |
| 177 | await h.press("p"); | |
| 178 | await h.type(OLD); | |
| 179 | await h.press("enter"); | |
| 180 | await h.type("short-pw9"); | |
| 181 | await h.press("enter"); | |
| 182 | expect(h.text()).toContain(CHANGE_PASSWORD_WORDS.weak_password); | |
| 183 | expect(h.text()).not.toContain("New password, again"); | |
| 184 | await h.press("esc"); | |
| 185 | h.stop(); | |
| 186 | noLeak(h.frames, OLD, "short-pw9"); | |
| 187 | expect(await signsIn("bob", OLD)).toBe(true); | |
| 188 | }); | |
| 189 | ||
| 190 | test("a new password over the cap is refused at its own prompt", async () => { | |
| 191 | const h = boot("bob"); | |
| 192 | await h.open(); | |
| 193 | await h.press("p"); | |
| 194 | await h.type(OLD); | |
| 195 | await h.press("enter"); | |
| 196 | await h.press(..."x".repeat(MAX_PASSWORD + 1)); | |
| 197 | await h.press("enter"); | |
| 198 | expect(h.text()).toContain(CHANGE_PASSWORD_WORDS.password_too_long); | |
| 199 | expect(h.text()).not.toContain("New password, again"); | |
| 200 | await h.press("esc"); | |
| 201 | h.stop(); | |
| 202 | expect(await signsIn("bob", OLD)).toBe(true); | |
| 203 | }); | |
| 204 | ||
| 205 | test("a throttled account is refused in Settings' words, and nothing changes", async () => { | |
| 206 | // Fill bob's window from the terminal's own address, as wrong guesses would. | |
| 207 | for (let i = 0; i < 1000 && admitSignin(forge.db, "bob", "127.0.0.1").admitted; i++); | |
| 208 | const h = boot("bob"); | |
| 209 | await h.open(); | |
| 210 | await h.press("p"); | |
| 211 | await h.type(OLD); | |
| 212 | await h.press("enter"); | |
| 213 | await h.type(NEW); | |
| 214 | await h.press("enter"); | |
| 215 | await h.type(NEW); | |
| 216 | await h.press("enter"); | |
| 217 | expect(h.text()).toContain(CHANGE_PASSWORD_WORDS.throttled); | |
| 218 | h.stop(); | |
| 219 | noLeak(h.frames, OLD, NEW); | |
| 220 | expect(await signsIn("bob", OLD)).toBe(true); | |
| 221 | }); | |
| 222 | ||
| 223 | test("an agent is not offered p, and p on its screen pushes nothing", () => { | |
| 224 | const alice = getUserByName(forge.db, "alice")!; | |
| 225 | const agent = new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user: { ...alice, kind: "agent" } }); | |
| 226 | const pushed: unknown[] = []; | |
| 227 | const ctx = { push: (s: unknown) => pushed.push(s), invalidate() {} } as never; | |
| 228 | const screen = signInsScreen(agent, ctx); | |
| 229 | screen.key?.({ type: "key", key: "p", ctrl: false, alt: false, shift: false } as never); | |
| 230 | expect(pushed).toEqual([]); | |
| 231 | }); | |
| 232 | }); |
new above
offline
A new password shorter than 10 characters is accepted here and only refused after the confirm prompt. Refuse it here with CHANGE_PASSWORD_WORDS.weak_password.