Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions scripts/validate-persistence.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,35 @@ function sanitizeResults(input, room) {
return out;
}

// ─── Espelho de dropTemporallyImpossible() (src/lib/room.ts, issue #34) ───────
// `sanitizeResults` limita o VALOR e é cega ao relógio: com o teto em 350, o
// forjador pede 349 e entra no ranking sem digitar. A checagem temporal é um
// PISO — digitar N chars a `wpm` custa `(N/5)/wpm` minutos, e isso tem de caber
// no tempo decorrido: `wpm >= (N/5)/E`. Roda com folga porque o servidor só
// enxerga caracteres DIGITADOS (`progress * chars`), um limite superior dos
// corretos, que são o numerador real do WPM.

const TIMING_SLACK = 0.65;
const TIMING_EPS_WPM = 1;

/** Espelho fiel de `dropTemporallyImpossible(rows, room, nowMs)` (sem o console.warn). */
function dropTemporallyImpossible(rows, room, nowMs) {
const startMs = room?.start_at ? Date.parse(room.start_at) : NaN;
if (!Number.isFinite(startMs)) return [];
const chars = room?.snippet?.code?.length ?? 0;
const globalMin = Math.max(0, nowMs - startMs) / 60000;

return rows.filter(r => {
const typedChars = Math.max(0, Math.min(1, Number(r.progress) || 0)) * chars;
if (typedChars <= 0) return true;
const fa = Number(r.finishedAt);
const inWindow = Number.isFinite(fa) && fa >= startMs && fa <= nowMs;
const elapsedMin = inWindow ? (fa - startMs) / 60000 : globalMin;
const floor = elapsedMin > 0 ? typedChars / 5 / elapsedMin : Infinity;
return !(Number(r.wpm) + TIMING_EPS_WPM < floor * TIMING_SLACK);
});
}

// ─── Espelho da allowlist de settings (src/lib/room.ts) ───────────────────────
// A fonte de verdade é `LANGUAGES`/`DIFFICULTIES` em src/lib/languages.ts. Este
// espelho segue o padrão do arquivo (sem import de TS) — se as listas mudarem lá,
Expand Down Expand Up @@ -219,6 +248,76 @@ console.log("\n── Entradas degeneradas ────────────
sanitizeResults(['forjado', legit()], ROOM).length === 1);
}

// ─── Coerência temporal do finish (issue #34) ────────────────────────────────

const RACE_START = Date.parse('2026-07-30T12:00:00.000Z');
const TIMED_ROOM = {
start_at: new Date(RACE_START).toISOString(),
snippet: { title: 't', code: 'x'.repeat(300), language: 'javascript', difficulty: 'easy' }
};
/** Linha já sanitizada (o filtro roda depois de `sanitizeResults`). */
const timed = (over = {}) => ({ ...legit({ finishedAt: null }), ...over });

