- 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]>
103 lines
6.0 KiB
TypeScript
103 lines
6.0 KiB
TypeScript
import { expect, spyOn, test } from "bun:test";
|
|
import { assignInitialRole, isUniqueViolation } from "../src/server/auth";
|
|
import { syncClaimGroups, groupsFromClaims } from "../src/server/auth/groups";
|
|
import { forEachDb } from "./helpers";
|
|
import { cookieFrom, makeApp } from "./app";
|
|
|
|
forEachDb("first sign-up becomes owner, later ones learners", async (db) => {
|
|
const t = await makeApp(db);
|
|
const ownerCookie = await t.signUp("[email protected]");
|
|
const me = await (await t.call("GET", "/api/me", { cookie: ownerCookie })).json();
|
|
expect(me.user.email).toBe("[email protected]");
|
|
expect(me.user.roles).toEqual(["owner"]);
|
|
const second = await t.signUp("[email protected]");
|
|
expect((await (await t.call("GET", "/api/me", { cookie: second })).json()).user.roles).toEqual(["learner"]);
|
|
expect((await (await t.call("GET", "/api/me")).json()).user).toBeNull();
|
|
}, 20_000);
|
|
|
|
forEachDb("sign in with password and sign out", async (db) => {
|
|
const t = await makeApp(db);
|
|
await t.signUp("[email protected]", "A", "pw-123456789");
|
|
const bad = await t.call("POST", "/api/auth/sign-in/email", { body: { email: "[email protected]", password: "nope-nope-nope" } });
|
|
expect(bad.status).toBe(401);
|
|
const ok = await t.call("POST", "/api/auth/sign-in/email", { body: { email: "[email protected]", password: "pw-123456789" } });
|
|
expect(ok.status).toBe(200);
|
|
const cookie = cookieFrom(ok);
|
|
expect((await (await t.call("GET", "/api/me", { cookie })).json()).user.name).toBe("A");
|
|
const out = await t.call("POST", "/api/auth/sign-out", { cookie, body: {} });
|
|
expect(out.status).toBe(200);
|
|
// the session row is gone: the old token no longer resolves once the cookie cache is dropped
|
|
const sessionOnly = cookie.split("; ").filter((c) => !c.includes("session_data")).join("; ");
|
|
expect((await (await t.call("GET", "/api/me", { cookie: sessionOnly })).json()).user).toBeNull();
|
|
}, 20_000);
|
|
|
|
forEachDb("owner claim is exclusive under concurrency", async (db) => {
|
|
const roles = await Promise.all(["u1", "u2", "u3", "u4"].map((u) => assignInitialRole(db, u)));
|
|
expect(roles.filter((r) => r === "owner").length).toBe(1);
|
|
});
|
|
|
|
forEachDb("SSO provider administration is owner-only", async (db) => {
|
|
const t = await makeApp(db);
|
|
await t.signUp("[email protected]");
|
|
const learner = await t.signUp("[email protected]");
|
|
expect((await t.call("POST", "/api/auth/sso/register", { cookie: learner, body: {} })).status).toBe(403);
|
|
expect((await t.call("POST", "/api/auth/sso/register", { body: {} })).status).toBe(403);
|
|
}, 20_000);
|
|
|
|
forEachDb("claim groups sync keeps manual memberships", async (db) => {
|
|
await db.sql`INSERT INTO groups (id, name) VALUES ('staff', 'Staff')`;
|
|
await db.sql`INSERT INTO group_members (group_id, user_id) VALUES ('staff', 'u1')`;
|
|
await syncClaimGroups(db, "u1", ["Staff", "DevOps Team"]);
|
|
const ids = async () => (await db.sql`SELECT group_id FROM group_members WHERE user_id = 'u1' ORDER BY group_id`).map((r: { group_id: string }) => r.group_id);
|
|
expect(await ids()).toEqual(["devops-team", "staff"]);
|
|
await syncClaimGroups(db, "u1", []);
|
|
expect(await ids()).toEqual(["staff"]); // manual one stays, claim one goes
|
|
});
|
|
|
|
test("groupsFromClaims reads arrays and strings", () => {
|
|
expect(groupsFromClaims({ groups: ["a", "b", "a"] })).toEqual(["a", "b"]);
|
|
expect(groupsFromClaims({ roles: "x, y" }, "roles")).toEqual(["x", "y"]);
|
|
expect(groupsFromClaims({ extraFields: { groups: ["z"] } })).toEqual(["z"]);
|
|
expect(groupsFromClaims(null)).toEqual([]);
|
|
});
|
|
|
|
forEachDb("owner bootstrap fails closed: an injected failure refuses the sign-up, no silent learner, no free owner slot", async (db) => {
|
|
const t = await makeApp(db);
|
|
const count = async (q: string) => Number((await db.sql.unsafe(q))[0].n);
|
|
const err = spyOn(console, "error").mockImplementation(() => {});
|
|
try {
|
|
// The role table is unreachable: role assignment fails with a non-unique error.
|
|
await db.sql.unsafe("ALTER TABLE user_roles RENAME TO user_roles_off");
|
|
const signUp = (email: string) => t.call("POST", "/api/auth/sign-up/email", { body: { email, name: "X", password: "correct horse battery" } });
|
|
const first = await signUp("[email protected]");
|
|
expect(first.status).toBe(500);
|
|
// nothing is left: no account, no owner claim
|
|
expect(await count("SELECT COUNT(*) AS n FROM auth_user")).toBe(0);
|
|
expect(await count("SELECT COUNT(*) AS n FROM learner_meta WHERE key = 'owner'")).toBe(0);
|
|
// while the failure lasts, the next sign-up does not become owner either: it fails the same way
|
|
expect((await signUp("[email protected]")).status).toBe(500);
|
|
expect(await count("SELECT COUNT(*) AS n FROM auth_user")).toBe(0);
|
|
expect(await count("SELECT COUNT(*) AS n FROM learner_meta WHERE key = 'owner'")).toBe(0);
|
|
// once the database recovers, the first completed account is the owner, the next a learner
|
|
await db.sql.unsafe("ALTER TABLE user_roles_off RENAME TO user_roles");
|
|
const own = await t.signUp("[email protected]");
|
|
const lea = await t.signUp("[email protected]");
|
|
expect((await (await t.call("GET", "/api/me", { cookie: own })).json()).user.roles).toEqual(["owner"]);
|
|
expect((await (await t.call("GET", "/api/me", { cookie: lea })).json()).user.roles).toEqual(["learner"]);
|
|
} finally {
|
|
err.mockRestore();
|
|
}
|
|
}, 30_000);
|
|
|
|
forEachDb("assignInitialRole rethrows anything but a unique violation, and rolls the owner claim back", async (db) => {
|
|
await db.sql.unsafe("ALTER TABLE user_roles RENAME TO user_roles_off");
|
|
await expect(assignInitialRole(db, "u1")).rejects.toThrow();
|
|
expect((await db.sql`SELECT COUNT(*) AS n FROM learner_meta WHERE key = 'owner'`)[0].n.toString()).toBe("0");
|
|
await db.sql.unsafe("ALTER TABLE user_roles_off RENAME TO user_roles");
|
|
expect(await assignInitialRole(db, "u1")).toBe("owner");
|
|
expect(await assignInitialRole(db, "u2")).toBe("learner");
|
|
expect(isUniqueViolation({ code: "23505" })).toBe(true);
|
|
expect(isUniqueViolation(new Error("UNIQUE constraint failed: learner_meta.key"))).toBe(true);
|
|
expect(isUniqueViolation(new Error("no such table: user_roles"))).toBe(false);
|
|
});
|