Files
NianAIGC/tests/account-store-postgres-auth.test.ts

127 lines
5.2 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const { hashLocalPassword, queryDatabase, transactionQuery, verifyLocalPassword, withDatabaseTransaction, state } = vi.hoisted(() => {
const state = {
user: {} as Record<string, unknown>,
transactionTail: Promise.resolve() as Promise<unknown>
};
const transactionQuery = vi.fn(async (text: string, values: readonly unknown[] = []) => {
if (text.includes("SELECT * FROM platform_users")) return { rows: [{ ...state.user }], rowCount: 1 };
if (text.includes("SET failed_login_count=$2")) {
state.user.failed_login_count = values[1];
state.user.locked_until = values[2];
state.user.updated_at = values[3];
return { rows: [], rowCount: 1 };
}
if (text.includes("SET failed_login_count=0")) {
state.user.failed_login_count = 0;
state.user.locked_until = null;
state.user.last_login_at = values[1];
state.user.updated_at = values[1];
return { rows: [{ ...state.user }], rowCount: 1 };
}
if (text.includes("SET password_hash=$2")) {
state.user.password_hash = values[1];
state.user.password_salt = values[2];
state.user.session_version = Number(state.user.session_version) + 1;
state.user.updated_at = values[3];
return { rows: [{ ...state.user }], rowCount: 1 };
}
throw new Error(`Unexpected SQL: ${text}`);
});
const withDatabaseTransaction = vi.fn(<T>(callback: (client: { query: typeof transactionQuery }) => Promise<T>) => {
const run = state.transactionTail.then(() => callback({ query: transactionQuery }));
state.transactionTail = run.catch(() => undefined);
return run;
});
return {
hashLocalPassword: vi.fn(async (password: string) => ({ hash: `hash:${password}`, salt: `salt:${password}` })),
queryDatabase: vi.fn(),
transactionQuery,
verifyLocalPassword: vi.fn(async (password: string, hash: string) => hash === `hash:${password}`),
withDatabaseTransaction,
state
};
});
vi.mock("@/lib/server/database", () => ({
isPostgresBackend: () => true,
queryDatabase,
withDatabaseTransaction
}));
vi.mock("@/lib/server/data-store", () => ({
reassignOwnerData: vi.fn()
}));
vi.mock("@/lib/server/auth/password", () => ({
hashLocalPassword,
verifyLocalPassword
}));
import { authenticatePlatformUser, changeOwnPassword } from "@/lib/server/account-store";
describe("PostgreSQL account authentication", () => {
beforeEach(() => {
queryDatabase.mockReset();
transactionQuery.mockClear();
withDatabaseTransaction.mockClear();
hashLocalPassword.mockClear();
verifyLocalPassword.mockClear();
state.transactionTail = Promise.resolve();
state.user = {
id: "user-1",
phone: "13800138000",
display_name: "Concurrent user",
role: "super_admin",
organization_id: null,
status: "active",
password_hash: "hash",
password_salt: "salt",
failed_login_count: 0,
locked_until: null,
session_version: 1,
last_login_at: null,
legacy_subject: null,
created_at: new Date("2026-08-12T00:00:00.000Z"),
updated_at: new Date("2026-08-12T00:00:00.000Z")
};
});
it("allows only one concurrent password change using the same current password", async () => {
state.user.password_hash = "hash:current-password";
state.user.password_salt = "salt:current-password";
const attempts = await Promise.allSettled([
changeOwnPassword("user-1", "current-password", "next-password-one"),
changeOwnPassword("user-1", "current-password", "next-password-two")
]);
expect(attempts[0]).toMatchObject({ status: "fulfilled", value: { sessionVersion: 2 } });
expect(attempts[1]).toMatchObject({ status: "rejected", reason: { status: 400 } });
expect(state.user.password_hash).toBe("hash:next-password-one");
expect(state.user.session_version).toBe(2);
expect(withDatabaseTransaction).toHaveBeenCalledTimes(2);
expect(transactionQuery.mock.calls.filter(([sql]) => String(sql).includes("FOR UPDATE"))).toHaveLength(2);
expect(transactionQuery.mock.calls.filter(([sql]) => String(sql).includes("session_version=session_version+1"))).toHaveLength(1);
expect(hashLocalPassword).toHaveBeenCalledTimes(1);
expect(queryDatabase).not.toHaveBeenCalled();
});
it("serializes concurrent failures on the user row and locks on the fifth attempt", async () => {
const attempts = await Promise.allSettled(
Array.from({ length: 5 }, () => authenticatePlatformUser("138 0013 8000", "wrong-password"))
);
expect(attempts.map((attempt) => attempt.status === "rejected" && attempt.reason.status)).toEqual([401, 401, 401, 401, 423]);
expect(state.user.failed_login_count).toBe(0);
expect(new Date(String(state.user.locked_until)).getTime()).toBeGreaterThan(Date.now());
expect(withDatabaseTransaction).toHaveBeenCalledTimes(5);
expect(transactionQuery.mock.calls.filter(([sql]) => String(sql).includes("FOR UPDATE"))).toHaveLength(5);
expect(queryDatabase).not.toHaveBeenCalled();
await expect(authenticatePlatformUser("13800138000", "wrong-password")).rejects.toMatchObject({ status: 423 });
expect(transactionQuery.mock.calls.filter(([sql]) => String(sql).includes("SET failed_login_count=$2"))).toHaveLength(5);
});
});