From 668ae379bf4d1f187507df5dc21def63c316ea54 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 03:20:06 +0100 Subject: [PATCH] feat(learner): Better Auth sessions, owner bootstrap, claim groups; server takes feature factories Co-Authored-By: Claude Opus 5.5 --- apps/server/src/main.ts | 26 ++- docs/contract-requests/learner.md | 22 +++ packages/feature-learner/src/index.ts | 1 + .../feature-learner/src/server/auth/groups.ts | 45 +++++ .../feature-learner/src/server/auth/index.ts | 181 ++++++++++++++++++ .../feature-learner/src/server/feature.ts | 25 +++ packages/feature-learner/src/server/http.ts | 28 +++ packages/feature-learner/test/app.ts | 48 +++++ packages/feature-learner/test/auth.test.ts | 62 ++++++ 9 files changed, 431 insertions(+), 7 deletions(-) create mode 100644 docs/contract-requests/learner.md create mode 100644 packages/feature-learner/src/server/auth/groups.ts create mode 100644 packages/feature-learner/src/server/auth/index.ts create mode 100644 packages/feature-learner/src/server/feature.ts create mode 100644 packages/feature-learner/src/server/http.ts create mode 100644 packages/feature-learner/test/app.ts create mode 100644 packages/feature-learner/test/auth.test.ts diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 9b43b71..ca26cd7 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -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; stop(): Promise }> { const db = await connect(config.databaseUrl); await migrate(db); const courses = await createCourseRepository(config.courseDirs); const handlers: Record = {}; - 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}`); } diff --git a/docs/contract-requests/learner.md b/docs/contract-requests/learner.md new file mode 100644 index 0000000..cf5c9cc --- /dev/null +++ b/docs/contract-requests/learner.md @@ -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`. + +## 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. diff --git a/packages/feature-learner/src/index.ts b/packages/feature-learner/src/index.ts index a1b83eb..1988a66 100644 --- a/packages/feature-learner/src/index.ts +++ b/packages/feature-learner/src/index.ts @@ -1 +1,2 @@ export { migrationsDirs, runMigrations } from "./server/migrate"; +export { learnerFeature } from "./server/feature"; diff --git a/packages/feature-learner/src/server/auth/groups.ts b/packages/feature-learner/src/server/auth/groups.ts new file mode 100644 index 0000000..978728c --- /dev/null +++ b/packages/feature-learner/src/server/auth/groups.ts @@ -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 | null | undefined, claim = "groups"): string[] { + if (!profile) return []; + const raw = profile[claim] ?? (profile.extraFields as Record | 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 { + 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 { + const wanted = new Set(); + for (const n of names) wanted.add(await ensureGroup(db, n)); + const prior = new Set((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})`; + } +} diff --git a/packages/feature-learner/src/server/auth/index.ts b/packages/feature-learner/src/server/auth/index.ts new file mode 100644 index 0000000..29d028c --- /dev/null +++ b/packages/feature-learner/src/server/auth/index.ts @@ -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; + +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 { + 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 { + 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 { + 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; + service: AuthService & { invalidate(userId?: string): void }; + providers(): ProviderInfo[]; + hasSso(): Promise; + // biome-ignore lint/suspicious/noExplicitAny: Better Auth's instance type is deeply generic + api: any; +} + +export function createAuth(ctx: ServerContext, ready: Promise, env: Env = process.env): LearnerAuth { + const { db, config } = ctx; + const groupsClaim = env.STC_AUTH_GROUPS_CLAIM || "groups"; + const pendingClaims = new Map(); + const providers: ProviderInfo[] = []; + + const socialProviders: Record = {}; + 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, 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(); + 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); + }, + }; +} diff --git a/packages/feature-learner/src/server/feature.ts b/packages/feature-learner/src/server/feature.ts new file mode 100644 index 0000000..0b297db --- /dev/null +++ b/packages/feature-learner/src/server/feature.ts @@ -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 }; +} diff --git a/packages/feature-learner/src/server/http.ts b/packages/feature-learner/src/server/http.ts new file mode 100644 index 0000000..7441a0c --- /dev/null +++ b/packages/feature-learner/src/server/http.ts @@ -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): 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): RouteHandler => + signedIn((req, rc, user) => (user.roles.includes("owner") ? h(req, rc, user) : json({ error: "owner only" }, 403))); + +export async function body>(req: Request): Promise { + 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; } +}; diff --git a/packages/feature-learner/test/app.ts b/packages/feature-learner/test/app.ts new file mode 100644 index 0000000..9bc48b6 --- /dev/null +++ b/packages/feature-learner/test/app.ts @@ -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 } = {}) { + const headers: Record = { 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("; "); +} diff --git a/packages/feature-learner/test/auth.test.ts b/packages/feature-learner/test/auth.test.ts new file mode 100644 index 0000000..6df41bc --- /dev/null +++ b/packages/feature-learner/test/auth.test.ts @@ -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([]); +});