forked from Mike/freno-dev
The PvP_Characters table tallied wins and losses but held no token balance and no purchase endpoint: earnings were client-reported (winners credited themselves locally), so verifying only a spend would have been security theater — a cheater could mint tokens by self-reporting battle wins. The server is now the ledger authority (FRE-142): registerCharacter seeds the balance from the client cache exactly once and reconciles it on every sync; battleResult requires the verified Lineage JWT bearer, rejects non-participants, and credits the winner via the reward formula ported from the client (computed from both registered rows); a new purchase mutation checks cost, balance, and cooldown in one conditional UPDATE scoped to the caller-owned row, so the spend is atomic under concurrency and rate limiting survives cold starts. Schema changes ship as an idempotent pvp-schema bootstrap (pvpTokens, ownerUserId, lastPurchaseAt), mirroring the analytics-schema pattern. Client-side counterpart shipped in the lineage repo; design record in its docs/security/pvp-server-side-purchases.md. Tests: pvp-schema (DDL + legacy-column migration + idempotency), pvp-rewards (formula parity with the client port, cost table), pvp-ledger (seed-once reconciliation, owner binding, participant verification, atomic spend, cooldown, refusal reasons) — 31 new tests against in-memory SQLite; suite 572 pass (1 pre-existing audit date-filter failure, unrelated).
122 lines
3.9 KiB
TypeScript
122 lines
3.9 KiB
TypeScript
import { describe, expect, it, beforeEach } from "bun:test";
|
|
import { Database } from "bun:sqlite";
|
|
import {
|
|
PVP_CHARACTERS_ADDED_COLUMNS,
|
|
PVP_CHARACTERS_DDL,
|
|
ensurePvpSchema
|
|
} from "./pvp-schema";
|
|
|
|
// Same rationale as analytics-schema.test.ts: PvP_Characters lived only in
|
|
// the live Turso database, so nothing proved the DDL, the added columns, and
|
|
// the router's statements agreed. These tests apply the real schema to a
|
|
// throwaway in-memory database and exercise the idempotent applier against
|
|
// both a fresh database and a pre-FRE-142 table shape.
|
|
|
|
let db: Database;
|
|
|
|
/** Minimal adapter so `ensurePvpSchema` can drive bun:sqlite. */
|
|
function sqliteConn(db: Database) {
|
|
return {
|
|
execute: async (stmt: { sql: string }) => {
|
|
const sql = stmt.sql.trim().toUpperCase();
|
|
if (sql.startsWith("SELECT")) {
|
|
return { rows: db.query(stmt.sql).all() };
|
|
}
|
|
db.run(stmt.sql);
|
|
return { rows: [] };
|
|
}
|
|
};
|
|
}
|
|
|
|
function columnNames(table: string): string[] {
|
|
return db
|
|
.query(`SELECT name FROM pragma_table_info('${table}')`)
|
|
.all()
|
|
.map((row) => (row as { name: string }).name);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
db = new Database(":memory:");
|
|
});
|
|
|
|
describe("PVP_CHARACTERS_DDL", () => {
|
|
it("creates the table with every column the router touches", () => {
|
|
db.run(PVP_CHARACTERS_DDL);
|
|
const columns = columnNames("PvP_Characters");
|
|
expect(columns).toContain("linkID");
|
|
expect(columns).toContain("winCount");
|
|
expect(columns).toContain("lossCount");
|
|
expect(columns).toContain("tokenRedemptionCount");
|
|
expect(columns).toContain("pvpTokens");
|
|
expect(columns).toContain("ownerUserId");
|
|
expect(columns).toContain("lastPurchaseAt");
|
|
});
|
|
|
|
it("is idempotent", () => {
|
|
db.run(PVP_CHARACTERS_DDL);
|
|
db.run(PVP_CHARACTERS_DDL);
|
|
expect(columnNames("PvP_Characters").length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe("ensurePvpSchema", () => {
|
|
it("creates the full table on a fresh database", async () => {
|
|
await ensurePvpSchema(sqliteConn(db));
|
|
const columns = columnNames("PvP_Characters");
|
|
for (const column of PVP_CHARACTERS_ADDED_COLUMNS) {
|
|
expect(columns).toContain(column.name);
|
|
}
|
|
});
|
|
|
|
it("adds the FRE-142 columns to a pre-existing legacy table", async () => {
|
|
// The legacy shape: everything except the three new columns.
|
|
db.run(`
|
|
CREATE TABLE PvP_Characters (
|
|
linkID TEXT PRIMARY KEY,
|
|
blessing TEXT,
|
|
playerClass TEXT,
|
|
name TEXT,
|
|
maxHealth INTEGER,
|
|
maxSanity INTEGER,
|
|
maxMana INTEGER,
|
|
baseManaRegen INTEGER,
|
|
strength INTEGER,
|
|
intelligence INTEGER,
|
|
dexterity INTEGER,
|
|
resistanceTable TEXT,
|
|
damageTable TEXT,
|
|
attackStrings TEXT,
|
|
knownSpells TEXT,
|
|
pushToken TEXT,
|
|
pushCurrentlyEnabled INTEGER,
|
|
winCount INTEGER NOT NULL DEFAULT 0,
|
|
lossCount INTEGER NOT NULL DEFAULT 0,
|
|
tokenRedemptionCount INTEGER NOT NULL DEFAULT 0
|
|
)
|
|
`);
|
|
expect(columnNames("PvP_Characters")).not.toContain("pvpTokens");
|
|
|
|
await ensurePvpSchema(sqliteConn(db));
|
|
|
|
const columns = columnNames("PvP_Characters");
|
|
expect(columns).toContain("pvpTokens");
|
|
expect(columns).toContain("ownerUserId");
|
|
expect(columns).toContain("lastPurchaseAt");
|
|
// Existing data is untouched by the migration.
|
|
db.run(
|
|
`INSERT INTO PvP_Characters (linkID) VALUES ('legacy-row')`
|
|
);
|
|
const row = db
|
|
.query(`SELECT pvpTokens, ownerUserId FROM PvP_Characters WHERE linkID = 'legacy-row'`)
|
|
.get() as { pvpTokens: number | null; ownerUserId: string | null };
|
|
expect(row.pvpTokens).toBeNull();
|
|
expect(row.ownerUserId).toBeNull();
|
|
});
|
|
|
|
it("is idempotent on an already-migrated table", async () => {
|
|
await ensurePvpSchema(sqliteConn(db));
|
|
await ensurePvpSchema(sqliteConn(db));
|
|
expect(columnNames("PvP_Characters")).toContain("pvpTokens");
|
|
});
|
|
});
|