171 lines
5.8 KiB
TypeScript
171 lines
5.8 KiB
TypeScript
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, /每日|上限/);
|
|
});
|