import { backupCodeResponseSchema, createGuestResponseSchema, errorResponseSchema, } from "@deadhead/schema"; import { sql } from "drizzle-orm"; import { describe, expect, it } from "vitest"; import { BACKUP_ALPHABET, normalizeBackupCode } from "../src/game/backup"; import { setCredits, useTestDatabase } from "./db"; import { call, cookieFrom, getState, signUp, signUpOwner, useTestApp, type Guest } from "./game"; const database = useTestDatabase(); const testApp = useTestApp(database); const makeCode = async (guest: Guest) => backupCodeResponseSchema.parse( (await call(testApp().app, guest, "POST", "/api/backup-code", {})).json(), ).code; /** Restores on a new device (no cookie); `device` sets the device-id header. */ const restore = (code: string, device = "device-a", ip = "10.0.0.1") => testApp().app.inject({ method: "POST", url: "/api/restore", headers: { "x-device-id": device }, remoteAddress: ip, payload: { code }, }); describe("backup codes (Step 10K.8)", () => { it("makes a 20-character code in four groups, without look-alike characters", async () => { const guest = await signUp(testApp().app); const code = await makeCode(guest); expect(code).toMatch(/^[A-Z2-9]{5}-[A-Z2-9]{5}-[A-Z2-9]{5}-[A-Z2-9]{5}$/); for (const ch of normalizeBackupCode(code)) expect(BACKUP_ALPHABET).toContain(ch); expect(code).not.toMatch(/[01OIL]/); expect((await call(testApp().app, guest, "GET", "/api/backup-code")).json()).toMatchObject({ hasCode: true, }); }); it("restores the same player on a second device, and both see the same state", async () => { const { app } = testApp(); const owner = await signUpOwner(app, database().db); await setCredits(database().db, owner.playerId, 4321); const code = await makeCode(owner); const response = await restore(code.toLowerCase().replaceAll("-", " ")); expect(response.statusCode).toBe(200); const body = createGuestResponseSchema.parse(response.json()); expect(body.playerId).toBe(owner.playerId); const second: Guest = { ...owner, cookie: cookieFrom(response.headers["set-cookie"]) }; expect(second.cookie).not.toBe(owner.cookie); await call(app, second, "POST", "/api/company/name", { name: "Second Device Co" }); expect((await getState(app, owner)).company).toMatchObject({ name: "Second Device Co", credits: 4321, }); }); it("stops the old code working when a new one is made", async () => { const guest = await signUp(testApp().app); const first = await makeCode(guest); const second = await makeCode(guest); expect(second).not.toBe(first); expect((await restore(first)).statusCode).toBe(409); expect((await restore(second)).statusCode).toBe(200); }); it("fails a wrong code with a generic error, and limits attempts", async () => { const wrong = await restore("ABCDE-FGHJK-MNPQR-STUVW", "device-b", "10.0.0.2"); expect(wrong.statusCode).toBe(409); expect(errorResponseSchema.parse(wrong.json()).error.code).toBe("restore-failed"); for (let i = 0; i < 4; i++) await restore("NOPE", "device-b", "10.0.0.2"); const limited = await restore("NOPE", "device-b", "10.0.0.2"); expect(limited.statusCode).toBe(429); expect(errorResponseSchema.parse(limited.json()).error.code).toBe("rate-limited"); // The same device from another network is still limited. expect((await restore("NOPE", "device-b", "10.0.0.3")).statusCode).toBe(429); }); it("never stores the plain code", async () => { const guest = await signUp(testApp().app); const code = await makeCode(guest); const plain = normalizeBackupCode(code); const rows = await database().db.execute( sql`select row_to_json(p)::text as row from players p`, ); for (const { row } of rows.rows as { row: string }[]) { expect(row).not.toContain(code); expect(row).not.toContain(plain); } }); it("still restores the player after they start a new company", async () => { const owner = await signUpOwner(testApp().app, database().db); const code = await makeCode(owner); await call(testApp().app, owner, "POST", "/api/company/new", {}); const response = await restore(code, "device-c", "10.0.0.4"); expect(createGuestResponseSchema.parse(response.json())).toMatchObject({ playerId: owner.playerId, state: { tutorial: { runsCompleted: 0 } }, }); }); });