148 lines
5.3 KiB
TypeScript
148 lines
5.3 KiB
TypeScript
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);
|
|
}
|
|
});
|