deadhead-space-freight-tycoon/apps/server/test/backup.test.ts

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 } },
});
});
});