e5fe005986
Co-authored-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net> Co-committed-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net>
508 lines
18 KiB
TypeScript
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);
|
|
});
|
|
}); |