Files
MineGeo/tests/developer-api.test.ts

239 lines
6.8 KiB
TypeScript

import assert from "node:assert/strict";
import { test } from "node:test";
import { createHash } from "node:crypto";
import {
developerResponse,
type DeveloperData,
} from "../lib/developer-response";
import {
developerKeyInput,
developerPagination,
developerPage,
type DeveloperScope,
} from "../shared/developer-api";
import { developerOpenApi } from "../shared/developer-openapi";
const owner = "00000000-0000-4000-8000-000000000001";
const keyId = "00000000-0000-4000-8000-000000000002";
const token = "mg_dev_" + "a".repeat(64);
function request(
path = "maps",
authorization = `Bearer ${token}`,
method = "GET",
) {
return new Request(`https://minegeo.vercel.app/api/dev/v1/${path}`, {
method,
headers: { authorization, cookie: "admin=ignored" },
});
}
function deps(scopes: DeveloperScope[] = ["maps:read"]): DeveloperData {
return {
consume: async (hash) => {
assert.equal(hash, createHash("sha256").update(token).digest("hex"));
return {
ok: true,
keyId,
ownerId: owner,
scopes,
limit: 60,
remaining: 59,
ownerLimit: 180,
ownerRemaining: 179,
resetAt: Math.floor(Date.now() / 1000) + 30,
};
},
maps: async () => ({
maps: [],
pagination: { limit: 20, offset: 0, total: 0, nextOffset: null },
}),
map: async () => null,
account: async () => ({ id: owner }),
games: async () => ({ games: [] }),
};
}
test("developer API accepts only dedicated Bearer keys, never cookies, contributor, metrics or query credentials", async () => {
for (const auth of [
"",
"Bearer contributor-token",
"Bearer mg_metrics_" + "a".repeat(64),
token,
`Bearer ${token}suffix`,
]) {
const d = deps();
d.consume = async () => {
assert.fail("must reject credential shape before querying");
};
const r = await developerResponse(
request("maps?key=" + token, auth),
["maps"],
d,
);
assert.equal(r.status, 401);
assert.match(r.headers.get("cache-control")!, /no-store/);
}
});
test("API denies revoked/expired/unknown keys and fails closed without leaking database errors", async () => {
for (const consume of [
async () => ({ ok: false, error: "invalid_key" }),
async () => {
throw Error("private SQL password");
},
async () => ({ ok: true }),
]) {
const d = deps();
d.consume = consume;
d.maps = async () => {
assert.fail("must not read data");
};
const r = await developerResponse(request(), ["maps"], d);
assert.ok([401, 503].includes(r.status));
assert.ok(!(await r.text()).includes("private SQL"));
}
});
test("each private API permission is independent; owner identity comes only from the validated key", async () => {
for (const [path, scope] of [
["me", "account:read"],
["me/maps", "account:maps:read"],
["me/games", "account:games:read"],
] as const) {
const denied = deps();
denied.account =
denied.games =
denied.maps =
async () => {
assert.fail("missing permission must not read");
};
assert.equal(
(await developerResponse(request(path), path.split("/"), denied)).status,
403,
);
const allowed = deps([scope]);
allowed.account = async (id) => {
assert.equal(id, owner);
return { id };
};
allowed.maps = async (_page, id) => {
assert.equal(id, owner);
return { maps: [] };
};
allowed.games = async (id) => {
assert.equal(id, owner);
return { games: [] };
};
assert.equal(
(await developerResponse(request(path), path.split("/"), allowed)).status,
200,
);
}
assert.equal(
(
await developerResponse(
request("me?ownerId=other"),
["me"],
deps(["account:read"]),
)
).status,
400,
);
});
test("quota rejection returns Retry-After and headers before any data read", async () => {
const d = deps();
d.consume = async () => ({
ok: false,
error: "rate_limited",
limit: 60,
remaining: 0,
ownerLimit: 180,
ownerRemaining: 0,
resetAt: Math.floor(Date.now() / 1000) + 30,
});
d.maps = async () => {
assert.fail("quota exhausted");
};
const r = await developerResponse(request(), ["maps"], d);
assert.equal(r.status, 429);
assert.equal(r.headers.get("ratelimit-remaining"), "0");
assert.ok(Number(r.headers.get("retry-after")) > 0);
assert.equal(r.headers.get("x-ratelimit-owner-limit"), "180");
});
test("published map lookup hides missing/private maps and forbids mutation", async () => {
const d = deps();
d.map = async (id) => {
assert.equal(id, owner);
return null;
};
assert.equal(
(await developerResponse(request("maps/" + owner), ["maps", owner], d))
.status,
404,
);
assert.equal(
(
await developerResponse(
request("maps/not-a-uuid"),
["maps", "not-a-uuid"],
d,
)
).status,
400,
);
const response = await developerResponse(
request("maps", `Bearer ${token}`, "POST"),
["maps"],
d,
);
assert.equal(response.status, 405);
assert.equal(response.headers.get("allow"), "GET");
});
test("pagination is bounded, search literal and strict; key scopes/expiry cannot escalate", () => {
const url = (q: string) => new URL("https://example.com/?" + q);
for (const q of [
"limit=51",
"limit=0",
"limit=1.5",
"offset=-1",
"offset=10001",
"limit=1&limit=2",
"owner_id=other",
"q=" + "x".repeat(81),
])
assert.throws(() => developerPagination(url(q), true), q);
assert.deepEqual(
developerPagination(url("limit=50&offset=10&q=%25_"), true),
{ limit: 50, offset: 10, q: "%_" },
);
assert.equal(
developerPage(80, { limit: 50, offset: 50, q: "" }).nextOffset,
null,
);
assert.equal(
developerPage(11000, { limit: 20, offset: 10000, q: "" }).nextOffset,
null,
);
const input = { name: "Python", scopes: ["maps:read"], expiresDays: 90 };
assert.ok(developerKeyInput.safeParse(input).success);
for (const bad of [
{ ...input, scopes: ["admin"] },
{ ...input, scopes: [] },
{ ...input, scopes: ["maps:read", "maps:read"] },
{ ...input, expiresDays: 366 },
{ ...input, ownerId: owner },
])
assert.equal(developerKeyInput.safeParse(bad).success, false);
});
test("OpenAPI documents only bounded read-only endpoints with explicit key permissions", () => {
assert.equal(developerOpenApi.openapi, "3.1.0");
assert.deepEqual(Object.keys(developerOpenApi.paths).sort(), [
"/maps",
"/maps/{id}",
"/me",
"/me/games",
"/me/maps",
]);
for (const path of Object.values(developerOpenApi.paths)) {
assert.deepEqual(Object.keys(path), ["get"]);
assert.equal(path.get.security[0].DeveloperKey.length, 0);
assert.ok(path.get["x-required-scope"]);
}
});