From 64edd2041b74bdb889ae928d6bef3a8caef01ec1 Mon Sep 17 00:00:00 2001 From: drifterza Date: Fri, 27 Feb 2026 13:56:15 +0200 Subject: [PATCH] fix first user owner assignment on OIDC login --- app/routes/auth/oidc-callback.ts | 34 +++-- tests/unit/auth/oidc-owner-assignment.test.ts | 117 ++++++++++++++++++ 2 files changed, 142 insertions(+), 9 deletions(-) create mode 100644 tests/unit/auth/oidc-owner-assignment.test.ts diff --git a/app/routes/auth/oidc-callback.ts b/app/routes/auth/oidc-callback.ts index 7dbec2f..015b9da 100644 --- a/app/routes/auth/oidc-callback.ts +++ b/app/routes/auth/oidc-callback.ts @@ -82,19 +82,35 @@ export async function loader({ request, context }: Route.LoaderArgs) { })() : userInfo.picture; - const [{ count: userCount }] = await context.db + const [{ count: ownerCount }] = await context.db .select({ count: count() }) .from(users) .where(eq(users.caps, Roles.owner)); - await context.db - .insert(users) - .values({ - id: ulid(), - sub: claims.sub, - caps: userCount === 0 ? Roles.owner : Roles.member, - }) - .onConflictDoNothing(); + const needsOwner = ownerCount === 0; + + if (needsOwner) { + await context.db + .insert(users) + .values({ + id: ulid(), + sub: claims.sub, + caps: Roles.owner, + }) + .onConflictDoUpdate({ + target: users.sub, + set: { caps: Roles.owner }, + }); + } else { + await context.db + .insert(users) + .values({ + id: ulid(), + sub: claims.sub, + caps: Roles.member, + }) + .onConflictDoNothing(); + } return redirect("/", { headers: { diff --git a/tests/unit/auth/oidc-owner-assignment.test.ts b/tests/unit/auth/oidc-owner-assignment.test.ts new file mode 100644 index 0000000..f446908 --- /dev/null +++ b/tests/unit/auth/oidc-owner-assignment.test.ts @@ -0,0 +1,117 @@ +import { createClient } from "@libsql/client"; +import { count, eq } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/libsql"; +import { ulid } from "ulidx"; +import { beforeEach, describe, expect, test } from "vitest"; + +import { users } from "~/server/db/schema"; +import { Roles } from "~/server/web/roles"; + +function createTestDb() { + const client = createClient({ url: ":memory:" }); + const db = drizzle(client); + return { client, db }; +} + +async function setupSchema(client: ReturnType) { + await client.execute(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + sub TEXT NOT NULL UNIQUE, + caps INTEGER NOT NULL DEFAULT 0, + onboarded INTEGER NOT NULL DEFAULT 0 + ) + `); +} + +async function countOwners(db: ReturnType) { + const [result] = await db + .select({ count: count() }) + .from(users) + .where(eq(users.caps, Roles.owner)); + return result?.count ?? 0; +} + +async function simulateOidcLogin(db: ReturnType, subject: string) { + const ownerCount = await countOwners(db); + const needsOwner = ownerCount === 0; + + if (needsOwner) { + await db + .insert(users) + .values({ + id: ulid(), + sub: subject, + caps: Roles.owner, + }) + .onConflictDoUpdate({ + target: users.sub, + set: { caps: Roles.owner }, + }); + } else { + await db + .insert(users) + .values({ + id: ulid(), + sub: subject, + caps: Roles.member, + }) + .onConflictDoNothing(); + } +} + +describe("OIDC owner assignment", () => { + let db: ReturnType; + let client: ReturnType; + + beforeEach(async () => { + const testDb = createTestDb(); + db = testDb.db; + client = testDb.client; + await setupSchema(client); + }); + + test("first user gets owner role", async () => { + await simulateOidcLogin(db, "first-user"); + + const [user] = await db.select().from(users).where(eq(users.sub, "first-user")); + expect(user.caps).toBe(Roles.owner); + }); + + test("second user gets member role when owner exists", async () => { + await simulateOidcLogin(db, "first-user"); + await simulateOidcLogin(db, "second-user"); + + const [second] = await db.select().from(users).where(eq(users.sub, "second-user")); + expect(second.caps).toBe(Roles.member); + }); + + test("existing member becomes owner if no owner exists", async () => { + await db.insert(users).values({ + id: ulid(), + sub: "orphaned-user", + caps: Roles.member, + onboarded: false, + }); + + await simulateOidcLogin(db, "orphaned-user"); + + const [user] = await db.select().from(users).where(eq(users.sub, "orphaned-user")); + expect(user.caps).toBe(Roles.owner); + }); + + test("existing member stays member when owner exists", async () => { + await simulateOidcLogin(db, "owner-user"); + await db.insert(users).values({ + id: ulid(), + sub: "member-user", + caps: Roles.member, + onboarded: false, + }); + + await simulateOidcLogin(db, "member-user"); + + const [member] = await db.select().from(users).where(eq(users.sub, "member-user")); + expect(member.caps).toBe(Roles.member); + }); +});