Files
STCourses/packages/feature-studio/test/app-helpers.ts
T
Lucas WintherandClaude Opus 5.5 8045c80d93 fix: final-review wave (CSRF guard, fail-closed owner bootstrap, streamed zips, e2e visibility suite)
- Critical 1 (CSRF): core wrap refuses every POST/PUT/PATCH/DELETE whose Origin
  (Referer as fallback) is not publicUrl's origin or STC_ALLOWED_ORIGINS, and
  any body that is not application/json (multipart only on declared upload
  routes). Better Auth routes go through the same guard; only declared
  identity-provider callbacks (SAML ACS/SLO, OAuth callbacks) are exempt.
  Better Auth trustedOrigins use the same list. Session cookie stays Lax
  (Strict breaks OAuth/OIDC/SAML returns); documented.
- Important 4 / learner minor 4: the first-owner claim and the role row are
  one transaction guarded by "no owner yet"; only a unique violation means
  learner; any other error fails the sign-up with 500 and removes the
  half-created user. Serialised per DB handle (Bun's SQLite connection).
- Important 2 / learner minor 12: code zips stream entry by entry (large
  files stored in 256 KB chunks with a data descriptor and incremental
  CRC-32), so memory stays at one chunk; the cap is now 1 GiB (ZIP32).
- Important 3 / learner minor 10: the e2e runs the production wiring
  (productFeatures order, AI drafts through the real sink) against a
  restricted course and asserts 404/401 for learner and anonymous on
  course, lesson, media, cover, code, zip, notes, progress, attempts, AI
  chat, library, search and AI status; a real Range request (206, 416);
  forged writes (foreign Origin, text/plain, no Origin) are 403 and the MCP
  test command never runs.
- Minors 7-9: compose passes every documented variable; the health check
  uses STC_PORT; productFeatures checks the studio-before-AI order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:42:17 +01:00

56 lines
3.0 KiB
TypeScript

import { resolve } from "node:path";
import type { AppConfig, AuthUser, ServerContext } from "@stc/contracts";
import { createApp, resolveConfig } from "@stc/core";
import { createCourseRepository } from "@stc/course-format";
import { connect, migrate } from "@stc/db";
import { studioFeature } from "../src/server/feature";
import { studioCourseDirs } from "../src/server/paths";
import { tempDir } from "./helpers";
export const REPO = resolve(import.meta.dir, "../../..");
export const users: Record<string, AuthUser> = {
ada: { id: "u-ada", email: "[email protected]", name: "Ada Lovelace", roles: ["author"], groups: [] },
owner: { id: "u-own", email: "[email protected]", name: "Olive Owner", roles: ["owner"], groups: [] },
lea: { id: "u-lea", email: "[email protected]", name: "Lea Learner", roles: ["learner"], groups: [] },
};
/** A server context like main.ts builds, with header-selected users (`x-user: ada`). */
export async function setupApp(extraCourseDirs: string[] = [], dbUrl = "sqlite://:memory:", prepare?: (dataDir: string) => void, configOverrides: Partial<AppConfig> = {}) {
const dataDir = tempDir();
prepare?.(dataDir);
let dropSchema = async () => {};
if (dbUrl.startsWith("postgres")) {
// a private schema per test, like packages/db's forEachDb
const schema = `t_${Bun.randomUUIDv7().replaceAll("-", "")}`;
const admin = await connect(dbUrl);
await admin.sql.unsafe(`CREATE SCHEMA ${schema}`);
const u = new URL(dbUrl);
u.searchParams.set("options", `-c search_path=${schema}`);
dbUrl = u.toString();
dropSchema = async () => { await admin.sql.unsafe(`DROP SCHEMA ${schema} CASCADE`); await admin.close(); };
}
const db = await connect(dbUrl);
await migrate(db);
const config = resolveConfig({ port: 0, databaseUrl: "sqlite://:memory:", dataDir, courseDirs: extraCourseDirs, secret: "t", publicUrl: "http://x", ...configOverrides });
const courses = await createCourseRepository(studioCourseDirs(config));
const ctx = {
config, db, courses, jobs: null as never,
auth: { userFromRequest: async (req: Request) => users[req.headers.get("x-user") ?? ""] ?? null },
} satisfies ServerContext;
const feature = studioFeature(ctx, { watch: false });
const app = createApp(ctx, [feature]);
const call = async (as: string | null, method: string, path: string, body?: unknown) => {
const headers: Record<string, string> = { origin: "http://x" };
if (as) headers["x-user"] = as;
let payload: BodyInit | undefined;
if (body instanceof FormData) payload = body;
else if (body !== undefined) { headers["content-type"] = "application/json"; payload = JSON.stringify(body); }
const r = await app.fetch(new Request(`http://x${path}`, { method, headers, body: payload }));
const text = await r.text();
let json: any = null;
try { json = JSON.parse(text); } catch { json = text; }
return { status: r.status, json };
};
return { ctx, db, dataDir, feature, app, call, stop: async () => { feature.stop(); await db.close(); await dropSchema(); } };
}