diff --git a/.env.example b/.env.example index 433ef796..7e6a6d44 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,13 @@ IPAPIIS_API_KEY="" IP2LOCATION_API_KEY="" MAC_LOOKUP_API_KEY="" RIPESTAT_SOURCE_APP="" +# Shareable diagnostic reports — stored in Cloudflare Workers KV. Both values +# below must be set (together with CLOUDFLARE_API_KEY, which additionally +# needs the "Workers KV Storage: Edit" permission) or the share-link feature +# stays hidden. CLOUDFLARE_KV_NAMESPACE_ID is the namespace's hex ID from the +# dashboard, not its name. +CLOUDFLARE_ACCOUNT_ID="" +CLOUDFLARE_KV_NAMESPACE_ID="" # MaxMind MAXMIND_ACCOUNT_ID="" MAXMIND_LICENSE_KEY="" diff --git a/README.md b/README.md index 03a8c04c..8f381932 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,9 @@ Download `GeoLite2-City.mmdb` and `GeoLite2-ASN.mmdb` from your MaxMind account | `IPINFO_API_KEY` | No | `""` | API Token for IPInfo.io, used to obtain IP geolocation information through IPInfo.io | | `IPAPIIS_API_KEY` | No | `""` | API Key for IPAPI.is, used to obtain IP geolocation information through IPAPI.is | | `IP2LOCATION_API_KEY` | No | `""` | API Key for IP2Location.io, used to obtain IP geolocation information through IP2Location.io | -| `CLOUDFLARE_API_KEY` | No | `""` | API Key for Cloudflare, used to obtain AS system information through Cloudflare | +| `CLOUDFLARE_API_KEY` | No | `""` | API Key for Cloudflare, used to obtain AS system information; with the two KV variables below (and the "Workers KV Storage: Edit" token permission) it also powers shareable diagnostic reports | +| `CLOUDFLARE_ACCOUNT_ID` | No | `""` | Cloudflare account ID, required for storing shareable diagnostic reports in Workers KV | +| `CLOUDFLARE_KV_NAMESPACE_ID` | No | `""` | Hex ID (not the name) of the Workers KV namespace that stores shareable diagnostic reports | | `RIPESTAT_SOURCE_APP` | No | `""` | Source app name for RIPE.net, used to obtain ASN history information through RIPE.net | | `MAC_LOOKUP_API_KEY` | No | `""` | API Key for MAC Lookup, used to obtain MAC address information | | `VITE_CURL_IPV4_DOMAIN` | No | `""` | Provides the IPv4 domain for the CURL API to users | diff --git a/README_FR.md b/README_FR.md index d2ba5500..9c7acdf2 100644 --- a/README_FR.md +++ b/README_FR.md @@ -148,7 +148,9 @@ Téléchargez `GeoLite2-City.mmdb` et `GeoLite2-ASN.mmdb` depuis votre compte Ma | `IPINFO_API_KEY` | Non | `""` | Jeton API pour IPInfo.io, utilisé pour obtenir des informations de géolocalisation sur l'adresse IP via IPInfo.io | | `IPAPIIS_API_KEY` | Non | `""` | Clé API pour IPAPI.is, utilisée pour obtenir des informations de géolocalisation sur l'adresse IP via IPAPI.is | | `IP2LOCATION_API_KEY` | Non | `""` | Clé API pour IP2Location.io, utilisée pour obtenir des informations de géolocalisation sur l'adresse IP via IP2Location.io | -| `CLOUDFLARE_API_KEY` | Non | `""` | Clé API pour Cloudflare, utilisée pour obtenir des informations sur le système AS via Cloudflare | +| `CLOUDFLARE_API_KEY` | Non | `""` | Clé API pour Cloudflare, utilisée pour les informations AS ; avec les deux variables KV ci-dessous (et la permission « Workers KV Storage: Edit »), elle alimente aussi le partage de rapports de diagnostic | +| `CLOUDFLARE_ACCOUNT_ID` | Non | `""` | ID du compte Cloudflare, requis pour stocker les rapports de diagnostic partageables dans Workers KV | +| `CLOUDFLARE_KV_NAMESPACE_ID` | Non | `""` | ID hexadécimal (pas le nom) de l'espace de noms Workers KV qui stocke les rapports de diagnostic partageables | | `RIPESTAT_SOURCE_APP` | Non | `""` | Nom de l'application source pour RIPE.net, utilisé pour obtenir des informations sur l'historique ASN via RIPE.net | | `MAC_LOOKUP_API_KEY` | Non | `""` | Clé API pour MAC Lookup, utilisée pour obtenir des informations sur l'adresse MAC via MAC Lookup | | `VITE_CURL_IPV4_DOMAIN` | Non | `""` | Fournit aux utilisateurs le domaine IPv4 pour l'API CURL | diff --git a/README_TR.md b/README_TR.md index d3bfa999..4b13cc72 100644 --- a/README_TR.md +++ b/README_TR.md @@ -148,7 +148,9 @@ MaxMind hesabınızdan `GeoLite2-City.mmdb` ve `GeoLite2-ASN.mmdb` dosyalarını | `IPINFO_API_KEY` | Hayır | `""` | IPInfo.io API token'ı, IP konum bilgisi almak için | | `IPAPIIS_API_KEY` | Hayır | `""` | IPAPI.is API anahtarı, IP konum bilgisi almak için | | `IP2LOCATION_API_KEY` | Hayır | `""` | IP2Location.io API anahtarı, IP konum bilgisi almak için | -| `CLOUDFLARE_API_KEY` | Hayır | `""` | Cloudflare API anahtarı, AS sistemi bilgisi almak için | +| `CLOUDFLARE_API_KEY` | Hayır | `""` | Cloudflare API anahtarı, AS sistemi bilgisi almak için; aşağıdaki iki KV değişkeniyle ("Workers KV Storage: Edit" izni gerekir) tanı raporu paylaşımını da sağlar | +| `CLOUDFLARE_ACCOUNT_ID` | Hayır | `""` | Cloudflare hesap kimliği, paylaşılabilir tanı raporlarını Workers KV'de saklamak için gerekli | +| `CLOUDFLARE_KV_NAMESPACE_ID` | Hayır | `""` | Paylaşılabilir tanı raporlarını saklayan Workers KV ad alanının onaltılık kimliği (adı değil) | | `RIPESTAT_SOURCE_APP` | Hayır | `""` | RIPE.net kaynak uygulama adı, RIPE.net üzerinden ASN geçmiş bilgisi almak için | | `MAC_LOOKUP_API_KEY` | Hayır | `""` | MAC Lookup API anahtarı, MAC adresi bilgisi almak için | | `VITE_CURL_IPV4_DOMAIN` | Hayır | `""` | Kullanıcılara CURL API için IPv4 domain sağlar | diff --git a/README_ZH.md b/README_ZH.md index c10a7b19..c18f67c0 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -148,7 +148,9 @@ MyIP 依赖 MaxMind 提供的免费 **GeoLite2** 数据库(City + ASN)来进 | `IPINFO_API_KEY` | 否 | `""` | IPInfo.io 的 API Token,用于通过 IPInfo.io 获取 IP 归属地信息 | | `IPAPIIS_API_KEY` | 否 | `""` | IPAPI.is 的 API Key,用于通过 IPAPI.is 获取 IP 归属地信息 | | `IP2LOCATION_API_KEY` | 否 | `""` | IP2Location.io 的 API Key,用于通过 IP2Location.io 获取 IP 归属地信息 | -| `CLOUDFLARE_API_KEY` | 否 | `""` | Cloudflare 的 API Key,用于通过 Cloudflare 获取 AS 系统的信息 | +| `CLOUDFLARE_API_KEY` | 否 | `""` | Cloudflare 的 API Key,用于获取 AS 系统信息;配合下方两个 KV 变量(token 需追加 "Workers KV Storage: Edit" 权限)还用于诊断报告分享 | +| `CLOUDFLARE_ACCOUNT_ID` | 否 | `""` | Cloudflare 账号 ID,诊断报告分享(Workers KV 存储)所需 | +| `CLOUDFLARE_KV_NAMESPACE_ID` | 否 | `""` | 存储诊断分享报告的 Workers KV namespace 的十六进制 ID(不是名字) | | `RIPESTAT_SOURCE_APP` | 否 | `""` | RIPE.net 的源应用名称,用于通过 RIPE.net 获取 ASN 的历史信息 | | `MAC_LOOKUP_API_KEY` | 否 | `""` | MAC 查询的 API Key,用于通过 MAC Lookup 获取 MAC 地址的归属信息 | | `VITE_CURL_IPV4_DOMAIN` | 否 | `""` | 为用户提供 CURL API 的 IPv4 域名 | diff --git a/api/AGENTS.md b/api/AGENTS.md index 9d55ebac..534ebcd1 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -17,8 +17,8 @@ Roughly one handler file per route: IP-geolocation sources (`ipinfo-io` / `cf-radar` / `asn-history` / `asn-connectivity` / `service-status` / `google-map` / `github-stars` / `invisibility-test` / `dns-leak-test`), user proxies (`get-user-info` / `update-user-achievement`), platform -(`configs` / `sentry-tunnel`). Each file's header comment states its route -and purpose — read those for specifics. +(`configs` / `sentry-tunnel` / `share-report`). Each file's header comment +states its route and purpose — read those for specifics. ## Conventions @@ -64,6 +64,7 @@ these checks: - `requireValidASN()` — `?asn=`, strips `AS`, rewrites to numeric (`cf-radar` predates it and still validates inline). - `requireValidProviderId()` — whitelists `?id=` against service-status slugs. +- `requireValidReportId()` — `/api/report/:id` route param (22-char base64url). New param shape → new guard in `common/guards.js`, attached in `backend-server.js`; never open-coded in the handler. diff --git a/api/configs.js b/api/configs.js index 93bb9ed6..a34f6179 100644 --- a/api/configs.js +++ b/api/configs.js @@ -1,4 +1,6 @@ // Validate environment variables exist to enable/disable frontend features +import { isReportSharingConfigured } from './share-report.js'; + export default (req, res) => { // defensive; app.get() in backend-server.js already gates method, but a // dedicated smoke test asserts this branch directly against the handler. @@ -24,6 +26,8 @@ export default (req, res) => { originalSite, cloudFlare: process.env.CLOUDFLARE_API_KEY || process.env.CLOUDFLARE_API, ipapiis: process.env.IPAPIIS_API_KEY, + // Share-link feature gate: all three CLOUDFLARE_* KV variables present. + reportSharing: isReportSharingConfigured(), }; let result = {}; for (const key in envConfigs) { diff --git a/api/share-report.js b/api/share-report.js new file mode 100644 index 00000000..9072c9ce --- /dev/null +++ b/api/share-report.js @@ -0,0 +1,125 @@ +// Shareable diagnostic report storage — POST /api/report validates an +// untrusted report against the shared schema whitelist and stores it in +// Cloudflare Workers KV (REST API, TTL-expired); GET /api/report/:id fetches +// one for the read-only /r/:id page (and for AI agents given a share link). +// +// The whole feature is env-gated: without the three CLOUDFLARE_* variables +// below both endpoints answer 503 and /api/configs reports +// `reportSharing: false`, so the share-link UI never shows. +// +// Abuse layering (write side): schema whitelist (no free-form text can be +// stored), 32KB size cap, 128-bit unguessable ids, KV TTL. Rate limiting is +// deliberately NOT done here — it's a deployment concern: edge rules cover +// the hosted site, and the env-gated global /api limiter in +// backend-server.js (SECURITY_RATE_LIMIT) covers self-hosts. + +import crypto from 'node:crypto'; +import { fetchUpstream } from '../common/fetch-with-timeout.js'; +import { validateReport, REPORT_TTL_DAYS, REPORT_MAX_BYTES } from '../common/report-schema.js'; +import logger from '../common/logger.js'; + +// --- env / KV plumbing ------------------------------------------------------- + +// Read at request time (not module load) so tests and late-loaded dotenv both work. +const kvConfig = () => { + const apiKey = process.env.CLOUDFLARE_API_KEY || process.env.CLOUDFLARE_API; + const accountId = process.env.CLOUDFLARE_ACCOUNT_ID; + const namespaceId = process.env.CLOUDFLARE_KV_NAMESPACE_ID; + if (!apiKey || !accountId || !namespaceId) return null; + return { apiKey, accountId, namespaceId }; +}; + +// Exposed for /api/configs (booleanized there). +export const isReportSharingConfigured = () => kvConfig() !== null; + +const kvValueUrl = ({ accountId, namespaceId }, key, queryString = '') => + `https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${key}${queryString}`; + +// Surface Cloudflare's own error message (truncated) in thrown errors — +// a bare status code turns "multipart required" into a guessing game. +const kvErrorDetail = (response) => + response.text().then((text) => text.slice(0, 300)).catch(() => ''); + +// --- handlers ------------------------------------------------------------------ + +// POST /api/report — body: { report, ttlDays } +const createReport = async (req, res) => { + // defensive; route gating covers this, but the smoke test asserts on it. + if (req.method !== 'POST') { + return res.status(405).json({ message: 'Method Not Allowed' }); + } + const config = kvConfig(); + if (!config) { + return res.status(503).json({ error: 'Report sharing is not configured' }); + } + + const { report, ttlDays = 7 } = req.body ?? {}; + if (!REPORT_TTL_DAYS.includes(ttlDays)) { + return res.status(400).json({ error: 'Invalid ttlDays' }); + } + const { ok, errors } = validateReport(report); + if (!ok) { + return res.status(400).json({ error: 'Invalid report', details: errors }); + } + const serialized = JSON.stringify(report); + if (Buffer.byteLength(serialized, 'utf8') > REPORT_MAX_BYTES) { + return res.status(413).json({ error: 'Report too large' }); + } + + try { + const id = crypto.randomBytes(16).toString('base64url'); + const ttlSeconds = ttlDays * 24 * 60 * 60; + // The KV write endpoint requires multipart/form-data with `value` + + // `metadata` fields (a raw body is a 400); fetch derives the + // multipart boundary from the FormData, so no manual Content-Type. + const form = new FormData(); + form.append('value', serialized); + form.append('metadata', '{}'); + const response = await fetchUpstream( + kvValueUrl(config, id, `?expiration_ttl=${ttlSeconds}`), + { + method: 'PUT', + headers: { 'Authorization': `Bearer ${config.apiKey}` }, + body: form, + }, + ); + if (!response.ok) throw new Error(`KV write responded ${response.status}: ${await kvErrorDetail(response)}`); + + return res.status(201).json({ + id, + expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(), + }); + } catch (error) { + logger.error({ err: error }, 'Report share create failed'); + return res.status(500).json({ error: error.message }); + } +}; + +// GET /api/report/:id — id format is enforced by requireValidReportId(). +export const getReport = async (req, res) => { + if (req.method !== 'GET') { + return res.status(405).json({ message: 'Method Not Allowed' }); + } + const config = kvConfig(); + if (!config) { + return res.status(503).json({ error: 'Report sharing is not configured' }); + } + + try { + const response = await fetchUpstream(kvValueUrl(config, req.params.id), { + headers: { 'Authorization': `Bearer ${config.apiKey}` }, + }); + if (response.status === 404) { + return res.status(404).json({ error: 'Report not found or expired' }); + } + if (!response.ok) throw new Error(`KV read responded ${response.status}: ${await kvErrorDetail(response)}`); + // Stored values are validated JSON; parse so cacheable()'s res.json + // hook applies the edge-cache header on this 2xx. + return res.status(200).json(JSON.parse(await response.text())); + } catch (error) { + logger.error({ err: error, reportId: req.params.id }, 'Report share read failed'); + return res.status(500).json({ error: error.message }); + } +}; + +export default createReport; diff --git a/backend-server.js b/backend-server.js index 94d9e81c..d10b4127 100644 --- a/backend-server.js +++ b/backend-server.js @@ -7,7 +7,7 @@ import { slowDown } from 'express-slow-down' import rateLimit from 'express-rate-limit'; import pinoHttp from 'pino-http'; import logger from './common/logger.js'; -import { requireReferer, requireValidIP, requireValidPrefix, requireValidASN, requireValidProviderId } from './common/guards.js'; +import { requireReferer, requireValidIP, requireValidPrefix, requireValidASN, requireValidProviderId, requireValidReportId } from './common/guards.js'; // Backend APIs import mapHandler from './api/google-map.js'; @@ -30,6 +30,7 @@ import serviceStatusHandler, { import { getSessionResult as dnsLeakGetResult } from './api/dns-leak-test.js'; import getWhois from './api/get-whois.js'; import sentryTunnelHandler from './api/sentry-tunnel.js'; +import createReportHandler, { getReport as getReportHandler } from './api/share-report.js'; import invisibilitytestHandler from './api/invisibility-test.js'; import macChecker from './api/mac-checker.js'; import githubStarsHandler from './api/github-stars.js'; @@ -227,6 +228,9 @@ const ONE_YEAR_CACHE = 365 * 24 * 60 * 60; // Short Cache app.get('/api/service-status', cacheable(FIVE_MIN_CACHE), serviceStatusHandler); app.get('/api/service-status/detail', requireValidProviderId(), cacheable(FIVE_MIN_CACHE), serviceStatusDetailHandler); +// Shared reports are immutable and expire via KV TTL; a short edge cache +// absorbs the read fan-out when one link is opened by many people. +app.get('/api/report/:id', requireValidReportId(), cacheable(FIVE_MIN_CACHE), getReportHandler); // Cache for 1 day app.get('/api/ipinfo', requireValidIP(), cacheable(ONE_DAY_CACHE), ipinfoHandler); app.get('/api/ipapicom', requireValidIP(), cacheable(ONE_DAY_CACHE), ipapicomHandler); @@ -253,6 +257,7 @@ app.get('/api/invisibility', invisibilitytestHandler); app.get('/api/getuserinfo', getUserinfo); app.put('/api/updateuserachievement', updateUserAchievement); app.get('/api/configs', validateConfigs); +app.post('/api/report', createReportHandler); // Sentry tunnel — first-party relay for the frontend SDK's envelopes // Mounted only when this deployment actually built the frontend with a DSN. diff --git a/common/guards.js b/common/guards.js index e3513eaa..4f285b66 100644 --- a/common/guards.js +++ b/common/guards.js @@ -64,6 +64,17 @@ export const requireValidASN = (paramName = 'asn') => (req, res, next) => { next(); }; +// Reject requests whose report id doesn't look like one we issued: 16 random +// bytes as base64url, always exactly 22 chars. Reads the ROUTE param (not the +// query) — the share-report read endpoint is /api/report/:id. +export const requireValidReportId = (paramName = 'id') => (req, res, next) => { + const id = req.params?.[paramName]; + if (!id || !/^[A-Za-z0-9_-]{22}$/.test(id)) { + return res.status(400).json({ error: 'Invalid report id' }); + } + next(); +}; + // Reject requests whose `id` isn't a known service-status provider slug. // Used by the per-provider components / incidents endpoints, which select a // row from the in-memory snapshot by id. diff --git a/tests/api-handlers.test.js b/tests/api-handlers.test.js index 20b070e8..ff9bc122 100644 --- a/tests/api-handlers.test.js +++ b/tests/api-handlers.test.js @@ -26,6 +26,8 @@ import { getSessionResult as dnsLeakGetResult } from '../api/dns-leak-test.js'; import serviceStatusHandler, { detailHandler as serviceStatusDetailHandler, } from '../api/service-status.js'; +import createReportHandler, { getReport as getReportHandler } from '../api/share-report.js'; +import { REPORT_VERSION } from '../common/report-schema.js'; // -- shared test utilities ------------------------------------------------ @@ -55,6 +57,7 @@ const ENV_KEYS = [ 'MAC_LOOKUP_API_KEY', 'IPAPIIS_API_KEY', 'IPINFO_API_KEY', 'IP2LOCATION_API_KEY', 'CLOUDFLARE_API_KEY', + 'CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_KV_NAMESPACE_ID', // Pre-rename spellings, still honored as fallbacks. 'IPINFO_API_TOKEN', 'CLOUDFLARE_API', ]; @@ -86,7 +89,7 @@ describe('configs handler', () => { const res = createResponse(); configsHandler(createRequest(), res); assert.equal(res.statusCode, 200); - for (const key of ['map', 'ipInfo', 'ipChecking', 'ip2location', 'originalSite', 'cloudFlare', 'ipapiis']) { + for (const key of ['map', 'ipInfo', 'ipChecking', 'ip2location', 'originalSite', 'cloudFlare', 'ipapiis', 'reportSharing']) { assert.equal(typeof res.body[key], 'boolean'); } assert.equal(res.body.originalSite, false); @@ -363,3 +366,106 @@ describe('service-status detail handler', () => { assert.ok(Array.isArray(res.body.incidents)); }); }); + +// -- share-report (POST /api/report + GET /api/report/:id) ----------------- +// Everything asserted here returns before the first fetchUpstream (KV is +// never hit). + +describe('share-report handlers', () => { + const kvEnv = () => { + process.env.CLOUDFLARE_API_KEY = 'test-key'; + process.env.CLOUDFLARE_ACCOUNT_ID = 'test-account'; + process.env.CLOUDFLARE_KV_NAMESPACE_ID = 'test-namespace'; + }; + const noKvEnv = () => { + delete process.env.CLOUDFLARE_API_KEY; + delete process.env.CLOUDFLARE_API; + delete process.env.CLOUDFLARE_ACCOUNT_ID; + delete process.env.CLOUDFLARE_KV_NAMESPACE_ID; + }; + + const createReq = (body, method = 'POST') => createRequest({ method, body }); + + const validReport = () => ({ + v: REPORT_VERSION, + generatedAt: '2026-07-14T08:00:00.000Z', + origin: 'ipcheck.ing', + locale: 'zh', + sections: { + ruletest: { + testedAt: '2026-07-14T08:00:00.000Z', + uniqueIPCount: 1, + workers: [{ id: 1, ip: '1.2.3.4', countryCode: 'US', org: 'CF' }], + }, + }, + }); + + it('POST rejects non-POST with 405', async () => { + const res = createResponse(); + await createReportHandler(createReq(undefined, 'GET'), res); + assert.equal(res.statusCode, 405); + }); + + it('POST returns 503 when KV env is not configured', async () => { + noKvEnv(); + const res = createResponse(); + await createReportHandler(createReq({ report: validReport(), ttlDays: 7 }), res); + assert.equal(res.statusCode, 503); + }); + + it('POST rejects a ttlDays outside the whitelist', async () => { + kvEnv(); + for (const ttlDays of [0, 2, 365, '7', null]) { + const res = createResponse(); + await createReportHandler(createReq({ report: validReport(), ttlDays }), res); + assert.equal(res.statusCode, 400, `ttlDays=${ttlDays} should 400`); + assert.equal(res.body.error, 'Invalid ttlDays'); + } + }); + + it('POST rejects an invalid report with the validator details', async () => { + kvEnv(); + const res = createResponse(); + await createReportHandler(createReq({ report: { v: REPORT_VERSION, smuggled: 'x' }, ttlDays: 7 }), res); + assert.equal(res.statusCode, 400); + assert.equal(res.body.error, 'Invalid report'); + assert.ok(Array.isArray(res.body.details)); + }); + + it('POST rejects a schema-valid but oversized report with 413', async () => { + kvEnv(); + const report = validReport(); + // Inflate within schema caps: 32 mtr probes × 64 hops ≫ 32KB. + report.sections.mtrtest = { + testedAt: '2026-07-14T08:00:00.000Z', + target: '8.8.8.8', + probes: Array.from({ length: 32 }, () => ({ + countryCode: 'US', + network: 'n'.repeat(120), + hops: Array.from({ length: 64 }, (_, i) => ({ + n: i + 1, host: 'h'.repeat(120), lossPct: 0, avgMs: 1.5, + })), + })), + }; + const res = createResponse(); + await createReportHandler(createReq({ report, ttlDays: 7 }), res); + assert.equal(res.statusCode, 413); + }); + + it('GET rejects non-GET with 405', async () => { + const res = createResponse(); + const req = createReq(undefined, 'POST'); + req.params = { id: 'a'.repeat(22) }; + await getReportHandler(req, res); + assert.equal(res.statusCode, 405); + }); + + it('GET returns 503 when KV env is not configured', async () => { + noKvEnv(); + const res = createResponse(); + const req = createReq(undefined, 'GET'); + req.params = { id: 'a'.repeat(22) }; + await getReportHandler(req, res); + assert.equal(res.statusCode, 503); + }); +}); diff --git a/tests/guards.test.js b/tests/guards.test.js index ad87d666..542b0e79 100644 --- a/tests/guards.test.js +++ b/tests/guards.test.js @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { requireReferer, requireValidIP, requireValidPrefix, requireValidProviderId } from '../common/guards.js'; +import { requireReferer, requireValidIP, requireValidPrefix, requireValidProviderId, requireValidReportId } from '../common/guards.js'; // Minimal (req, res, next) stubs — just enough to observe what the // middleware does. @@ -157,3 +157,37 @@ describe('requireValidProviderId', () => { assert.equal(nextCalled, false); }); }); + +describe('requireValidReportId', () => { + const guard = requireValidReportId(); + // A report id is 16 random bytes as base64url — always exactly 22 chars. + const validId = 'AbC123-_xyz987654321ab'; + + // This guard reads req.params (route param), not req.query. + const makeParamsReq = (params) => ({ headers: {}, params }); + + it('calls next() for a well-formed 22-char base64url id', () => { + let nextCalled = false; + guard(makeParamsReq({ id: validId }), makeRes(), () => { nextCalled = true; }); + assert.equal(nextCalled, true); + }); + + it('returns 400 when the id is missing', () => { + const res = makeRes(); + let nextCalled = false; + guard(makeParamsReq({}), res, () => { nextCalled = true; }); + assert.equal(res.statusCode, 400); + assert.equal(res.body.error, 'Invalid report id'); + assert.equal(nextCalled, false); + }); + + it('returns 400 for wrong length or non-base64url characters', () => { + for (const bad of ['short', 'a'.repeat(23), 'a'.repeat(21), `${'a'.repeat(21)}+`, `${'a'.repeat(21)}/`]) { + const res = makeRes(); + let nextCalled = false; + guard(makeParamsReq({ id: bad }), res, () => { nextCalled = true; }); + assert.equal(res.statusCode, 400, `should reject "${bad}"`); + assert.equal(nextCalled, false); + } + }); +});