Add developer API, Prometheus metrics, and community links
This commit is contained in:
238
tests/developer-api.test.ts
Normal file
238
tests/developer-api.test.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
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"]);
|
||||
}
|
||||
});
|
||||
147
tests/prometheus.test.ts
Normal file
147
tests/prometheus.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
metricDefinitions,
|
||||
formatMetrics,
|
||||
prometheusAction,
|
||||
prometheusConfig,
|
||||
} from "../shared/prometheus";
|
||||
import { metricsResponse } from "../lib/prometheus-response";
|
||||
import { grafanaDashboard } from "../shared/grafana-dashboard";
|
||||
|
||||
const token = "mg_metrics_" + "a".repeat(64);
|
||||
const samples = Object.entries(metricDefinitions).flatMap(
|
||||
([metric, definition]) => {
|
||||
let dimensions: Record<string, string>[] = [{}];
|
||||
for (const [key, values] of Object.entries(definition.labels) as [
|
||||
string,
|
||||
readonly string[],
|
||||
][])
|
||||
dimensions = dimensions.flatMap((labels) =>
|
||||
values.map((value) => ({ ...labels, [key]: value })),
|
||||
);
|
||||
return dimensions.map((labels) => ({ metric, labels, value: 0 }));
|
||||
},
|
||||
);
|
||||
|
||||
test("metrics exposition is bounded, complete and contains only fixed aggregate dimensions", () => {
|
||||
const text = formatMetrics(samples);
|
||||
assert.equal(samples.length, 43);
|
||||
assert.equal(
|
||||
text.split("\n").filter((line) => line && !line.startsWith("#")).length,
|
||||
43,
|
||||
);
|
||||
assert.ok(text.endsWith("\n"));
|
||||
assert.match(text, /# TYPE minegeo_games gauge/);
|
||||
assert.match(text, /minegeo_games\{difficulty="easy",status="complete"\} 0/);
|
||||
for (const bad of [
|
||||
[...samples, samples[0]],
|
||||
samples.filter((s) => s.metric !== "minegeo_accounts"),
|
||||
[{ ...samples[0], metric: "evil_metric" }, ...samples.slice(1)],
|
||||
[
|
||||
{ ...samples[0], labels: { username: "private-player" } },
|
||||
...samples.slice(1),
|
||||
],
|
||||
[{ ...samples[0], value: NaN }, ...samples.slice(1)],
|
||||
[{ ...samples[0], value: -1 }, ...samples.slice(1)],
|
||||
])
|
||||
assert.throws(() => formatMetrics(bad));
|
||||
});
|
||||
|
||||
test("scrape authentication rejects cookie-only, query-only and device-style credentials before reading data", async () => {
|
||||
for (const authorization of [
|
||||
null,
|
||||
"Bearer contributor-token",
|
||||
"Basic abc",
|
||||
token,
|
||||
`Bearer ${token}\n`,
|
||||
`Bearer ${token}suffix`,
|
||||
]) {
|
||||
const headers = new Headers({ cookie: "admin=session" });
|
||||
// Headers reject newlines before our endpoint is involved.
|
||||
if (authorization?.includes("\n")) continue;
|
||||
if (authorization) headers.set("authorization", authorization);
|
||||
const response = await metricsResponse({ headers }, async () => {
|
||||
assert.fail("must not query without a valid credential shape");
|
||||
});
|
||||
assert.equal(response.status, 401);
|
||||
assert.match(response.headers.get("cache-control")!, /no-store/);
|
||||
}
|
||||
const response = await metricsResponse(
|
||||
{ headers: new Headers({ authorization: `Bearer ${token}` }) },
|
||||
async (hash) => {
|
||||
assert.equal(hash, createHash("sha256").update(token).digest("hex"));
|
||||
return null;
|
||||
},
|
||||
);
|
||||
assert.equal(response.status, 401);
|
||||
});
|
||||
|
||||
test("scrape returns Prometheus text on success and fails closed during database or validation outages", async () => {
|
||||
const request = {
|
||||
headers: new Headers({ authorization: `Bearer ${token}` }),
|
||||
};
|
||||
const response = await metricsResponse(request, async () => samples);
|
||||
assert.equal(response.status, 200);
|
||||
assert.match(
|
||||
response.headers.get("content-type")!,
|
||||
/text\/plain; version=0.0.4/,
|
||||
);
|
||||
const body = await response.text();
|
||||
assert.match(body, /minegeo_exporter_read_duration_seconds [0-9.]+/);
|
||||
assert.ok(!body.includes(token));
|
||||
for (const read of [
|
||||
async () => {
|
||||
throw new Error("private database detail");
|
||||
},
|
||||
async () => [],
|
||||
]) {
|
||||
const failed = await metricsResponse(request, read);
|
||||
assert.equal(failed.status, 503);
|
||||
assert.equal(await failed.text(), "Metrics temporarily unavailable\n");
|
||||
}
|
||||
});
|
||||
|
||||
test("copyable YAML scopes bearer credentials to the HTTPS metrics target and disallows malformed setup", () => {
|
||||
const config = prometheusConfig(
|
||||
"https://minegeo.vercel.app/api/metrics",
|
||||
token,
|
||||
);
|
||||
assert.match(config, /metrics_path: "\/api\/metrics"/);
|
||||
assert.match(config, /targets: \["minegeo.vercel.app"\]/);
|
||||
assert.match(config, /follow_redirects: false/);
|
||||
assert.match(config, /type: Bearer/);
|
||||
assert.match(config, /scrape_interval: 60s/);
|
||||
for (const endpoint of [
|
||||
"http://localhost/api/metrics",
|
||||
"https://user:secret@example.org/api/metrics",
|
||||
"https://example.org/api/metrics?secret=bad",
|
||||
])
|
||||
assert.throws(() => prometheusConfig(endpoint, token));
|
||||
assert.throws(() =>
|
||||
prometheusConfig("https://example.org/api/metrics", "bad\nconfig"),
|
||||
);
|
||||
assert.equal(
|
||||
prometheusAction.safeParse({ action: "rotate", role: "admin" }).success,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("Grafana dashboard imports with a selected Prometheus source and avoids rates on retained-count gauges", () => {
|
||||
const dashboard = grafanaDashboard();
|
||||
assert.equal(dashboard.__inputs[0].pluginId, "prometheus");
|
||||
assert.equal(dashboard.panels.length, 15);
|
||||
const json = JSON.stringify(dashboard);
|
||||
assert.ok(!json.includes(token));
|
||||
const supported = new Set([
|
||||
...Object.keys(metricDefinitions),
|
||||
"minegeo_exporter_read_duration_seconds",
|
||||
]);
|
||||
for (const panel of dashboard.panels) {
|
||||
assert.equal(panel.datasource.uid, "${DS_PROMETHEUS}");
|
||||
assert.ok(!/\b(rate|increase)\(/.test(panel.targets[0].expr));
|
||||
for (const name of panel.targets[0].expr.match(/minegeo_[a-z0-9_]+/g) ?? [])
|
||||
assert.ok(supported.has(name), name);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user