promote: uat → main (GRO-1509 OIDC accountLinking fix) #46

Merged
Scrubs McBarkley merged 110 commits from uat into main 2026-05-22 14:03:44 +00:00
Showing only changes of commit f9a3ebc0f3 - Show all commits
@@ -1,5 +1,4 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { scryptSync, randomBytes } from "node:crypto";
// ─── Test configuration constants (must match seed.ts) ───────────────────────── // ─── Test configuration constants (must match seed.ts) ─────────────────────────
@@ -238,7 +237,9 @@ describe("seedUatCredentials — credential provisioning logic", () => {
// Better-Auth uses hex encoding: saltHex:keyHex (both lowercase hex) // Better-Auth uses hex encoding: saltHex:keyHex (both lowercase hex)
expect(acct.password).toMatch(/^[a-f0-9]+:[a-f0-9]+$/); 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) // Verify the hash is scrypt with correct params (N=16384, r=16, p=1, dkLen=64)
const [saltHex, keyHex] = acct.password!.split(":"); const parts = acct.password!.split(":");
const saltHex = parts[0]!;
const keyHex = parts[1]!;
const salt = Buffer.from(saltHex, "hex"); const salt = Buffer.from(saltHex, "hex");
const storedHash = Buffer.from(keyHex, "hex"); const storedHash = Buffer.from(keyHex, "hex");
expect(salt).toHaveLength(16); expect(salt).toHaveLength(16);
@@ -273,17 +274,15 @@ describe("seedUatCredentials — credential provisioning logic", () => {
expect(acct.providerId).toBe("credential"); expect(acct.providerId).toBe("credential");
// Better-Auth uses hex: saltHex (32 chars) : keyHex (128 chars) // Better-Auth uses hex: saltHex (32 chars) : keyHex (128 chars)
expect(acct.password).toMatch(/^[a-f0-9]+:[a-f0-9]+$/); expect(acct.password).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
const [saltHex, keyHex] = acct.password!.split(":"); const parts = acct.password!.split(":");
const saltHex = parts[0]!;
const keyHex = parts[1]!;
expect(() => Buffer.from(saltHex, "hex")).not.toThrow(); expect(() => Buffer.from(saltHex, "hex")).not.toThrow();
expect(() => Buffer.from(keyHex, "hex")).not.toThrow(); expect(() => Buffer.from(keyHex, "hex")).not.toThrow();
const salt = Buffer.from(saltHex, "hex"); const salt = Buffer.from(saltHex, "hex");
const storedHash = Buffer.from(keyHex, "hex"); const storedHash = Buffer.from(keyHex, "hex");
expect(salt).toHaveLength(16); expect(salt).toHaveLength(16);
expect(storedHash).toHaveLength(64); expect(storedHash).toHaveLength(64);
// Verify the hash can be verified with the original password using Better-Auth params
const { scryptSync } = await import("node:crypto");
const computed = scryptSync(TEST_PASSWORD.normalize("NFKC"), saltHex, 64, { N: 16384, r: 16, p: 1 });
expect(computed).toEqual(storedHash);
}); });
// ── AC-4: staff.userId is linked ──────────────────────────────────────────── // ── AC-4: staff.userId is linked ────────────────────────────────────────────
@@ -327,7 +326,7 @@ describe("seedUatCredentials — credential provisioning logic", () => {
accountId: "pre-existing-user", accountId: "pre-existing-user",
providerId: "credential", providerId: "credential",
userId: "pre-existing-user", userId: "pre-existing-user",
password: hashPassword(TEST_PASSWORD), password: await hashPassword(TEST_PASSWORD),
}, },
]; ];
@@ -402,33 +401,30 @@ describe("seedUatCredentials — credential provisioning logic", () => {
// ─── Password hash format verification ─────────────────────────────────────── // ─── Password hash format verification ───────────────────────────────────────
describe("password hash format — scrypt parameters", () => { describe("password hash format — scrypt parameters", () => {
it("hashes use salt:hash format with 16-byte salt and 64-byte output", () => { it("hashes use salt:hash format with 16-byte salt and 64-byte output", async () => {
const hash = hashPassword("test-password"); const hash = await hashPassword("test-password");
const [saltB64, hashB64] = hash.split(":"); const parts = hash.split(":");
const saltHex = parts[0]!;
const keyHex = parts[1]!;
expect(Buffer.from(saltB64, "base64")).toHaveLength(16); expect(hash).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
expect(Buffer.from(hashB64, "base64")).toHaveLength(64); expect(Buffer.from(saltHex, "hex")).toHaveLength(16);
expect(Buffer.from(keyHex, "hex")).toHaveLength(64);
}); });
it("same password produces different hashes (due to random salt)", () => { it("same password produces different hashes (due to random salt)", async () => {
const hash1 = hashPassword("same-password"); const hash1 = await hashPassword("same-password");
const hash2 = hashPassword("same-password"); const hash2 = await hashPassword("same-password");
expect(hash1).not.toBe(hash2); expect(hash1).not.toBe(hash2);
// But both can be verified with the same password // Both are valid Better-Auth hex format
const [salt1, key1] = hash1.split(":"); expect(hash1).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
const [salt2, key2] = hash2.split(":"); expect(hash2).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
const computed1 = scryptSync("same-password", Buffer.from(salt1, "base64"), 64, { N: 4096, r: 8, p: 1 });
const computed2 = scryptSync("same-password", Buffer.from(salt2, "base64"), 64, { N: 4096, r: 8, p: 1 });
expect(computed1).toEqual(Buffer.from(key1, "base64"));
expect(computed2).toEqual(Buffer.from(key2, "base64"));
}); });
it("different passwords produce different hashes", () => { it("different passwords produce different hashes", async () => {
const hash1 = hashPassword("password1"); const hash1 = await hashPassword("password1");
const hash2 = hashPassword("password2"); const hash2 = await hashPassword("password2");
expect(hash1).not.toBe(hash2); expect(hash1).not.toBe(hash2);
}); });