import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { DEFAULT_PERSONAL_REPORT_DAILY_LIMIT, REPORT_EXPORT_PERSONAL_CAPABILITY_KEY, checkPersonalReportEntitlement, checkSameOrigin, isPersonalReportFeatureEnabled, readPersonalReportDailyLimit, resolveAllowedReportOrigins, } from "../src/lib/personal-report-entitlement.ts"; test("entitlement exposes the report.export.personal capability key", () => { assert.equal(REPORT_EXPORT_PERSONAL_CAPABILITY_KEY, "report.export.personal"); }); test("feature flag is enabled only by explicit env true", () => { assert.equal(isPersonalReportFeatureEnabled({}), false); assert.equal(isPersonalReportFeatureEnabled({ PERSONAL_REPORT_ENABLED: "false" }), false); assert.equal(isPersonalReportFeatureEnabled({ PERSONAL_REPORT_ENABLED: "TRUE" }), false); assert.equal(isPersonalReportFeatureEnabled({ PERSONAL_REPORT_ENABLED: "true" }), true); }); test("daily limit is read from env and never hardcoded in the module UI surface", () => { assert.equal(readPersonalReportDailyLimit({}), DEFAULT_PERSONAL_REPORT_DAILY_LIMIT); assert.equal(readPersonalReportDailyLimit({ PERSONAL_REPORT_DAILY_LIMIT: "3" }), 3); assert.equal(readPersonalReportDailyLimit({ PERSONAL_REPORT_DAILY_LIMIT: "0" }), DEFAULT_PERSONAL_REPORT_DAILY_LIMIT); assert.equal(readPersonalReportDailyLimit({ PERSONAL_REPORT_DAILY_LIMIT: "-1" }), DEFAULT_PERSONAL_REPORT_DAILY_LIMIT); assert.equal(readPersonalReportDailyLimit({ PERSONAL_REPORT_DAILY_LIMIT: "abc" }), DEFAULT_PERSONAL_REPORT_DAILY_LIMIT); }); test("allowed origins are parsed from the comma-separated env list", () => { assert.deepEqual(resolveAllowedReportOrigins({}), []); assert.deepEqual( resolveAllowedReportOrigins({ PERSONAL_REPORT_ALLOWED_ORIGINS: " https://a.example ,https://b.example, " }), ["https://a.example", "https://b.example"], ); }); test("same-origin check accepts absent origin and same request origin", () => { assert.deepEqual(checkSameOrigin("https://jyotisha.chat/api/reports", null, []), { ok: true }); assert.deepEqual( checkSameOrigin("https://jyotisha.chat/api/reports", "https://jyotisha.chat", []), { ok: true }, ); }); test("same-origin check rejects cross-origin and accepts a trusted allowlist", () => { assert.deepEqual( checkSameOrigin("https://jyotisha.chat/api/reports", "https://evil.example", []), { ok: false, code: "cross_origin_forbidden" }, ); assert.deepEqual( checkSameOrigin( "https://jyotisha.chat/api/reports", "https://trusted-proxy.example", ["https://trusted-proxy.example"], ), { ok: true }, ); }); test("same-origin check trusts only consistent forwarded host and protocol", () => { const headers = new Headers({ host: "staging.jyotisha.chat", "x-forwarded-host": "staging.jyotisha.chat", "x-forwarded-proto": "https", }); assert.deepEqual( checkSameOrigin( "http://staging.jyotisha.chat/api/reports", "https://staging.jyotisha.chat", [], headers, ), { ok: true }, ); assert.deepEqual( checkSameOrigin( "http://staging.jyotisha.chat/api/reports", "https://evil.example", [], headers, ), { ok: false, code: "cross_origin_forbidden" }, ); assert.deepEqual( checkSameOrigin( "http://staging.jyotisha.chat/api/reports", "https://staging.jyotisha.chat", [], new Headers({ host: "staging.jyotisha.chat", "x-forwarded-host": "evil.example", "x-forwarded-proto": "https", }), ), { ok: false, code: "cross_origin_forbidden" }, ); assert.deepEqual( checkSameOrigin( "http://staging.jyotisha.chat/api/reports", "https://staging.jyotisha.chat", [], new Headers({ host: "staging.jyotisha.chat", "x-forwarded-host": "staging.jyotisha.chat", "x-forwarded-proto": "https, http", }), ), { ok: false, code: "cross_origin_forbidden" }, ); }); test("entitlement blocks when the feature is disabled", async () => { const result = await checkPersonalReportEntitlement({ userId: "u1", featureEnabled: false, dailyLimit: 5, countGenerating: async () => 0, countCreatedToday: async () => 0, }); assert.deepEqual(result, { allowed: false, code: "report_export_disabled", httpStatus: 403 }); }); test("entitlement blocks a second concurrent generation with 409", async () => { const result = await checkPersonalReportEntitlement({ userId: "u1", featureEnabled: true, dailyLimit: 5, countGenerating: async () => 1, countCreatedToday: async () => 0, }); assert.deepEqual(result, { allowed: false, code: "report_generation_in_progress", httpStatus: 409 }); }); test("entitlement blocks at the daily limit with 429", async () => { const result = await checkPersonalReportEntitlement({ userId: "u1", featureEnabled: true, dailyLimit: 2, countGenerating: async () => 0, countCreatedToday: async () => 2, }); assert.deepEqual(result, { allowed: false, code: "report_rate_limited", httpStatus: 429 }); }); test("entitlement allows a fresh generation within limits", async () => { const result = await checkPersonalReportEntitlement({ userId: "u1", featureEnabled: true, dailyLimit: 5, countGenerating: async () => 0, countCreatedToday: async () => 1, }); assert.deepEqual(result, { allowed: true }); }); test("report API routes never hardcode the daily limit in the UI-facing module", () => { const entitlementSource = readFileSync( new URL("../src/lib/personal-report-entitlement.ts", import.meta.url), "utf8", ); assert.match(entitlementSource, /REPORT_DAILY_LIMIT_ENV/); // The limit must be read from env at request time, not baked as a literal // default inside the route response mapping. assert.doesNotMatch(entitlementSource, /每日|上限/); });