console.log("\n── Anti-cheat: coerência temporal do finish (#34) ───────────────");
{
assert("ataque da issue: finish 200 ms após o start com wpm 349 → descartado",
dropTemporallyImpossible([timed({ wpm: 349 })], TIMED_ROOM, RACE_START + 200).length === 0);
assert("nada persistido quando todas as linhas caem (array vazio)",
dropTemporallyImpossible([timed({ wpm: 349 })], TIMED_ROOM, RACE_START + 200).length === 0);
assert("residual declarado: esperar ~11 s torna a claim consistente e ela passa",
dropTemporallyImpossible([timed({ wpm: 349 })], TIMED_ROOM, RACE_START + 11_000).length === 1);

// O falso-positivo que a folga existe para evitar: `wpm` sai de correctChars
// (297), o piso sai de typed (300) — sem TIMING_SLACK, 59 < 60 e o honesto cai.
assert("corrida honesta de 60 s com 3 erros não corrigidos (wpm 59) → preservada",
dropTemporallyImpossible([timed({ wpm: 59 })], TIMED_ROOM, RACE_START + 60_000).length === 1);
assert("jogador lento: 10 chars em 5 min, wpm 0 → preservado",
dropTemporallyImpossible(
[timed({ wpm: 0, progress: 10 / 300, finished: false })], TIMED_ROOM, RACE_START + 300_000
).length === 1);
assert("abandono com progress parcial → julgado pelo que digitou, preservado",
dropTemporallyImpossible(
[timed({ wpm: 30, progress: 0.25, finished: false })], TIMED_ROOM, RACE_START + 30_000
).length === 1);
assert("quem não digitou nada (progress 0) → passa",
dropTemporallyImpossible(
[timed({ wpm: 0, progress: 0, finished: false })], TIMED_ROOM, RACE_START + 200
).length === 1);

assert("finishedAt honesto APERTA o piso: terminou aos 200 ms, request aos 60 s → descartado",
dropTemporallyImpossible(
[timed({ wpm: 349, finishedAt: RACE_START + 200 })], TIMED_ROOM, RACE_START + 60_000
).length === 0);
assert("finishedAt forjado no futuro não compra tempo (cai no relógio do servidor)",
dropTemporallyImpossible(
[timed({ wpm: 349, finishedAt: RACE_START + 3_600_000 })], TIMED_ROOM, RACE_START + 200
).length === 0);
assert("finishedAt antes do start (skew de relógio) não derruba o honesto",
dropTemporallyImpossible(
[timed({ wpm: 59, finishedAt: RACE_START - 30_000 })], TIMED_ROOM, RACE_START + 60_000
).length === 1);

assert("sala racing sem start_at → descarta tudo (nunca 'passa tudo')",
dropTemporallyImpossible(
[timed(), timed({ name: 'z' })],
{ start_at: null, snippet: TIMED_ROOM.snippet }, RACE_START + 60_000
).length === 0);
assert("start_at inválido → descarta tudo",
dropTemporallyImpossible(
[timed()], { start_at: 'ontem', snippet: TIMED_ROOM.snippet }, RACE_START + 60_000
).length === 0);
assert("sala sem snippet → sem alvo para medir, a linha passa",
dropTemporallyImpossible(
[timed({ wpm: 349 })], { start_at: TIMED_ROOM.start_at, snippet: null }, RACE_START + 200
).length === 1);
assert("descarta só a linha impossível, preserva a honesta do mesmo payload",
dropTemporallyImpossible(
[timed({ name: 'honesto', wpm: 59 }),
timed({ name: 'forjado', wpm: 349, finishedAt: RACE_START + 200 })],
TIMED_ROOM, RACE_START + 60_000
).map(r => r.name).join() === 'honesto');
}

// ─── validateMatchInsert (linha na tabela `matches`) ──────────────────────────

