Files
api/apps/api/src/__tests__/seed-uat-credentials.test.ts
T
The Dogfather e5fe005986
CI / Lint & Typecheck (push) Successful in 16s
CI / Test (push) Successful in 12s
CI / Build & Push Docker Images (push) Failing after 36s
Promote dev→uat: restore deterministic TestCooper/TestRocky alerts (GRO-1962) (#123)
Co-authored-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net>
Co-committed-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net>
2026-06-01 00:36:36 +00:00

508 lines
18 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ─── Test configuration constants (must match seed.ts) ─────────────────────────
const UAT_ACCOUNTS = [
{
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",
},
];
const TEST_PASSWORD = "test-password-123";
// ─── Password hashing — must match better-auth/crypto (N=16384, r=16, p=1, dkLen=64, hex) ───
async function hashPassword(password: string): Promise<string> {
const { hashPassword } = await import("better-auth/crypto");
return hashPassword(password);
}
// ─── Mock DB state ─────────────────────────────────────────────────────────────
interface UserRow {
id: string;
email: string;
name: string;
emailVerified: boolean;
}
interface AccountRow {
id: string;
accountId: string;
providerId: string;
userId: string;
password: string | null;
}
interface StaffRow {
id: string;
email: string;
userId: string | null;
name: string;
}
let dbUsers: UserRow[] = [];
let dbAccounts: AccountRow[] = [];
let dbStaff: StaffRow[] = [];
let insertedUsers: UserRow[] = [];
let insertedAccounts: AccountRow[] = [];
let updatedAccounts: Array<{ id: string; password: string }> = [];
let updatedStaff: Array<{ id: string; userId: string }> = [];
const originalEnv = { ...process.env };
function resetMock() {
dbUsers = [];
dbAccounts = [];
dbStaff = [];
insertedUsers = [];
insertedAccounts = [];
updatedAccounts = [];
updatedStaff = [];
process.env = { ...originalEnv };
}
// ─── Mock schema ───────────────────────────────────────────────────────────────
function makeSchemaMock() {
const user = new Proxy({ _name: "user" }, {
get(_t, p) {
if (p === "_name") return "user";
if (p === "$inferSelect") return {};
return { table: "user", column: p };
},
});
const account = new Proxy({ _name: "account" }, {
get(_t, p) {
if (p === "_name") return "account";
if (p === "$inferSelect") return {};
return { table: "account", column: p };
},
});
const staff = new Proxy({ _name: "staff" }, {
get(_t, p) {
if (p === "_name") return "staff";
if (p === "$inferSelect") return {};
return { table: "staff", column: p };
},
});
return { user, account, staff };
}
const { user: mockUser, account: mockAccount, staff: mockStaff } = makeSchemaMock();
function eq(col: unknown, val: unknown) {
return { __type: "eq" as const, col, val };
}
function and(...conds: unknown[]) {
return { __type: "and" as const, conds };
}
// ─── Seed logic helper ─────────────────────────────────────────────────────────
// Inline the credential provisioning logic under test so we can call it directly.
// This is the same logic as seed.ts lines 514-598.
interface SeedAccount {
email: string;
name: string;
passwordEnv: string;
staffEmail: string | null;
}
let uuidCounter = 0;
function mockUuid(): string {
return `mock-uuid-${++uuidCounter}`;
}
async function seedUatCredentials(
accounts: SeedAccount[],
opts: {
users?: UserRow[];
accounts?: AccountRow[];
staff?: StaffRow[];
}
) {
const { users = dbUsers, accounts: accts = dbAccounts, staff: staffRows = dbStaff } = opts;
for (const acct of accounts) {
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 = users.find((u) => u.email === acct.email);
let userId: string;
if (existingUser) {
userId = existingUser.id;
} else {
userId = mockUuid();
const newUser: UserRow = { id: userId, name: acct.name, email: acct.email, emailVerified: true };
insertedUsers.push(newUser);
dbUsers.push(newUser);
}
// 2. Check if credential account already exists
const existingAccount = accts.find(
(a) => a.userId === userId && a.providerId === "credential"
);
if (existingAccount) {
// Idempotent update: re-hash the current env password and update the stored hash.
const { hashPassword } = await import("better-auth/crypto");
const passwordHash = await hashPassword(password);
existingAccount.password = passwordHash;
updatedAccounts.push({ id: existingAccount.id, password: passwordHash });
} else {
// Use Better-Auth's hashPassword so test helper matches production seed.ts
const { hashPassword } = await import("better-auth/crypto");
const passwordHash = await hashPassword(password);
const newAccount: AccountRow = {
id: mockUuid(),
accountId: userId,
providerId: "credential",
userId,
password: passwordHash,
};
insertedAccounts.push(newAccount);
dbAccounts.push(newAccount);
}
// 3. Link staff record to Better-Auth user
if (acct.staffEmail) {
const existingStaff = staffRows.find((s) => s.email === acct.staffEmail);
if (existingStaff && !existingStaff.userId) {
existingStaff.userId = userId;
updatedStaff.push({ id: existingStaff.id, userId });
}
}
}
}
// ─── Tests ─────────────────────────────────────────────────────────────────────
describe("seedUatCredentials — credential provisioning logic", () => {
beforeEach(() => {
resetMock();
uuidCounter = 0;
});
afterEach(() => {
process.env = { ...originalEnv };
});
// ── AC-1: creates user + account when neither exists ──────────────────────
it("AC-1: creates user and account for each UAT account with password env var set", async () => {
process.env.SEED_UAT_SUPER_PASSWORD = TEST_PASSWORD;
process.env.SEED_UAT_GROOMER_PASSWORD = TEST_PASSWORD;
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
process.env.SEED_UAT_TESTER_PASSWORD = TEST_PASSWORD;
await seedUatCredentials(UAT_ACCOUNTS, { users: [], accounts: [], staff: [] });
// 4 users created (customer + tester have no staff, super + groomer do)
expect(insertedUsers).toHaveLength(4);
expect(insertedUsers.find((u) => u.email === "uat-super@groombook.dev")).toBeDefined();
expect(insertedUsers.find((u) => u.email === "uat-groomer@groombook.dev")).toBeDefined();
expect(insertedUsers.find((u) => u.email === "uat-customer@groombook.dev")).toBeDefined();
expect(insertedUsers.find((u) => u.email === "uat-tester@groombook.dev")).toBeDefined();
// 4 accounts created
expect(insertedAccounts).toHaveLength(4);
for (const acct of insertedAccounts) {
expect(acct.providerId).toBe("credential");
// Better-Auth uses hex encoding: saltHex:keyHex (both lowercase hex)
expect(acct.password).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
// Verify the hash is scrypt with correct params (N=16384, r=16, p=1, dkLen=64)
const parts = acct.password!.split(":");
const saltHex = parts[0]!;
const keyHex = parts[1]!;
const salt = Buffer.from(saltHex, "hex");
const storedHash = Buffer.from(keyHex, "hex");
expect(salt).toHaveLength(16);
expect(storedHash).toHaveLength(64);
}
});
// ── AC-2: emailVerified = true ─────────────────────────────────────────────
it("AC-2: created users have emailVerified = true", async () => {
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
await seedUatCredentials(
[UAT_ACCOUNTS[2]!], // customer only
{ users: [], accounts: [], staff: [] }
);
expect(insertedUsers[0]!.emailVerified).toBe(true);
});
// ── AC-3: providerId = credential, password is hashed ──────────────────────
it("AC-3: account records use providerId='credential' with properly formatted hashed password", async () => {
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
await seedUatCredentials(
[UAT_ACCOUNTS[2]!],
{ users: [], accounts: [], staff: [] }
);
const acct = insertedAccounts[0]!;
expect(acct.providerId).toBe("credential");
// Better-Auth uses hex: saltHex (32 chars) : keyHex (128 chars)
expect(acct.password).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
const parts = acct.password!.split(":");
const saltHex = parts[0]!;
const keyHex = parts[1]!;
expect(() => Buffer.from(saltHex, "hex")).not.toThrow();
expect(() => Buffer.from(keyHex, "hex")).not.toThrow();
const salt = Buffer.from(saltHex, "hex");
const storedHash = Buffer.from(keyHex, "hex");
expect(salt).toHaveLength(16);
expect(storedHash).toHaveLength(64);
});
// ── AC-4: staff.userId is linked ────────────────────────────────────────────
it("AC-4: links staff.userId to the Better-Auth user when staff record exists", async () => {
process.env.SEED_UAT_SUPER_PASSWORD = TEST_PASSWORD;
const staffRows: StaffRow[] = [
{ id: "staff-super-1", email: "uat-super@groombook.dev", userId: null, name: "UAT Super User" },
];
await seedUatCredentials([UAT_ACCOUNTS[0]!], { users: [], accounts: [], staff: staffRows });
expect(updatedStaff).toHaveLength(1);
expect(updatedStaff[0]!.id).toBe("staff-super-1");
expect(updatedStaff[0]!.userId).toBe("mock-uuid-1");
expect(staffRows[0]!.userId).toBe("mock-uuid-1");
});
it("AC-4b: does not update staff.userId if already set", async () => {
process.env.SEED_UAT_GROOMER_PASSWORD = TEST_PASSWORD;
const staffRows: StaffRow[] = [
{ id: "staff-groomer-1", email: "uat-groomer@groombook.dev", userId: "already-linked", name: "UAT Groomer" },
];
await seedUatCredentials([UAT_ACCOUNTS[1]!], { users: [], accounts: [], staff: staffRows });
expect(updatedStaff).toHaveLength(0);
});
// ── AC-5: idempotent — does not insert duplicate records ───────────────────
it("AC-5: re-running does not insert duplicate user or account records", async () => {
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
const preExistingUsers: UserRow[] = [
{ id: "pre-existing-user", email: "uat-customer@groombook.dev", name: "UAT Customer", emailVerified: true },
];
const preExistingAccounts: AccountRow[] = [
{
id: "pre-existing-acct",
accountId: "pre-existing-user",
providerId: "credential",
userId: "pre-existing-user",
password: await hashPassword(TEST_PASSWORD),
},
];
await seedUatCredentials([UAT_ACCOUNTS[2]!], {
users: preExistingUsers,
accounts: preExistingAccounts,
staff: [],
});
// No inserts — user and account already exist
expect(insertedUsers).toHaveLength(0);
expect(insertedAccounts).toHaveLength(0);
});
// ── AC-5b: password rotation on re-seed ─────────────────────────────────────
it("AC-5b: re-running with a new password updates the stored credential hash", async () => {
const OLD_PASSWORD = "old-password-abc";
const NEW_PASSWORD = "new-password-xyz";
process.env.SEED_UAT_CUSTOMER_PASSWORD = NEW_PASSWORD;
const preExistingUsers: UserRow[] = [
{ id: "pre-existing-user", email: "uat-customer@groombook.dev", name: "UAT Customer", emailVerified: true },
];
const preExistingAccounts: AccountRow[] = [
{
id: "pre-existing-acct",
accountId: "pre-existing-user",
providerId: "credential",
userId: "pre-existing-user",
password: await hashPassword(OLD_PASSWORD),
},
];
await seedUatCredentials([UAT_ACCOUNTS[2]!], {
users: preExistingUsers,
accounts: preExistingAccounts,
staff: [],
});
// No new records inserted
expect(insertedUsers).toHaveLength(0);
expect(insertedAccounts).toHaveLength(0);
// Password WAS updated to the new env value
expect(updatedAccounts).toHaveLength(1);
expect(updatedAccounts[0]!.id).toBe("pre-existing-acct");
// New hash is valid Better-Auth format (salt:key, each hex)
const newHashParts = updatedAccounts[0]!.password.split(":");
expect(Buffer.from(newHashParts[0]!, "hex")).toHaveLength(16);
expect(Buffer.from(newHashParts[1]!, "hex")).toHaveLength(64);
});
// ── AC-8: existing account password IS updated (not frozen at first-seed) ──
it("AC-8: re-seeding with a changed password env var updates the stored hash", async () => {
const ORIGINAL_PASSWORD = "original-password";
const ROTATED_PASSWORD = "rotated-password-456";
process.env.SEED_UAT_CUSTOMER_PASSWORD = ROTATED_PASSWORD;
const preExistingUsers: UserRow[] = [
{ id: "pre-existing-user", email: "uat-customer@groombook.dev", name: "UAT Customer", emailVerified: true },
];
// Account was created with the original password on first seed
const originalHash = await hashPassword(ORIGINAL_PASSWORD);
const preExistingAccounts: AccountRow[] = [
{
id: "pre-existing-acct",
accountId: "pre-existing-user",
providerId: "credential",
userId: "pre-existing-user",
password: originalHash,
},
];
// Re-seed with the rotated password env var
await seedUatCredentials([UAT_ACCOUNTS[2]!], {
users: preExistingUsers,
accounts: preExistingAccounts,
staff: [],
});
// No new user or account created
expect(insertedUsers).toHaveLength(0);
expect(insertedAccounts).toHaveLength(0);
// The pre-existing account's password WAS updated (not frozen at first-seed).
// hashPassword uses a random salt so we verify by format + that it is a new,
// different valid hash from the original.
const updatedAcct = preExistingAccounts[0]!;
expect(updatedAcct.password).toBeDefined();
expect(updatedAcct.password).toMatch(/^[a-f0-9]{32}:[a-f0-9]{128}$/);
expect(updatedAcct.password).not.toBe(originalHash); // it actually changed
});
// ── AC-6: missing env var skips with warning ────────────────────────────────
it("AC-6: missing SEED_UAT_*_PASSWORD env var skips that account (no error)", async () => {
// No env vars set at all
delete process.env.SEED_UAT_SUPER_PASSWORD;
delete process.env.SEED_UAT_GROOMER_PASSWORD;
delete process.env.SEED_UAT_CUSTOMER_PASSWORD;
delete process.env.SEED_UAT_TESTER_PASSWORD;
const warnSpy = vi.spyOn(console, "warn").mockReturnValue(undefined);
await seedUatCredentials(UAT_ACCOUNTS, { users: [], accounts: [], staff: [] });
// Nothing created
expect(insertedUsers).toHaveLength(0);
expect(insertedAccounts).toHaveLength(0);
// Warning logged for each of the 4 accounts
expect(warnSpy).toHaveBeenCalledTimes(4);
expect(warnSpy).toHaveBeenCalledWith(
"⚠ Skipping uat-super@groombook.dev — SEED_UAT_SUPER_PASSWORD not set"
);
warnSpy.mockRestore();
});
// ── AC-7: partial env var coverage ─────────────────────────────────────────
it("AC-7: only accounts with password env var set are provisioned", async () => {
process.env.SEED_UAT_SUPER_PASSWORD = TEST_PASSWORD;
// Only super has password set
const warnSpy = vi.spyOn(console, "warn").mockReturnValue(undefined);
await seedUatCredentials(UAT_ACCOUNTS, { users: [], accounts: [], staff: [] });
expect(insertedUsers).toHaveLength(1);
expect(insertedUsers[0]!.email).toBe("uat-super@groombook.dev");
expect(insertedAccounts).toHaveLength(1);
expect(insertedAccounts[0]!.accountId).toBe("mock-uuid-1");
// 3 warnings for missing accounts
expect(warnSpy).toHaveBeenCalledTimes(3);
warnSpy.mockRestore();
});
});
// ─── Password hash format verification ───────────────────────────────────────
describe("password hash format — scrypt parameters", () => {
it("hashes use salt:hash format with 16-byte salt and 64-byte output", async () => {
const hash = await hashPassword("test-password");
const parts = hash.split(":");
const saltHex = parts[0]!;
const keyHex = parts[1]!;
expect(hash).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
expect(Buffer.from(saltHex, "hex")).toHaveLength(16);
expect(Buffer.from(keyHex, "hex")).toHaveLength(64);
});
it("same password produces different hashes (due to random salt)", async () => {
const hash1 = await hashPassword("same-password");
const hash2 = await hashPassword("same-password");
expect(hash1).not.toBe(hash2);
// Both are valid Better-Auth hex format
expect(hash1).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
expect(hash2).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
});
it("different passwords produce different hashes", async () => {
const hash1 = await hashPassword("password1");
const hash2 = await hashPassword("password2");
expect(hash1).not.toBe(hash2);
});
});