246 lines
12 KiB
JavaScript
246 lines
12 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { randomBytes } from "node:crypto";
|
|
import test from "node:test";
|
|
import { createGameServer } from "./server.mjs";
|
|
|
|
function fixtureUsername() {
|
|
return `card${process.pid.toString(36)}${randomBytes(4).toString("hex")}`;
|
|
}
|
|
|
|
async function startServer(dataDir) {
|
|
const root = path.join(dataDir, "http");
|
|
const staticDir = path.join(root, "static");
|
|
const contentDir = path.join(root, "content");
|
|
mkdirSync(staticDir, { recursive: true });
|
|
mkdirSync(contentDir, { recursive: true });
|
|
const runtime = createGameServer({
|
|
staticDir, contentDir, dataDir, enableShelvedModes: true, corsOrigins: "https://native-test.invalid",
|
|
});
|
|
await new Promise((resolve) => runtime.server.listen(0, "127.0.0.1", resolve));
|
|
return { runtime, origin: `http://127.0.0.1:${runtime.server.address().port}` };
|
|
}
|
|
|
|
async function stopServer(runtime) {
|
|
if (!runtime?.server.listening) return;
|
|
await new Promise((resolve, reject) => {
|
|
runtime.server.close((error) => error ? reject(error) : resolve());
|
|
});
|
|
}
|
|
|
|
async function request(origin, route, { method = "GET", token, body } = {}) {
|
|
const response = await fetch(`${origin}${route}`, {
|
|
method,
|
|
headers: {
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
|
},
|
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
});
|
|
return { status: response.status, body: await response.json() };
|
|
}
|
|
|
|
async function register(origin, username, password) {
|
|
const result = await request(origin, "/api/auth/register", {
|
|
method: "POST", body: { username, password },
|
|
});
|
|
assert.equal(result.status, 201);
|
|
return result.body;
|
|
}
|
|
|
|
test("cloud card PATCH checkpoints atomically per authenticated account and legacy PUT cannot erase cards", async () => {
|
|
const tempDir = mkdtempSync(path.join(os.tmpdir(), "healer-boss-card-checkpoint-"));
|
|
const firstPassword = `A${randomBytes(18).toString("base64url")}`;
|
|
const secondPassword = `B${randomBytes(18).toString("base64url")}`;
|
|
const firstUsername = fixtureUsername();
|
|
const secondUsername = fixtureUsername();
|
|
let runtime;
|
|
|
|
try {
|
|
let service = await startServer(tempDir);
|
|
runtime = service.runtime;
|
|
const unauthenticated = await request(service.origin, "/api/cloud-save", {
|
|
method: "PATCH", body: { expectedRevision: 0, bossCards: {} },
|
|
});
|
|
assert.equal(unauthenticated.status, 401);
|
|
const preflight = await fetch(`${service.origin}/api/cloud-save`, {
|
|
method: "OPTIONS",
|
|
headers: {
|
|
Origin: "https://native-test.invalid",
|
|
"Access-Control-Request-Method": "PATCH",
|
|
"Access-Control-Request-Headers": "authorization,content-type",
|
|
},
|
|
});
|
|
assert.equal(preflight.status, 204);
|
|
assert.equal(preflight.headers.get("access-control-allow-origin"), "https://native-test.invalid");
|
|
assert.match(preflight.headers.get("access-control-allow-methods"), /\bPATCH\b/);
|
|
const unauthorizedMalformed = await request(service.origin, "/api/cloud-save", {
|
|
method: "PATCH", token: "not-a-valid-session-token",
|
|
body: { expectedRevision: 0 },
|
|
});
|
|
assert.equal(unauthorizedMalformed.status, 401);
|
|
|
|
const firstAccount = await register(service.origin, firstUsername, firstPassword);
|
|
const secondAccount = await register(service.origin, secondUsername, secondPassword);
|
|
assert.notEqual(firstAccount.account.id, secondAccount.account.id);
|
|
assert.ok(firstAccount.token);
|
|
assert.ok(secondAccount.token);
|
|
const missingCards = await request(service.origin, "/api/cloud-save", {
|
|
method: "PATCH", token: firstAccount.token, body: { expectedRevision: 0 },
|
|
});
|
|
assert.equal(missingCards.status, 400);
|
|
assert.equal(missingCards.body.code, "invalid_boss_cards");
|
|
|
|
const initialFirst = await request(service.origin, "/api/cloud-save", { token: firstAccount.token });
|
|
const initialSecond = await request(service.origin, "/api/cloud-save", { token: secondAccount.token });
|
|
assert.equal(initialFirst.body.revision, 0);
|
|
assert.deepEqual(initialFirst.body.data, { version: 1, characters: [] });
|
|
assert.equal(initialSecond.body.revision, 0);
|
|
|
|
const rawCards = JSON.parse(
|
|
'{"counts":{"__proto__":{"normal":9},"wow:55":{"normal":2.9,"rare":0,"epic":1.7,"legendary":9007199254740992,"unknown":50},"":{"normal":7}},"lastClaims":{"__proto__":"200:1","spawn:a":"100:2","ignored":42}}',
|
|
);
|
|
const firstCheckpoint = await request(service.origin, "/api/cloud-save", {
|
|
method: "PATCH",
|
|
token: firstAccount.token,
|
|
body: { accountId: secondAccount.account.id, expectedRevision: 0, bossCards: rawCards },
|
|
});
|
|
assert.equal(firstCheckpoint.status, 200);
|
|
assert.equal(firstCheckpoint.body.revision, 1);
|
|
assert.deepEqual(firstCheckpoint.body.data.version, 1);
|
|
assert.deepEqual(firstCheckpoint.body.data.characters, []);
|
|
assert.equal(firstCheckpoint.body.data.bossCards.counts["wow:55"].normal, 2);
|
|
assert.equal(firstCheckpoint.body.data.bossCards.counts["wow:55"].epic, 1);
|
|
assert.equal(firstCheckpoint.body.data.bossCards.counts["wow:55"].legendary, Number.MAX_SAFE_INTEGER);
|
|
assert.equal(firstCheckpoint.body.data.bossCards.counts["wow:55"].rare, undefined);
|
|
assert.equal(firstCheckpoint.body.data.bossCards.counts["wow:55"].unknown, undefined);
|
|
assert.equal(firstCheckpoint.body.data.bossCards.counts[""], undefined);
|
|
assert.equal(firstCheckpoint.body.data.bossCards.counts["__proto__"].normal, 9);
|
|
assert.equal(firstCheckpoint.body.data.bossCards.lastClaims["__proto__"], "200:1");
|
|
assert.equal(firstCheckpoint.body.data.bossCards.lastClaims["spawn:a"], "100:2");
|
|
assert.equal(firstCheckpoint.body.data.bossCards.lastClaims.ignored, undefined);
|
|
|
|
const conflict = await request(service.origin, "/api/cloud-save", {
|
|
method: "PATCH", token: firstAccount.token,
|
|
body: { expectedRevision: 0, bossCards: { counts: { "wow:55": { normal: 50 } } } },
|
|
});
|
|
assert.equal(conflict.status, 409);
|
|
assert.equal(conflict.body.code, "revision_conflict");
|
|
const unchangedAfterConflict = await request(service.origin, "/api/cloud-save", { token: firstAccount.token });
|
|
assert.deepEqual(unchangedAfterConflict.body, firstCheckpoint.body);
|
|
|
|
const invalidRevision = await request(service.origin, "/api/cloud-save", {
|
|
method: "PATCH", token: firstAccount.token,
|
|
body: { expectedRevision: 1.5, bossCards: {} },
|
|
});
|
|
assert.equal(invalidRevision.status, 400);
|
|
assert.equal(invalidRevision.body.code, "invalid_revision");
|
|
|
|
const retried = await request(service.origin, "/api/cloud-save", {
|
|
method: "PATCH", token: firstAccount.token,
|
|
body: {
|
|
expectedRevision: 1,
|
|
bossCards: {
|
|
counts: { "wow:55": { normal: 5, rare: 1 }, "wow:new": { epic: 1 } },
|
|
lastClaims: { "spawn:a": "100:1", "spawn:b": "alpha" },
|
|
},
|
|
},
|
|
});
|
|
assert.equal(retried.status, 200);
|
|
assert.equal(retried.body.revision, 2);
|
|
assert.equal(retried.body.data.bossCards.counts["wow:55"].normal, 5);
|
|
assert.equal(retried.body.data.bossCards.counts["wow:55"].rare, 1);
|
|
assert.equal(retried.body.data.bossCards.counts["wow:new"].epic, 1);
|
|
assert.equal(retried.body.data.bossCards.lastClaims["spawn:a"], "100:2");
|
|
|
|
const otherCheckpoint = await request(service.origin, "/api/cloud-save", {
|
|
method: "PATCH", token: secondAccount.token,
|
|
body: { expectedRevision: 0, bossCards: { counts: { "rom:9": { rare: 3 } } } },
|
|
});
|
|
assert.equal(otherCheckpoint.status, 200);
|
|
assert.equal(otherCheckpoint.body.revision, 1);
|
|
assert.deepEqual(Object.keys(otherCheckpoint.body.data.bossCards.counts), ["rom:9"]);
|
|
const firstStillIsolated = await request(service.origin, "/api/cloud-save", { token: firstAccount.token });
|
|
assert.equal(firstStillIsolated.body.revision, 2);
|
|
assert.equal(firstStillIsolated.body.data.bossCards.counts["rom:9"], undefined);
|
|
|
|
const stalePut = await request(service.origin, "/api/cloud-save", {
|
|
method: "PUT", token: firstAccount.token,
|
|
body: {
|
|
data: {
|
|
version: 2,
|
|
characters: [{ id: "character-a" }],
|
|
uiPreferences: { theme: "night" },
|
|
futureField: { preservedDuringThisWrite: true },
|
|
bossCards: {
|
|
counts: { "wow:55": { normal: 1 }, "wow:legacy": { legendary: 1 } },
|
|
lastClaims: { "spawn:a": "99:9", "spawn:c": "claim-c" },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
assert.equal(stalePut.status, 200);
|
|
assert.equal(stalePut.body.revision, 3);
|
|
const stalePutExpectedData = JSON.parse(
|
|
'{"version":2,"characters":[{"id":"character-a"}],"uiPreferences":{"theme":"night"},"futureField":{"preservedDuringThisWrite":true},"bossCards":{"counts":{"__proto__":{"normal":9},"wow:55":{"normal":5,"rare":1,"epic":1,"legendary":9007199254740991},"wow:new":{"epic":1},"wow:legacy":{"legendary":1}},"lastClaims":{"__proto__":"200:1","spawn:a":"100:2","spawn:b":"alpha","spawn:c":"claim-c"}}}',
|
|
);
|
|
assert.deepEqual(stalePut.body.data, stalePutExpectedData);
|
|
assert.equal(stalePut.body.data.bossCards.counts["wow:55"].normal, 5);
|
|
assert.equal(stalePut.body.data.bossCards.counts["wow:55"].rare, 1);
|
|
assert.equal(stalePut.body.data.bossCards.counts["wow:legacy"].legendary, 1);
|
|
assert.equal(stalePut.body.data.bossCards.lastClaims["spawn:a"], "100:2");
|
|
|
|
const omittedCardPut = await request(service.origin, "/api/cloud-save", {
|
|
method: "PUT", token: firstAccount.token,
|
|
body: { data: { version: 3, characters: [], custom: "legacy replacement" } },
|
|
});
|
|
assert.equal(omittedCardPut.status, 200);
|
|
assert.equal(omittedCardPut.body.revision, 4);
|
|
assert.deepEqual(omittedCardPut.body.data, {
|
|
version: 3,
|
|
characters: [],
|
|
custom: "legacy replacement",
|
|
bossCards: stalePut.body.data.bossCards,
|
|
});
|
|
|
|
await stopServer(runtime);
|
|
runtime = null;
|
|
service = await startServer(tempDir);
|
|
runtime = service.runtime;
|
|
const firstLogin = await request(service.origin, "/api/auth/login", {
|
|
method: "POST", body: { username: firstUsername, password: firstPassword },
|
|
});
|
|
const secondLogin = await request(service.origin, "/api/auth/login", {
|
|
method: "POST", body: { username: secondUsername, password: secondPassword },
|
|
});
|
|
assert.equal(firstLogin.status, 200);
|
|
assert.equal(secondLogin.status, 200);
|
|
const reopenedFirst = await request(service.origin, "/api/cloud-save", { token: firstLogin.body.token });
|
|
const reopenedSecond = await request(service.origin, "/api/cloud-save", { token: secondLogin.body.token });
|
|
assert.deepEqual(reopenedFirst.body.data, omittedCardPut.body.data);
|
|
assert.deepEqual(reopenedSecond.body.data.bossCards.counts, { "rom:9": { rare: 3 } });
|
|
|
|
const concurrent = await Promise.all([0, 1].map(() => request(service.origin, "/api/cloud-save", {
|
|
method: "PATCH", token: firstLogin.body.token,
|
|
body: { expectedRevision: 4, bossCards: stalePut.body.data.bossCards },
|
|
})));
|
|
assert.deepEqual(concurrent.map((result) => result.status).sort(), [200, 409]);
|
|
assert.equal(concurrent.find((result) => result.status === 409).body.code, "revision_conflict");
|
|
const afterConcurrentCheckpoint = await request(service.origin, "/api/cloud-save", { token: firstLogin.body.token });
|
|
assert.equal(afterConcurrentCheckpoint.body.revision, 5);
|
|
assert.deepEqual(afterConcurrentCheckpoint.body.data, omittedCardPut.body.data);
|
|
const duplicateCheckpoint = await request(service.origin, "/api/cloud-save", {
|
|
method: "PATCH", token: firstLogin.body.token,
|
|
body: { expectedRevision: 5, bossCards: stalePut.body.data.bossCards },
|
|
});
|
|
assert.equal(duplicateCheckpoint.status, 200);
|
|
assert.equal(duplicateCheckpoint.body.revision, 6);
|
|
assert.deepEqual(duplicateCheckpoint.body.data.bossCards, omittedCardPut.body.data.bossCards);
|
|
} finally {
|
|
await stopServer(runtime);
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|