127 lines
5.2 KiB
TypeScript
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);
|
|
});
|
|
});
|