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.site/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"]); } });