feat(learner): Better Auth sessions, owner bootstrap, claim groups; server takes feature factories
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9aa475329a
commit
668ae379bf
9 files changed
+431
-7
No files matched your search
@@ -0,0 +1,48 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import type { AppConfig, JobQueue, ServerContext } from "@stc/contracts";
|
||||
import { createApp } from "@stc/core";
|
||||
import { createCourseRepository } from "@stc/course-format";
|
||||
import type { Db } from "@stc/db";
|
||||
import { learnerFeature } from "../src/server/feature";
|
||||
|
||||
export const FIXTURES = resolve(import.meta.dir, "../../../fixtures/courses");
|
||||
export const ORIGIN = "http://localhost:3000";
|
||||
|
||||
const fakeJobs: JobQueue = { enqueue: async () => "j", get: async () => null, list: async () => [] };
|
||||
|
||||
export async function makeApp(db: Db, courseDirs: string[] = [FIXTURES]) {
|
||||
const config: AppConfig = {
|
||||
port: 0, databaseUrl: "", dataDir: mkdtempSync(join(tmpdir(), "stc-learner-")), courseDirs,
|
||||
secret: "test-secret-0123456789abcdef0123456789", publicUrl: ORIGIN,
|
||||
};
|
||||
const ctx: ServerContext = {
|
||||
config, db, courses: await createCourseRepository(courseDirs), jobs: fakeJobs,
|
||||
auth: { userFromRequest: async () => null },
|
||||
};
|
||||
const feature = learnerFeature(ctx);
|
||||
ctx.auth = feature.auth;
|
||||
const app = createApp(ctx, [feature]);
|
||||
|
||||
async function call(method: string, path: string, opts: { body?: unknown; cookie?: string; headers?: Record<string, string> } = {}) {
|
||||
const headers: Record<string, string> = { origin: ORIGIN, ...opts.headers };
|
||||
if (opts.cookie) headers.cookie = opts.cookie;
|
||||
let body: BodyInit | undefined;
|
||||
if (opts.body !== undefined) { headers["content-type"] = "application/json"; body = JSON.stringify(opts.body); }
|
||||
return app.fetch(new Request(ORIGIN + path, { method, headers, body }));
|
||||
}
|
||||
|
||||
/** Signs up and returns a cookie header for the new session. */
|
||||
async function signUp(email: string, name = email.split("@")[0]!, password = "correct horse battery") {
|
||||
const r = await call("POST", "/api/auth/sign-up/email", { body: { email, name, password } });
|
||||
if (r.status !== 200) throw new Error(`sign-up failed ${r.status}: ${await r.text()}`);
|
||||
return cookieFrom(r);
|
||||
}
|
||||
|
||||
return { app, ctx, feature, call, signUp };
|
||||
}
|
||||
|
||||
export function cookieFrom(r: Response): string {
|
||||
return r.headers.getSetCookie().map((c) => c.split(";")[0]).filter((c) => !c.endsWith("=")).join("; ");
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { assignInitialRole } 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("owner@example.com");
|
||||
const me = await (await t.call("GET", "/api/me", { cookie: ownerCookie })).json();
|
||||
expect(me.user.email).toBe("owner@example.com");
|
||||
expect(me.user.roles).toEqual(["owner"]);
|
||||
const second = await t.signUp("learner@example.com");
|
||||
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("a@example.com", "A", "pw-123456789");
|
||||
const bad = await t.call("POST", "/api/auth/sign-in/email", { body: { email: "a@example.com", password: "nope-nope-nope" } });
|
||||
expect(bad.status).toBe(401);
|
||||
const ok = await t.call("POST", "/api/auth/sign-in/email", { body: { email: "a@example.com", 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("owner@example.com");
|
||||
const learner = await t.signUp("l@example.com");
|
||||
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([]);
|
||||
});
|
||||
Reference in new issue
Block a user