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:
co-authored by
Claude Opus 5.5
parent
9aa475329a
commit
668ae379bf
+19
-7
@@ -1,25 +1,35 @@
|
||||
import type { AppConfig, AuthService, Feature, JobHandler, ServerContext } from "@stc/contracts";
|
||||
import type { AppConfig, AuthService, Feature, JobHandler, JobQueue, ServerContext } from "@stc/contracts";
|
||||
import { createApp, createJobQueue, loadConfig } from "@stc/core";
|
||||
import { createCourseRepository } from "@stc/course-format";
|
||||
import { connect, migrate } from "@stc/db";
|
||||
import { learnerFeature } from "@stc/feature-learner";
|
||||
import indexHtml from "../../web/index.html";
|
||||
|
||||
// Temporary: the learner package replaces this with real sessions.
|
||||
// Used until a feature supplies an `auth` service (the learner package does).
|
||||
const anonymousAuth: AuthService = { userFromRequest: async () => null };
|
||||
|
||||
/** A feature, or a factory that needs the server context. A feature may carry the `AuthService`. */
|
||||
export type FeatureEntry = (Feature & { auth?: AuthService }) | ((ctx: ServerContext) => Feature & { auth?: AuthService });
|
||||
|
||||
export async function startServer(
|
||||
config: AppConfig,
|
||||
features: Feature[],
|
||||
features: FeatureEntry[],
|
||||
_webFeatures?: unknown,
|
||||
): Promise<{ server: Bun.Server<undefined>; stop(): Promise<void> }> {
|
||||
const db = await connect(config.databaseUrl);
|
||||
await migrate(db);
|
||||
const courses = await createCourseRepository(config.courseDirs);
|
||||
const handlers: Record<string, JobHandler> = {};
|
||||
for (const f of features) Object.assign(handlers, f.jobs);
|
||||
// Factories get the context before the job queue exists; ctx.jobs is set right after they run.
|
||||
const ctx: ServerContext = { config, db, courses, jobs: null as unknown as JobQueue, auth: anonymousAuth };
|
||||
const built = features.map((f) => (typeof f === "function" ? f(ctx) : f));
|
||||
for (const f of built) {
|
||||
Object.assign(handlers, f.jobs);
|
||||
if (f.auth) ctx.auth = f.auth;
|
||||
}
|
||||
const jobs = createJobQueue(db, handlers);
|
||||
const ctx: ServerContext = { config, db, courses, jobs, auth: anonymousAuth };
|
||||
const app = createApp(ctx, features);
|
||||
ctx.jobs = jobs;
|
||||
const app = createApp(ctx, built);
|
||||
jobs.start();
|
||||
const server = Bun.serve({
|
||||
port: config.port,
|
||||
@@ -39,6 +49,8 @@ export async function startServer(
|
||||
|
||||
if (import.meta.main) {
|
||||
const config = loadConfig();
|
||||
const { server } = await startServer(config, []);
|
||||
const { server } = await startServer(config, [
|
||||
learnerFeature,
|
||||
]);
|
||||
console.log(`STCourses listening on ${server.url}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Contract requests from WP-A (learner)
|
||||
|
||||
## 1. Feature factories and the auth service (`apps/server/src/main.ts`, `@stc/contracts`)
|
||||
|
||||
**Problem.** Phase 0 passes `Feature[]` to `startServer`, but `ServerContext` is built inside `startServer`, so a feature that needs `ctx` (every Phase 1 package exports `xFeature(ctx)`) cannot be listed. There is also no way for a feature to replace the anonymous `AuthService`.
|
||||
|
||||
**Workaround in `feat/learner` (applied, small):** `startServer` accepts `FeatureEntry = Feature | ((ctx) => Feature & { auth?: AuthService })`. Factories run after `ctx` exists (with `ctx.jobs` set right after they return, so factories must not use `jobs` synchronously), and a feature carrying `auth` replaces `ctx.auth`. The `main` entry lists `learnerFeature`.
|
||||
|
||||
**Proposal.** Move both into the contract:
|
||||
```ts
|
||||
export type FeatureFactory = (ctx: ServerContext) => Feature;
|
||||
export interface Feature { /* … */ auth?: AuthService; migrationsDirs?: string[] }
|
||||
```
|
||||
and let `startServer` take `Array<Feature | FeatureFactory>`.
|
||||
|
||||
## 2. Migrations per package
|
||||
|
||||
`migrate()` only reads `packages/db/migrations`. The learner package exports `migrationsDirs` and, until Phase 2 wires it in, runs its own migrations lazily inside `learnerFeature(ctx)` through `runMigrations(db, dirs)`, which uses the same `schema_migrations` table and file-name keys. When `migrate()` learns about `migrationsDirs`, the lazy call becomes a no-op; it can then be removed.
|
||||
|
||||
## 3. Package entry for the browser
|
||||
|
||||
`@stc/feature-learner`'s index exports both `learnerFeature` (server) and `learnerWeb`/`LessonView` (browser). Bun's browser bundler tree-shakes the server half (checked), so one entry works. The web shell imports `learnerWeb` from `@stc/feature-learner`; the studio can import `LessonView` the same way.
|
||||
@@ -1 +1,2 @@
|
||||
export { migrationsDirs, runMigrations } from "./server/migrate";
|
||||
export { learnerFeature } from "./server/feature";
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Db } from "@stc/db";
|
||||
|
||||
export const groupIdFromName = (name: string): string =>
|
||||
name.trim().toLowerCase().normalize("NFKD").replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "") || "group";
|
||||
|
||||
/** Reads a groups claim from an IdP profile: an array, or a comma/space separated string. */
|
||||
export function groupsFromClaims(profile: Record<string, unknown> | null | undefined, claim = "groups"): string[] {
|
||||
if (!profile) return [];
|
||||
const raw = profile[claim] ?? (profile.extraFields as Record<string, unknown> | undefined)?.[claim];
|
||||
const list = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(/[,\s]+/) : [];
|
||||
return [...new Set(list.map((g) => String(g).trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
/** Finds or creates the group called `name`; returns its id. */
|
||||
export async function ensureGroup(db: Db, name: string): Promise<string> {
|
||||
const byName = await db.sql`SELECT id FROM groups WHERE name = ${name}`;
|
||||
if (byName.length) return byName[0].id;
|
||||
let id = groupIdFromName(name);
|
||||
const taken = await db.sql`SELECT 1 AS x FROM groups WHERE id = ${id}`;
|
||||
if (taken.length) id = `${id}-${Bun.randomUUIDv7().slice(-6)}`;
|
||||
await db.sql`INSERT INTO groups (id, name) VALUES (${id}, ${name})`;
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the user's claim-sourced group memberships equal `names`. Memberships the owner added by hand
|
||||
* are never removed here, and a group the user already belongs to by hand is not recorded as a claim.
|
||||
*/
|
||||
export async function syncClaimGroups(db: Db, userId: string, names: string[]): Promise<void> {
|
||||
const wanted = new Set<string>();
|
||||
for (const n of names) wanted.add(await ensureGroup(db, n));
|
||||
const prior = new Set<string>((await db.sql`SELECT group_id FROM learner_group_claims WHERE user_id = ${userId}`).map((r: { group_id: string }) => r.group_id));
|
||||
for (const g of prior) {
|
||||
if (wanted.has(g)) continue;
|
||||
await db.sql`DELETE FROM learner_group_claims WHERE user_id = ${userId} AND group_id = ${g}`;
|
||||
await db.sql`DELETE FROM group_members WHERE user_id = ${userId} AND group_id = ${g}`;
|
||||
}
|
||||
for (const g of wanted) {
|
||||
if (prior.has(g)) continue;
|
||||
const member = await db.sql`SELECT 1 AS x FROM group_members WHERE group_id = ${g} AND user_id = ${userId}`;
|
||||
if (member.length) continue; // manual membership stays manual
|
||||
await db.sql`INSERT INTO group_members (group_id, user_id) VALUES (${g}, ${userId})`;
|
||||
await db.sql`INSERT INTO learner_group_claims (user_id, group_id) VALUES (${userId}, ${g})`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { betterAuth, type BetterAuthOptions } from "better-auth";
|
||||
import { genericOAuth } from "better-auth/plugins/generic-oauth";
|
||||
import { sso } from "@better-auth/sso";
|
||||
import type { AuthService, AuthUser, Role, ServerContext } from "@stc/contracts";
|
||||
import type { Db } from "@stc/db";
|
||||
import { bunSqlAdapter } from "./adapter";
|
||||
import { groupsFromClaims, syncClaimGroups } from "./groups";
|
||||
|
||||
export interface ProviderInfo { id: string; label: string; kind: "social" | "oidc" }
|
||||
|
||||
type Env = Record<string, string | undefined>;
|
||||
|
||||
const SOCIAL: { id: string; label: string; env: string }[] = [
|
||||
{ id: "github", label: "GitHub", env: "GITHUB" },
|
||||
{ id: "google", label: "Google", env: "GOOGLE" },
|
||||
{ id: "microsoft", label: "Microsoft", env: "MICROSOFT" },
|
||||
{ id: "gitlab", label: "GitLab", env: "GITLAB" },
|
||||
{ id: "discord", label: "Discord", env: "DISCORD" },
|
||||
];
|
||||
|
||||
/** Owner-only Better Auth paths (SSO provider administration). Sign-in and callbacks stay open. */
|
||||
const OWNER_ONLY = /^\/api\/auth\/sso\/(register|update-provider|delete-provider|providers|get-provider|request-domain-verification|verify-domain)\b/;
|
||||
|
||||
export async function rolesOf(db: Db, userId: string): Promise<Role[]> {
|
||||
const rows = await db.sql`SELECT role FROM user_roles WHERE user_id = ${userId} ORDER BY role`;
|
||||
return rows.map((r: { role: Role }) => r.role);
|
||||
}
|
||||
|
||||
export async function groupsOf(db: Db, userId: string): Promise<string[]> {
|
||||
const rows = await db.sql`SELECT group_id FROM group_members WHERE user_id = ${userId} ORDER BY group_id`;
|
||||
return rows.map((r: { group_id: string }) => r.group_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* First user ever to be created becomes the owner. The claim is an INSERT into `learner_meta`
|
||||
* keyed `owner`, so with concurrent sign-ups exactly one wins (primary-key conflict for the rest).
|
||||
*/
|
||||
export async function assignInitialRole(db: Db, userId: string): Promise<Role> {
|
||||
let role: Role = "learner";
|
||||
try {
|
||||
await db.sql`INSERT INTO learner_meta (key, value) VALUES ('owner', ${userId})`;
|
||||
role = "owner";
|
||||
} catch {
|
||||
role = "learner";
|
||||
}
|
||||
await db.sql`INSERT INTO user_roles (user_id, role) VALUES (${userId}, ${role})`;
|
||||
return role;
|
||||
}
|
||||
|
||||
export interface LearnerAuth {
|
||||
handler(req: Request, user: AuthUser | null): Promise<Response>;
|
||||
service: AuthService & { invalidate(userId?: string): void };
|
||||
providers(): ProviderInfo[];
|
||||
hasSso(): Promise<boolean>;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Better Auth's instance type is deeply generic
|
||||
api: any;
|
||||
}
|
||||
|
||||
export function createAuth(ctx: ServerContext, ready: Promise<unknown>, env: Env = process.env): LearnerAuth {
|
||||
const { db, config } = ctx;
|
||||
const groupsClaim = env.STC_AUTH_GROUPS_CLAIM || "groups";
|
||||
const pendingClaims = new Map<string, { groups: string[]; at: number }>();
|
||||
const providers: ProviderInfo[] = [];
|
||||
|
||||
const socialProviders: Record<string, { clientId: string; clientSecret: string }> = {};
|
||||
for (const s of SOCIAL) {
|
||||
const id = env[`STC_AUTH_${s.env}_ID`], secret = env[`STC_AUTH_${s.env}_SECRET`];
|
||||
if (id && secret) {
|
||||
socialProviders[s.id] = { clientId: id, clientSecret: secret };
|
||||
providers.push({ id: s.id, label: s.label, kind: "social" });
|
||||
}
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: plugin list is heterogeneous
|
||||
const plugins: any[] = [];
|
||||
if (env.STC_AUTH_OIDC_ISSUER && env.STC_AUTH_OIDC_ID) {
|
||||
const issuer = env.STC_AUTH_OIDC_ISSUER.replace(/\/$/, "");
|
||||
plugins.push(genericOAuth({
|
||||
config: [{
|
||||
providerId: "oidc",
|
||||
clientId: env.STC_AUTH_OIDC_ID,
|
||||
clientSecret: env.STC_AUTH_OIDC_SECRET ?? "",
|
||||
discoveryUrl: env.STC_AUTH_OIDC_DISCOVERY_URL || `${issuer}/.well-known/openid-configuration`,
|
||||
scopes: (env.STC_AUTH_OIDC_SCOPES || "openid email profile").split(/[\s,]+/),
|
||||
pkce: true,
|
||||
mapProfileToUser: (profile) => {
|
||||
const email = typeof profile.email === "string" ? profile.email.toLowerCase() : null;
|
||||
if (email) pendingClaims.set(email, { groups: groupsFromClaims(profile as Record<string, unknown>, groupsClaim), at: Date.now() });
|
||||
return {};
|
||||
},
|
||||
}],
|
||||
}));
|
||||
providers.push({ id: "oidc", label: env.STC_AUTH_OIDC_NAME || "Single sign-on", kind: "oidc" });
|
||||
}
|
||||
plugins.push(sso({
|
||||
provisionUserOnEveryLogin: true,
|
||||
provisionUser: async ({ user, userInfo }) => {
|
||||
await syncClaimGroups(db, user.id, groupsFromClaims(userInfo, groupsClaim));
|
||||
cache.clear();
|
||||
},
|
||||
modelName: "ssoProvider",
|
||||
}));
|
||||
|
||||
const options: BetterAuthOptions = {
|
||||
appName: "STCourses",
|
||||
baseURL: config.publicUrl,
|
||||
basePath: "/api/auth",
|
||||
secret: config.secret,
|
||||
database: bunSqlAdapter(db),
|
||||
emailAndPassword: { enabled: true, minPasswordLength: 8, autoSignIn: true },
|
||||
socialProviders,
|
||||
plugins,
|
||||
trustedOrigins: [config.publicUrl, ...(env.STC_TRUSTED_ORIGINS ?? "").split(",").map((s) => s.trim()).filter(Boolean)],
|
||||
telemetry: { enabled: false },
|
||||
session: { cookieCache: { enabled: true, maxAge: 60 } },
|
||||
databaseHooks: {
|
||||
user: { create: { after: async (user) => { await assignInitialRole(db, user.id); } } },
|
||||
session: {
|
||||
create: {
|
||||
after: async (session) => {
|
||||
const rows = await db.sql`SELECT email FROM auth_user WHERE id = ${session.userId}`;
|
||||
const email = rows[0]?.email?.toLowerCase();
|
||||
const claim = email ? pendingClaims.get(email) : undefined;
|
||||
if (claim && email) {
|
||||
pendingClaims.delete(email);
|
||||
if (Date.now() - claim.at < 10 * 60_000) await syncClaimGroups(db, session.userId, claim.groups);
|
||||
}
|
||||
cache.delete(session.userId);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const auth = betterAuth(options);
|
||||
|
||||
// userId -> roles/groups, briefly cached: every request (media ranges included) resolves the user.
|
||||
const cache = new Map<string, { roles: Role[]; groups: string[]; at: number }>();
|
||||
const TTL = 15_000;
|
||||
|
||||
const service: LearnerAuth["service"] = {
|
||||
async userFromRequest(req) {
|
||||
if (!req.headers.get("cookie")) return null;
|
||||
await ready;
|
||||
let s: { user: { id: string; email: string; name: string } } | null = null;
|
||||
try {
|
||||
s = await auth.api.getSession({ headers: req.headers });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!s) return null;
|
||||
const id = s.user.id;
|
||||
let c = cache.get(id);
|
||||
if (!c || Date.now() - c.at > TTL) {
|
||||
c = { roles: await rolesOf(db, id), groups: await groupsOf(db, id), at: Date.now() };
|
||||
cache.set(id, c);
|
||||
}
|
||||
return { id, email: s.user.email, name: s.user.name, roles: c.roles, groups: c.groups };
|
||||
},
|
||||
invalidate(userId) {
|
||||
if (userId) cache.delete(userId);
|
||||
else cache.clear();
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
api: auth.api,
|
||||
service,
|
||||
providers: () => providers,
|
||||
async hasSso() {
|
||||
await ready;
|
||||
const rows = await db.sql`SELECT COUNT(*) AS n FROM auth_sso_provider`;
|
||||
return Number(rows[0].n) > 0;
|
||||
},
|
||||
async handler(req, user) {
|
||||
await ready;
|
||||
const path = new URL(req.url).pathname;
|
||||
if (OWNER_ONLY.test(path) && !user?.roles.includes("owner")) return Response.json({ error: "owner only" }, { status: 403 });
|
||||
return auth.handler(req);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { AuthService, Feature, ServerContext } from "@stc/contracts";
|
||||
import { createAuth } from "./auth";
|
||||
import { runMigrations } from "./migrate";
|
||||
import { json } from "./http";
|
||||
|
||||
/**
|
||||
* The learner feature: auth, library, reader, notes, progress and exercises.
|
||||
* Returns the routes plus the `AuthService` that replaces the server's anonymous one.
|
||||
*/
|
||||
export function learnerFeature(ctx: ServerContext): Feature & { auth: AuthService } {
|
||||
const ready = runMigrations(ctx.db);
|
||||
ready.catch((e) => console.error("[learner] migrations failed", e));
|
||||
const auth = createAuth(ctx, ready);
|
||||
const routes: Feature["routes"] = {
|
||||
"/api/auth/*": {
|
||||
GET: (req, rc) => auth.handler(req, rc.user),
|
||||
POST: (req, rc) => auth.handler(req, rc.user),
|
||||
},
|
||||
"/api/me": { GET: async (_req, rc) => json({ user: rc.user }) },
|
||||
"/api/learner/providers": {
|
||||
GET: async () => json({ emailPassword: true, providers: auth.providers(), sso: await auth.hasSso() }),
|
||||
},
|
||||
};
|
||||
return { name: "learner", routes, auth: auth.service };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { AuthUser, RequestContext, RouteHandler } from "@stc/contracts";
|
||||
|
||||
export const json = (body: unknown, status = 200): Response => Response.json(body, { status });
|
||||
export const notFound = (): Response => json({ error: "not found" }, 404);
|
||||
export const badRequest = (message: string): Response => json({ error: message }, 400);
|
||||
|
||||
/** Wraps a handler that needs a signed-in user; anonymous callers get 401. */
|
||||
export const signedIn = (h: (req: Request, rc: RequestContext, user: AuthUser) => Response | Promise<Response>): RouteHandler =>
|
||||
(req, rc) => (rc.user ? h(req, rc, rc.user) : json({ error: "sign in required" }, 401));
|
||||
|
||||
/** Owner-only handler: 401 when anonymous, 403 for other roles. */
|
||||
export const ownerOnly = (h: (req: Request, rc: RequestContext, user: AuthUser) => Response | Promise<Response>): RouteHandler =>
|
||||
signedIn((req, rc, user) => (user.roles.includes("owner") ? h(req, rc, user) : json({ error: "owner only" }, 403)));
|
||||
|
||||
export async function body<T = Record<string, unknown>>(req: Request): Promise<T | null> {
|
||||
try {
|
||||
const v = await req.json();
|
||||
return v && typeof v === "object" ? (v as T) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Route params, decoded (core's matcher already decodes; Bun's router may not). */
|
||||
export const param = (rc: RequestContext, name: string): string => {
|
||||
const v = rc.params[name] ?? "";
|
||||
try { return decodeURIComponent(v); } catch { return v; }
|
||||
};
|
||||
@@ -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