107 lines
4.4 KiB
TypeScript
107 lines
4.4 KiB
TypeScript
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 } },
|
|
});
|
|
});
|
|
});
|