harden browser routes with csrf checks and lock internal/dev endpoints
This commit is contained in:
@@ -3,6 +3,8 @@ NODE_ENV=production
|
|||||||
PORT=3000
|
PORT=3000
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
APP_BASE_URL=https://xartaudio.example.com
|
APP_BASE_URL=https://xartaudio.example.com
|
||||||
|
ENABLE_DEV_ROUTES=false
|
||||||
|
ALLOW_IN_MEMORY_STATE_FALLBACK=false
|
||||||
|
|
||||||
# Better Auth
|
# Better Auth
|
||||||
BETTER_AUTH_SECRET=replace-me
|
BETTER_AUTH_SECRET=replace-me
|
||||||
|
|||||||
86
src/app.js
86
src/app.js
@@ -73,6 +73,9 @@ function buildApp({
|
|||||||
});
|
});
|
||||||
const rateLimits = config.rateLimits || {};
|
const rateLimits = config.rateLimits || {};
|
||||||
const abusePolicy = config.abuse || {};
|
const abusePolicy = config.abuse || {};
|
||||||
|
const devRoutesEnabled = config.enableDevRoutes !== undefined
|
||||||
|
? Boolean(config.enableDevRoutes)
|
||||||
|
: true;
|
||||||
const polar = polarAdapter || createPolarAdapter({
|
const polar = polarAdapter || createPolarAdapter({
|
||||||
accessToken: config.polarAccessToken,
|
accessToken: config.polarAccessToken,
|
||||||
server: config.polarServer,
|
server: config.polarServer,
|
||||||
@@ -249,6 +252,49 @@ function buildApp({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function enforceBrowserCsrf(headers) {
|
||||||
|
const secFetchSite = headers["sec-fetch-site"];
|
||||||
|
if (secFetchSite === "cross-site") {
|
||||||
|
return json(403, { error: "csrf_blocked" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedOrigin = (() => {
|
||||||
|
try {
|
||||||
|
return new URL(config.appBaseUrl).origin;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (!expectedOrigin) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originHeader = headers.origin || null;
|
||||||
|
if (originHeader) {
|
||||||
|
try {
|
||||||
|
if (new URL(originHeader).origin !== expectedOrigin) {
|
||||||
|
return json(403, { error: "invalid_origin" });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return json(403, { error: "invalid_origin" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refererHeader = headers.referer || null;
|
||||||
|
if (refererHeader) {
|
||||||
|
try {
|
||||||
|
if (new URL(refererHeader).origin !== expectedOrigin) {
|
||||||
|
return json(403, { error: "invalid_referer" });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return json(403, { error: "invalid_referer" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function getAbuseDecision(callerUserId) {
|
function getAbuseDecision(callerUserId) {
|
||||||
if (!callerUserId) {
|
if (!callerUserId) {
|
||||||
return { allowed: true };
|
return { allowed: true };
|
||||||
@@ -529,6 +575,10 @@ function buildApp({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method === "POST" && path === "/auth/email/sign-in") {
|
if (method === "POST" && path === "/auth/email/sign-in") {
|
||||||
|
const csrf = enforceBrowserCsrf(safeHeaders);
|
||||||
|
if (csrf) {
|
||||||
|
return csrf;
|
||||||
|
}
|
||||||
const rateLimited = enforceRedirectRateLimit(authLimiter, `auth:${clientAddress}`, "/login");
|
const rateLimited = enforceRedirectRateLimit(authLimiter, `auth:${clientAddress}`, "/login");
|
||||||
if (rateLimited) {
|
if (rateLimited) {
|
||||||
return rateLimited;
|
return rateLimited;
|
||||||
@@ -555,6 +605,10 @@ function buildApp({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method === "POST" && path === "/auth/email/sign-up") {
|
if (method === "POST" && path === "/auth/email/sign-up") {
|
||||||
|
const csrf = enforceBrowserCsrf(safeHeaders);
|
||||||
|
if (csrf) {
|
||||||
|
return csrf;
|
||||||
|
}
|
||||||
const rateLimited = enforceRedirectRateLimit(authLimiter, `auth:${clientAddress}`, "/login");
|
const rateLimited = enforceRedirectRateLimit(authLimiter, `auth:${clientAddress}`, "/login");
|
||||||
if (rateLimited) {
|
if (rateLimited) {
|
||||||
return rateLimited;
|
return rateLimited;
|
||||||
@@ -581,6 +635,10 @@ function buildApp({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method === "POST" && path === "/auth/x") {
|
if (method === "POST" && path === "/auth/x") {
|
||||||
|
const csrf = enforceBrowserCsrf(safeHeaders);
|
||||||
|
if (csrf) {
|
||||||
|
return csrf;
|
||||||
|
}
|
||||||
const rateLimited = enforceRedirectRateLimit(authLimiter, `auth:${clientAddress}`, "/login");
|
const rateLimited = enforceRedirectRateLimit(authLimiter, `auth:${clientAddress}`, "/login");
|
||||||
if (rateLimited) {
|
if (rateLimited) {
|
||||||
return rateLimited;
|
return rateLimited;
|
||||||
@@ -611,6 +669,10 @@ function buildApp({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method === "POST" && path === "/auth/logout") {
|
if (method === "POST" && path === "/auth/logout") {
|
||||||
|
const csrf = enforceBrowserCsrf(safeHeaders);
|
||||||
|
if (csrf) {
|
||||||
|
return csrf;
|
||||||
|
}
|
||||||
const signOut = await auth.signOut(safeHeaders);
|
const signOut = await auth.signOut(safeHeaders);
|
||||||
return redirect("/", signOut.setCookie
|
return redirect("/", signOut.setCookie
|
||||||
? { "set-cookie": signOut.setCookie }
|
? { "set-cookie": signOut.setCookie }
|
||||||
@@ -628,10 +690,18 @@ function buildApp({
|
|||||||
summary: engine.getUserSummary(userId),
|
summary: engine.getUserSummary(userId),
|
||||||
jobs: engine.listJobsForUser(userId),
|
jobs: engine.listJobsForUser(userId),
|
||||||
flash: safeQuery.flash || null,
|
flash: safeQuery.flash || null,
|
||||||
|
showDeveloperActions: devRoutesEnabled,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method === "POST" && path === "/app/actions/topup") {
|
if (method === "POST" && path === "/app/actions/topup") {
|
||||||
|
if (!devRoutesEnabled) {
|
||||||
|
return json(404, { error: "not_found" });
|
||||||
|
}
|
||||||
|
const csrf = enforceBrowserCsrf(safeHeaders);
|
||||||
|
if (csrf) {
|
||||||
|
return csrf;
|
||||||
|
}
|
||||||
const rateLimited = enforceRedirectRateLimit(actionLimiter, `action:${userId || clientAddress}`, "/app");
|
const rateLimited = enforceRedirectRateLimit(actionLimiter, `action:${userId || clientAddress}`, "/app");
|
||||||
if (rateLimited) {
|
if (rateLimited) {
|
||||||
return rateLimited;
|
return rateLimited;
|
||||||
@@ -656,6 +726,13 @@ function buildApp({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method === "POST" && path === "/app/actions/simulate-mention") {
|
if (method === "POST" && path === "/app/actions/simulate-mention") {
|
||||||
|
if (!devRoutesEnabled) {
|
||||||
|
return json(404, { error: "not_found" });
|
||||||
|
}
|
||||||
|
const csrf = enforceBrowserCsrf(safeHeaders);
|
||||||
|
if (csrf) {
|
||||||
|
return csrf;
|
||||||
|
}
|
||||||
const rateLimited = enforceRedirectRateLimit(actionLimiter, `action:${userId || clientAddress}`, "/app");
|
const rateLimited = enforceRedirectRateLimit(actionLimiter, `action:${userId || clientAddress}`, "/app");
|
||||||
if (rateLimited) {
|
if (rateLimited) {
|
||||||
return rateLimited;
|
return rateLimited;
|
||||||
@@ -718,6 +795,10 @@ function buildApp({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method === "POST" && path.startsWith("/audio/") && path.endsWith("/unlock")) {
|
if (method === "POST" && path.startsWith("/audio/") && path.endsWith("/unlock")) {
|
||||||
|
const csrf = enforceBrowserCsrf(safeHeaders);
|
||||||
|
if (csrf) {
|
||||||
|
return csrf;
|
||||||
|
}
|
||||||
const rateLimited = enforceRedirectRateLimit(actionLimiter, `action:${userId || clientAddress}`, path.replace("/unlock", ""));
|
const rateLimited = enforceRedirectRateLimit(actionLimiter, `action:${userId || clientAddress}`, path.replace("/unlock", ""));
|
||||||
if (rateLimited) {
|
if (rateLimited) {
|
||||||
return rateLimited;
|
return rateLimited;
|
||||||
@@ -803,6 +884,11 @@ function buildApp({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method === "GET" && path === "/api/x/mentions") {
|
if (method === "GET" && path === "/api/x/mentions") {
|
||||||
|
const authResponse = ensureInternalAuth(safeHeaders);
|
||||||
|
if (authResponse) {
|
||||||
|
return authResponse;
|
||||||
|
}
|
||||||
|
|
||||||
if (!x.isConfigured()) {
|
if (!x.isConfigured()) {
|
||||||
return json(503, { error: "x_api_not_configured" });
|
return json(503, { error: "x_api_not_configured" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,10 @@ parsed.allowInMemoryStateFallback = boolFromEnv(
|
|||||||
"ALLOW_IN_MEMORY_STATE_FALLBACK",
|
"ALLOW_IN_MEMORY_STATE_FALLBACK",
|
||||||
parsed.nodeEnv !== "production",
|
parsed.nodeEnv !== "production",
|
||||||
);
|
);
|
||||||
|
parsed.enableDevRoutes = boolFromEnv(
|
||||||
|
"ENABLE_DEV_ROUTES",
|
||||||
|
parsed.nodeEnv !== "production",
|
||||||
|
);
|
||||||
|
|
||||||
const ConfigSchema = z.object({
|
const ConfigSchema = z.object({
|
||||||
nodeEnv: z.string().min(1),
|
nodeEnv: z.string().min(1),
|
||||||
@@ -158,6 +162,7 @@ const ConfigSchema = z.object({
|
|||||||
maxCharsPerArticle: z.number().int().positive(),
|
maxCharsPerArticle: z.number().int().positive(),
|
||||||
}),
|
}),
|
||||||
allowInMemoryStateFallback: z.boolean(),
|
allowInMemoryStateFallback: z.boolean(),
|
||||||
|
enableDevRoutes: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const config = ConfigSchema.parse(parsed);
|
const config = ConfigSchema.parse(parsed);
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ function renderLoginPage({ returnTo = "/app", error = null }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAppPage({ userId, summary, jobs, flash = null }) {
|
function renderAppPage({ userId, summary, jobs, flash = null, showDeveloperActions = true }) {
|
||||||
const flashMarkup = flash ? `<div role="alert" class="alert alert-info mb-6 flex items-center gap-2 text-sm shadow-sm"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg><span>${escapeHtml(flash)}</span></div>` : "";
|
const flashMarkup = flash ? `<div role="alert" class="alert alert-info mb-6 flex items-center gap-2 text-sm shadow-sm"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg><span>${escapeHtml(flash)}</span></div>` : "";
|
||||||
|
|
||||||
const jobsMarkup = jobs.length === 0
|
const jobsMarkup = jobs.length === 0
|
||||||
@@ -294,6 +294,33 @@ function renderAppPage({ userId, summary, jobs, flash = null }) {
|
|||||||
</a>`).join("")}
|
</a>`).join("")}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
|
const developerActionsMarkup = showDeveloperActions
|
||||||
|
? `<div class="space-y-6">
|
||||||
|
<div class="card bg-base-100 shadow-sm border border-base-content/10 sticky top-24">
|
||||||
|
<div class="card-body p-5">
|
||||||
|
<h2 class="card-title text-sm font-bold uppercase tracking-wider opacity-60 mb-2">Developer Actions</h2>
|
||||||
|
|
||||||
|
<form method="POST" action="/app/actions/topup" class="mb-4">
|
||||||
|
<label class="label text-xs font-medium pl-0">Top Up Credits</label>
|
||||||
|
<div class="join w-full">
|
||||||
|
<input name="amount" type="number" min="1" max="500" value="10" class="input input-bordered input-sm join-item w-full" />
|
||||||
|
<button class="btn btn-primary btn-sm join-item">Add</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="divider my-2"></div>
|
||||||
|
|
||||||
|
<form method="POST" action="/app/actions/simulate-mention" class="flex flex-col gap-3">
|
||||||
|
<label class="label text-xs font-medium pl-0">Simulate Mention</label>
|
||||||
|
<input name="title" required class="input input-bordered input-sm w-full" placeholder="Article Title" />
|
||||||
|
<textarea name="body" required rows="3" class="textarea textarea-bordered textarea-sm w-full" placeholder="Paste article text..."></textarea>
|
||||||
|
<button class="btn btn-accent btn-sm w-full">Generate</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
: "";
|
||||||
|
|
||||||
return shell({
|
return shell({
|
||||||
title: "Dashboard | XArtAudio",
|
title: "Dashboard | XArtAudio",
|
||||||
user: { authenticated: true, userId },
|
user: { authenticated: true, userId },
|
||||||
@@ -325,8 +352,8 @@ function renderAppPage({ userId, summary, jobs, flash = null }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-8 md:grid-cols-3 mb-12">
|
<div class="grid gap-8 ${showDeveloperActions ? "md:grid-cols-3" : ""} mb-12">
|
||||||
<div class="md:col-span-2 space-y-8">
|
<div class="${showDeveloperActions ? "md:col-span-2" : ""} space-y-8">
|
||||||
<section>
|
<section>
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h2 class="text-xl font-bold flex items-center gap-2">
|
<h2 class="text-xl font-bold flex items-center gap-2">
|
||||||
@@ -337,31 +364,7 @@ function renderAppPage({ userId, summary, jobs, flash = null }) {
|
|||||||
${jobsMarkup}
|
${jobsMarkup}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
${developerActionsMarkup}
|
||||||
<div class="space-y-6">
|
|
||||||
<div class="card bg-base-100 shadow-sm border border-base-content/10 sticky top-24">
|
|
||||||
<div class="card-body p-5">
|
|
||||||
<h2 class="card-title text-sm font-bold uppercase tracking-wider opacity-60 mb-2">Developer Actions</h2>
|
|
||||||
|
|
||||||
<form method="POST" action="/app/actions/topup" class="mb-4">
|
|
||||||
<label class="label text-xs font-medium pl-0">Top Up Credits</label>
|
|
||||||
<div class="join w-full">
|
|
||||||
<input name="amount" type="number" min="1" max="500" value="10" class="input input-bordered input-sm join-item w-full" />
|
|
||||||
<button class="btn btn-primary btn-sm join-item">Add</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="divider my-2"></div>
|
|
||||||
|
|
||||||
<form method="POST" action="/app/actions/simulate-mention" class="flex flex-col gap-3">
|
|
||||||
<label class="label text-xs font-medium pl-0">Simulate Mention</label>
|
|
||||||
<input name="title" required class="input input-bordered input-sm w-full" placeholder="Article Title" />
|
|
||||||
<textarea name="body" required rows="3" class="textarea textarea-bordered textarea-sm w-full" placeholder="Paste article text..."></textarea>
|
|
||||||
<button class="btn btn-accent btn-sm w-full">Generate</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ function createApp(options = {}) {
|
|||||||
betterAuthBasePath: "/api/auth",
|
betterAuthBasePath: "/api/auth",
|
||||||
xOAuthClientId: "x-client-id",
|
xOAuthClientId: "x-client-id",
|
||||||
xOAuthClientSecret: "x-client-secret",
|
xOAuthClientSecret: "x-client-secret",
|
||||||
internalApiToken: "",
|
internalApiToken: "internal-token",
|
||||||
convexDeploymentUrl: "",
|
convexDeploymentUrl: "",
|
||||||
convexAuthToken: "",
|
convexAuthToken: "",
|
||||||
convexStateQuery: "state:getLatestSnapshot",
|
convexStateQuery: "state:getLatestSnapshot",
|
||||||
@@ -381,6 +381,7 @@ test("/api/x/mentions returns upstream mentions when configured", async () => {
|
|||||||
const response = await call(app, {
|
const response = await call(app, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/api/x/mentions",
|
path: "/api/x/mentions",
|
||||||
|
headers: { "x-internal-token": "internal-token" },
|
||||||
query: { sinceId: "100" },
|
query: { sinceId: "100" },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -390,6 +391,61 @@ test("/api/x/mentions returns upstream mentions when configured", async () => {
|
|||||||
assert.equal(body.mentions[0].id, "m1");
|
assert.equal(body.mentions[0].id, "m1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("/api/x/mentions requires internal token", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
xAdapter: {
|
||||||
|
isConfigured() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
async listMentions() {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await call(app, {
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/x/mentions",
|
||||||
|
query: { sinceId: "1" },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cross-site browser posts are blocked", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const response = await call(app, {
|
||||||
|
method: "POST",
|
||||||
|
path: "/app/actions/topup",
|
||||||
|
headers: {
|
||||||
|
cookie: "xartaudio_user=alice",
|
||||||
|
origin: "https://evil.example",
|
||||||
|
"sec-fetch-site": "cross-site",
|
||||||
|
},
|
||||||
|
body: "amount=5",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.status, 403);
|
||||||
|
assert.match(response.body, /csrf_blocked|invalid_origin/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dev dashboard routes can be disabled", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
enableDevRoutes: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await call(app, {
|
||||||
|
method: "POST",
|
||||||
|
path: "/app/actions/topup",
|
||||||
|
headers: { cookie: "xartaudio_user=alice" },
|
||||||
|
body: "amount=5",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
test("simulate mention schedules background audio generation when service is configured", async () => {
|
test("simulate mention schedules background audio generation when service is configured", async () => {
|
||||||
const queued = [];
|
const queued = [];
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
@@ -573,7 +629,11 @@ test("internal retention endpoint prunes stale content and assets", async () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("internal endpoints are disabled when no token configured", async () => {
|
test("internal endpoints are disabled when no token configured", async () => {
|
||||||
const app = createApp();
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
internalApiToken: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
const response = await call(app, {
|
const response = await call(app, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/internal/retention/run",
|
path: "/internal/retention/run",
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ test("config uses defaults when env is missing", () => {
|
|||||||
assert.equal(config.minioUseSSL, true);
|
assert.equal(config.minioUseSSL, true);
|
||||||
assert.equal(config.rateLimits.webhookPerMinute, 120);
|
assert.equal(config.rateLimits.webhookPerMinute, 120);
|
||||||
assert.equal(config.allowInMemoryStateFallback, true);
|
assert.equal(config.allowInMemoryStateFallback, true);
|
||||||
|
assert.equal(config.enableDevRoutes, true);
|
||||||
assert.equal(config.abuse.maxJobsPerUserPerDay, 0);
|
assert.equal(config.abuse.maxJobsPerUserPerDay, 0);
|
||||||
assert.equal(config.abuse.cooldownSec, 0);
|
assert.equal(config.abuse.cooldownSec, 0);
|
||||||
assert.deepEqual(config.abuse.denyUserIds, []);
|
assert.deepEqual(config.abuse.denyUserIds, []);
|
||||||
@@ -117,6 +118,7 @@ test("config reads convex/qwen/minio overrides", () => {
|
|||||||
assert.equal(config.abuse.cooldownSec, 120);
|
assert.equal(config.abuse.cooldownSec, 120);
|
||||||
assert.deepEqual(config.abuse.denyUserIds, ["u1", "u2"]);
|
assert.deepEqual(config.abuse.denyUserIds, ["u1", "u2"]);
|
||||||
assert.equal(config.allowInMemoryStateFallback, false);
|
assert.equal(config.allowInMemoryStateFallback, false);
|
||||||
|
assert.equal(config.enableDevRoutes, false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,18 @@ test("app page renders stats and forms", () => {
|
|||||||
assert.match(html, /Hello/);
|
assert.match(html, /Hello/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("app page can hide developer actions", () => {
|
||||||
|
const html = renderAppPage({
|
||||||
|
userId: "u1",
|
||||||
|
summary: { balance: 4, totalJobs: 2, totalCreditsSpent: 2 },
|
||||||
|
jobs: [],
|
||||||
|
showDeveloperActions: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.doesNotMatch(html, /Developer Actions/);
|
||||||
|
assert.doesNotMatch(html, /\/app\/actions\/topup/);
|
||||||
|
});
|
||||||
|
|
||||||
test("audio page shows unlock action when payment is required", () => {
|
test("audio page shows unlock action when payment is required", () => {
|
||||||
const html = renderAudioPage({
|
const html = renderAudioPage({
|
||||||
audio: { id: "1", storageKey: "audio/1.mp3", articleTitle: "A", durationSec: 30 },
|
audio: { id: "1", storageKey: "audio/1.mp3", articleTitle: "A", durationSec: 30 },
|
||||||
|
|||||||
Reference in New Issue
Block a user