promote: uat → main (GRO-1757 SSO auto-provision fix) #89

Merged
Scrubs McBarkley merged 35 commits from uat into main 2026-05-26 02:15:58 +00:00
5 changed files with 223 additions and 27 deletions
Showing only changes of commit 7cb5fda3e3 - Show all commits
+1 -2
View File
@@ -26,14 +26,13 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-API-1.1 | Login via OIDC | POST to OIDC provider callback, verify JWT token issued | 200 OK, JWT returned with valid claims |
| TC-API-1.2 | Session persistence | Make authenticated request, verify session token valid | 200 OK, request succeeds |
| TC-API-1.3 | Logout | Call logout endpoint, verify token invalidated | 200 OK, subsequent requests return 401 |
| TC-API-1.4 | Email+password login (UAT) | POST /api/auth/sign-in/email with uat-super@groombook.dev + SEED_UAT_SUPER_PASSWORD | 200 OK, session cookie returned |
| TC-API-1.5 | Email+password login — groomer | POST /api/auth/sign-in/email with uat-groomer@groombook.dev + SEED_UAT_GROOMER_PASSWORD | 200 OK, session cookie returned |
| TC-API-1.6 | Email+password login — customer | POST /api/auth/sign-in/email with uat-customer@groombook.dev + SEED_UAT_CUSTOMER_PASSWORD | 200 OK, session cookie returned |
| TC-API-1.7 | Email+password login — tester | POST /api/auth/sign-in/email with uat-tester@groombook.dev + SEED_UAT_TESTER_PASSWORD | 200 OK, session cookie returned |
| TC-API-1.8 | Email+password — invalid password | POST /api/auth/sign-in/email with wrong password | 400 Bad Request, error returned |
| TC-API-1.9 | Email+password — unknown user | POST /api/auth/sign-in/email with non-existent email | 400 Bad Request, error returned |
| TC-API-1.10 | Auto-provision on first OIDC login | First login as a Better-Auth user with no existing staff record | 200 OK, access granted; groomer staff record auto-created with name/email from user table |
### 4.2 Client Management
+108 -23
View File
@@ -45,40 +45,76 @@ const GROOMER: StaffRow = {
let staffLookupResult: StaffRow | null = null;
let managerFallbackResult: StaffRow | null = MANAGER;
let userLookupResult: { id: string; name: string | null; email: string | null } | null = null;
let _insertedStaff: StaffRow | null = null;
vi.mock("../db", () => {
const staff = new Proxy(
{ _name: "staff" },
{
get(target, prop) {
if (prop === "_name") return "staff";
if (prop === "$inferSelect") return {};
return { table: "staff", column: prop };
},
}
);
const makeTableProxy = (name: string) =>
new Proxy(
{ _name: name },
{
get(target, prop) {
if (prop === "_name") return name;
if (prop === "$inferSelect") return {};
return { table: name, column: prop };
},
}
);
const staff = makeTableProxy("staff");
const user = makeTableProxy("user");
const buildQuery = (result: unknown, fallback: unknown) => ({
[Symbol.iterator]: function* () {
if (result) yield result;
},
limit: (_n: number) => {
const item = result ?? fallback;
return {
[Symbol.iterator]: function* () { if (item) yield item; },
0: item,
length: item ? 1 : 0,
};
},
});
return {
getDb: () => ({
select: () => ({
from: () => ({
where: () => ({
limit: () => {
// dev mode fallback to first manager
return managerFallbackResult ? [managerFallbackResult] : [];
},
[Symbol.iterator]: function* () {
if (staffLookupResult) yield staffLookupResult;
},
0: staffLookupResult,
length: staffLookupResult ? 1 : 0,
}),
from: (table: unknown) => ({
where: () => buildQuery(
table === staff ? staffLookupResult : userLookupResult,
table === staff ? managerFallbackResult : null
),
}),
}),
insert: (_table: unknown) => ({
values: (vals: Record<string, unknown>) => ({
returning: () => {
const newStaff: StaffRow = {
id: "new-staff-id",
oidcSub: null,
userId: vals.userId as string,
role: vals.role as StaffRow["role"],
isSuperUser: false,
name: vals.name as string,
email: vals.email as string,
active: true,
icalToken: null,
createdAt: new Date(),
updatedAt: new Date(),
};
_insertedStaff = newStaff;
return [newStaff];
},
}),
}),
}),
staff,
user,
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
and: vi.fn((..._clauses: unknown[]) => ({})),
sql: vi.fn((..._args: unknown[]) => ({})),
};
});
@@ -87,6 +123,8 @@ vi.mock("../db", () => {
function resetMocks() {
staffLookupResult = null;
managerFallbackResult = MANAGER;
userLookupResult = null;
_insertedStaff = null;
}
/** Build a minimal Hono app with jwtPayload pre-set, then apply a middleware. */
@@ -96,7 +134,10 @@ function buildApp(
) {
const app = new Hono<AppEnv>();
app.use("*", async (c, next) => {
c.set("jwtPayload", { sub: staffLookupResult?.userId ?? "unknown-sub" });
c.set("jwtPayload", {
sub: userLookupResult?.id ?? staffLookupResult?.userId ?? "unknown-sub",
email: userLookupResult?.email,
});
await next();
});
app.use("*", middleware);
@@ -202,6 +243,50 @@ describe("resolveStaffMiddleware", () => {
const body = await res.json();
expect(body.error).toMatch(/no staff records found/i);
});
it("auto-provision: creates groomer staff record on first login when Better-Auth user exists", async () => {
staffLookupResult = null;
userLookupResult = { id: "ba-user-new", name: "New User", email: "newuser@example.com" };
let capturedStaff: StaffRow | null = null;
const app = buildApp(resolveStaffMiddleware, (c) => {
capturedStaff = c.get("staff");
return c.json({ ok: true });
});
const res = await app.request("/test");
expect(res.status).toBe(200);
expect(capturedStaff).not.toBeNull();
expect(capturedStaff!.role).toBe("groomer");
expect(capturedStaff!.userId).toBe("ba-user-new");
expect(capturedStaff!.name).toBe("New User");
expect(capturedStaff!.email).toBe("newuser@example.com");
expect(capturedStaff!.isSuperUser).toBe(false);
});
it("auto-provision: falls back to email prefix when user has no name", async () => {
staffLookupResult = null;
userLookupResult = { id: "ba-user-noname", name: null, email: "firstlogin@example.com" };
let capturedStaff: StaffRow | null = null;
const app = buildApp(resolveStaffMiddleware, (c) => {
capturedStaff = c.get("staff");
return c.json({ ok: true });
});
const res = await app.request("/test");
expect(res.status).toBe(200);
expect(capturedStaff!.name).toBe("firstlogin");
});
it("auto-provision: returns 403 when no staff record and no Better-Auth user exists", async () => {
staffLookupResult = null;
userLookupResult = null;
const app = buildApp(resolveStaffMiddleware);
const res = await app.request("/test");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/no staff record found for authenticated user/i);
});
});
// ─── requireRole tests ────────────────────────────────────────────────────────
+28 -1
View File
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono";
import { and, eq, getDb, sql, staff } from "../db/index.js";
import { and, eq, getDb, sql, staff, user } from "../db/index.js";
export type StaffRole = "groomer" | "receptionist" | "manager";
export type StaffRow = typeof staff.$inferSelect;
@@ -110,6 +110,33 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
return;
}
}
// Auto-provision: no staff record exists for this user at all, but a valid
// Better-Auth user session exists (jwt.sub = user.id from user table).
// Create a minimal groomer staff record on first login.
const [userRow] = await db
.select({ id: user.id, name: user.name, email: user.email })
.from(user)
.where(eq(user.id, jwt.sub))
.limit(1);
if (userRow) {
const [newStaff] = await db
.insert(staff)
.values({
name: userRow.name ?? jwt.email?.split("@")[0] ?? "Unknown",
email: userRow.email ?? jwt.email ?? "",
userId: jwt.sub,
role: "groomer",
isSuperUser: false,
active: true,
})
.returning();
if (!newStaff) {
return c.json({ error: "Internal error: staff record creation failed" }, 500);
}
c.set("staff", newStaff);
await next();
return;
}
return c.json(
{ error: "Forbidden: no staff record found for authenticated user" },
403
+1
View File
@@ -25,6 +25,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"better-auth": "^1.5.6",
"drizzle-orm": "^0.38.4",
"postgres": "^3.4.5"
},
+85 -1
View File
@@ -18,7 +18,7 @@
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import { eq, sql } from "drizzle-orm";
import { eq, and, sql } from "drizzle-orm";
import * as schema from "./schema.js";
// ── Seed profile configuration ─────────────────────────────────────────────
@@ -490,6 +490,90 @@ async function seedKnownUsers() {
}
}
// ── Better-Auth email+password credentials for UAT accounts ──────────────────
// Provisions Better-Auth user + account records so UAT testers can log in
// via email+password (POST /api/auth/sign-in/email) instead of Authentik SSO.
const uatPasswordAccounts = [
{ email: "uat-super@groombook.dev", name: "UAT Super User", passwordEnv: "SEED_UAT_SUPER_PASSWORD", staffEmail: "uat-super@groombook.dev" },
{ email: "uat-groomer@groombook.dev", name: "UAT Staff Groomer", passwordEnv: "SEED_UAT_GROOMER_PASSWORD", staffEmail: "uat-groomer@groombook.dev" },
{ email: "uat-customer@groombook.dev", name: "UAT Customer", passwordEnv: "SEED_UAT_CUSTOMER_PASSWORD", staffEmail: null },
{ email: "uat-tester@groombook.dev", name: "UAT Tester", passwordEnv: "SEED_UAT_TESTER_PASSWORD", staffEmail: "uat-tester@groombook.dev" },
];
for (const acct of uatPasswordAccounts) {
const password = process.env[acct.passwordEnv];
if (!password) {
console.warn(`⚠ Skipping ${acct.email}${acct.passwordEnv} not set`);
continue;
}
// 1. Find or create the Better-Auth user
const [existingUser] = await db
.select()
.from(schema.user)
.where(eq(schema.user.email, acct.email))
.limit(1);
let userId: string;
if (existingUser) {
userId = existingUser.id;
console.log(`✓ Better-Auth user '${acct.name}' already exists — skipping user creation`);
} else {
userId = uuid();
await db.insert(schema.user).values({
id: userId,
name: acct.name,
email: acct.email,
emailVerified: true,
});
console.log(`✓ Created Better-Auth user '${acct.name}' (${acct.email})`);
}
// 2. Check if credential account already exists
const [existingAccount] = await db
.select()
.from(schema.account)
.where(and(
eq(schema.account.userId, userId),
eq(schema.account.providerId, "credential")
))
.limit(1);
if (existingAccount) {
console.log(`✓ Credential account for '${acct.email}' already exists — skipping`);
} else {
// Use Better-Auth's own hashPassword so the hash format matches what
// better-auth validates at sign-in time.
// better-auth/crypto uses: N=16384, r=16, p=1, dkLen=64.
const { hashPassword } = await import("better-auth/crypto");
const passwordHash = await hashPassword(password);
await db.insert(schema.account).values({
id: uuid(),
accountId: userId,
providerId: "credential",
userId,
password: passwordHash,
});
console.log(`✓ Created credential account for '${acct.email}'`);
}
// 3. Link staff record to Better-Auth user (for accounts that have staff records)
if (acct.staffEmail) {
const [existingStaff] = await db
.select()
.from(schema.staff)
.where(eq(schema.staff.email, acct.staffEmail))
.limit(1);
if (existingStaff && !existingStaff.userId) {
await db.update(schema.staff)
.set({ userId })
.where(eq(schema.staff.id, existingStaff.id));
console.log(`✓ Linked staff '${acct.staffEmail}' → Better-Auth user`);
}
}
}
// ── Services: idempotent upsert using name as unique key ─────────────────────
// UNIQUE constraint on services.name (migration 0020) must exist first.
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.