console.log("\n── validateMatchInsert ──────────────────────────────────────────");
Expand Down
23 changes: 22 additions & 1 deletion src/app/api/rooms/[code]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
resolveLang,
roomUpdateOutcome,
sanitizeResults,
dropTemporallyImpossible,
ABSOLUTE_MAX_PLAYERS,
type ResultRow,
type RoomRow
Expand Down Expand Up @@ -165,7 +166,27 @@ export async function POST(req: Request, { params }: { params: { code: string }
case "finish": {
// Fronteira anti-cheat: `results` vem do cliente e alimenta o leaderboard
// global. Sanitiza/clampa/descarta linhas forjadas antes de persistir.
const results: ResultRow[] = sanitizeResults(body.results, room as RoomRow);
const now = Date.now();
const startMs = room.start_at ? Date.parse(room.start_at) : NaN;
// Corrida ainda no countdown: nenhum caractere pôde ser digitado, então este
// `finish` não é o caminho feliz de ninguém — é ataque ou bug. 409 honesto,
// sem flipar a sala nem persistir (#34). O `<` espelha exatamente as guardas
// do cliente (`shouldFinishRace` e o caminho de sala vazia em `useRoom.ts:508`,
// ambos `now < startMs`), então nenhum `finish` legítimo passa a levar 409.
// Fora de `racing` nada muda: o update condicional abaixo segue devolvendo
// `ok: true` sem linhas, como a #96 decidiu de propósito.
if (room.status === "racing" && Number.isFinite(startMs) && now < startMs)
return NextResponse.json(
{ ok: false, error: "A corrida ainda não começou" },
{ status: 409 }
);
// `sanitizeResults` limita o valor; `dropTemporallyImpossible` olha o relógio
// e descarta o que o tempo decorrido não comporta (piso por jogador, #34).
const results: ResultRow[] = dropTemporallyImpossible(
sanitizeResults(body.results, room as RoomRow),
room as RoomRow,
now
);
// Conditional transition racing→finished so only the first caller persists.
const { data: flipped, error: finishErr } = await sb
.from("rooms")
Expand Down
164 changes: 164 additions & 0 deletions src/lib/room.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { describe, it, expect } from "vitest";
import {
sanitizeResults,
dropTemporallyImpossible,
TIMING_SLACK,
TIMING_EPS_WPM,
buildMatchRow,
buildScoreRows,
clampInt,
Expand Down Expand Up @@ -178,6 +181,167 @@ describe("sanitizeResults — entradas degeneradas", () => {
});
});

// ─── Coerência temporal do finish (issue #34) ────────────────────────────────
// `sanitizeResults` é cega ao relógio: com o teto global em 350, o forjador pede
// 349 e entra no ranking sem digitar. Aqui vive o piso físico — e o que ele
// precisa NÃO fazer: descartar jogador honesto. O insumo do servidor
// (`progress * chars` = caracteres digitados) é limite superior de `correctChars`,
// que é o numerador real do WPM; sem folga, quem erra ou digita devagar cai fora.

const RACE_START = Date.parse("2026-07-30T12:00:00.000Z");
/** Sala em `racing` com snippet de 300 caracteres. */
const TIMED_ROOM = {
start_at: new Date(RACE_START).toISOString(),
snippet: { title: "t", code: "x".repeat(300), language: "javascript", difficulty: "easy" }
} as unknown as RoomRow;

/** Linha final já sanitizada (o filtro roda depois de `sanitizeResults`). */
const row = (over: Partial<ResultRow> = {}): ResultRow => ({
id: "p1",
name: "caio",
color: "#00ff88",
wpm: 85,
accuracy: 97,
errors: 3,
progress: 1,
place: 1,
finished: true,
finishedAt: null,
...over
});

describe("dropTemporallyImpossible — o ataque da issue #34", () => {
it("finish 200 ms depois do start com wpm 349 → descartado", () => {
// 300 chars em 0,2 s exigiriam 18.000 WPM; mesmo com folga o piso é 11.700.
const out = dropTemporallyImpossible([row({ wpm: 349 })], TIMED_ROOM, RACE_START + 200);
expect(out).toEqual([]);
});

it("o mesmo ataque com o tempo mínimo (~11 s) passa — residual declarado na issue", () => {
const out = dropTemporallyImpossible([row({ wpm: 349 })], TIMED_ROOM, RACE_START + 11_000);
expect(out).toHaveLength(1);
});

it("descarta só a linha impossível, preservando as honestas do mesmo payload", () => {
// Corrida de 60 s: o honesto (sem `finishedAt`) é medido pelo relógio do
// servidor; o forjado alega ter varrido os 300 chars em 200 ms.
const out = dropTemporallyImpossible(
[row({ name: "honesto", wpm: 59 }), row({ name: "forjado", wpm: 349, finishedAt: RACE_START + 200 })],
TIMED_ROOM,
RACE_START + 60_000
);
expect(out.map(r => r.name)).toEqual(["honesto"]);
});
});

describe("dropTemporallyImpossible — não descarta jogador honesto", () => {
it("corrida de 60 s com 3 erros não corrigidos (wpm 59 < piso cru 60) → preservada", () => {
// O caso que a folga existe para cobrir: `wpm` sai de correctChars (297),
// o piso sai de typed (300). Sem `TIMING_SLACK` esta linha seria descartada.
const out = dropTemporallyImpossible([row({ wpm: 59 })], TIMED_ROOM, RACE_START + 60_000);
expect(out).toHaveLength(1);
});

it("jogador lento/ocioso: 10 chars em 5 min, wpm 0 → preservado", () => {
const out = dropTemporallyImpossible(
[row({ wpm: 0, progress: 10 / 300, finished: false })],
TIMED_ROOM,
RACE_START + 300_000
);
expect(out).toHaveLength(1);
});

it("abandono no meio (progress parcial) é julgado pelo que digitou", () => {
const out = dropTemporallyImpossible(
[row({ wpm: 30, progress: 0.25, finished: false })],
TIMED_ROOM,
RACE_START + 30_000
);
expect(out).toHaveLength(1);
});

it("quem não digitou nada passa (nenhum trabalho a justificar)", () => {
const out = dropTemporallyImpossible(
[row({ wpm: 0, progress: 0, finished: false })],
TIMED_ROOM,
RACE_START + 200
);
expect(out).toHaveLength(1);
});
});

describe("dropTemporallyImpossible — finishedAt do cliente nunca afrouxa o piso", () => {
it("finishedAt honesto aperta o piso: terminou aos 200 ms, request aos 60 s → descartado", () => {
const out = dropTemporallyImpossible(
[row({ wpm: 349, finishedAt: RACE_START + 200 })],
TIMED_ROOM,
RACE_START + 60_000
);
expect(out).toEqual([]);
});

it("finishedAt forjado no futuro cai no relógio do servidor (não compra tempo)", () => {
const out = dropTemporallyImpossible(
[row({ wpm: 349, finishedAt: RACE_START + 3_600_000 })],
TIMED_ROOM,
RACE_START + 200
);
expect(out).toEqual([]);
});

it("finishedAt antes do start (relógio do cliente defasado) não derruba o honesto", () => {
// Fora da janela [start, now] o piso volta ao E global do servidor — a mesma
// garantia mínima de quem não manda `finishedAt`, sem punir skew de relógio.
const out = dropTemporallyImpossible(
[row({ wpm: 59, finishedAt: RACE_START - 30_000 })],
TIMED_ROOM,
RACE_START + 60_000
);
expect(out).toHaveLength(1);
});
});

describe("dropTemporallyImpossible — sala em estado degenerado", () => {
it("start_at ausente numa sala racing → descarta tudo (nunca 'passa tudo')", () => {
const out = dropTemporallyImpossible(
[row(), row({ name: "z" })],
{ start_at: null, snippet: TIMED_ROOM.snippet } as unknown as RoomRow,
RACE_START + 60_000
);
expect(out).toEqual([]);
});

it("start_at inválido → descarta tudo", () => {
const out = dropTemporallyImpossible(
[row()],
{ start_at: "ontem", snippet: TIMED_ROOM.snippet } as unknown as RoomRow,
RACE_START + 60_000
);
expect(out).toEqual([]);
});

it("sala sem snippet → sem alvo para medir, a linha passa", () => {
const out = dropTemporallyImpossible(
[row({ wpm: 349 })],
{ start_at: TIMED_ROOM.start_at, snippet: null } as unknown as RoomRow,
RACE_START + 200
);
expect(out).toHaveLength(1);
});

it("array vazio → array vazio", () => {
expect(dropTemporallyImpossible([], TIMED_ROOM, RACE_START + 60_000)).toEqual([]);
});
});

describe("dropTemporallyImpossible — as folgas são constantes nomeadas", () => {
it("TIMING_SLACK e TIMING_EPS_WPM são exportadas e afrouxam, nunca apertam", () => {
expect(TIMING_SLACK).toBeGreaterThan(0);
expect(TIMING_SLACK).toBeLessThanOrEqual(1);
expect(TIMING_EPS_WPM).toBeGreaterThanOrEqual(0);
});
});

// ─── Builders do leaderboard (matches/scores) ────────────────────────────────
// Antes da #50 esse mapeamento vivia inline dentro de `persistMatch` (async e
// privada, só alcançável atrás de uma corrida completa) — impossível de proteger.
Expand Down
Loading
Loading