Update .env.example to list all 4 required OIDC vars (was missing OIDC_REDIRECT_URI). Fix test assertion to match username->email rename in signInWithCredentials. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
408 lines
13 KiB
TypeScript
408 lines
13 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
|
/** Mock session shape returned by getSession in tests. */
|
|
interface MockSessionData {
|
|
data: {
|
|
user: Record<string, unknown>;
|
|
} | null;
|
|
}
|
|
|
|
/** Words that must never appear in PDA-friendly messages. */
|
|
const FORBIDDEN_WORDS = [
|
|
"overdue",
|
|
"urgent",
|
|
"must",
|
|
"critical",
|
|
"required",
|
|
"error",
|
|
"failed",
|
|
"failure",
|
|
];
|
|
|
|
// Mock BetterAuth before importing the module under test
|
|
vi.mock("better-auth/react", () => ({
|
|
createAuthClient: vi.fn(() => ({
|
|
signIn: vi.fn(),
|
|
signOut: vi.fn(),
|
|
useSession: vi.fn(),
|
|
getSession: vi.fn(() => Promise.resolve({ data: null })),
|
|
})),
|
|
}));
|
|
|
|
vi.mock("better-auth/client/plugins", () => ({
|
|
genericOAuthClient: vi.fn(() => ({})),
|
|
}));
|
|
|
|
vi.mock("./config", () => ({
|
|
API_BASE_URL: "http://localhost:3001",
|
|
}));
|
|
|
|
// Import after mocks are set up
|
|
const { signInWithCredentials, getAccessToken, isAdmin, getSession } =
|
|
await import("./auth-client");
|
|
|
|
/**
|
|
* Helper to build a mock Response object that behaves like the Fetch API Response.
|
|
*/
|
|
function mockResponse(options: {
|
|
ok: boolean;
|
|
status: number;
|
|
body?: Record<string, unknown>;
|
|
}): Response {
|
|
const { ok, status, body = {} } = options;
|
|
return {
|
|
ok,
|
|
status,
|
|
json: vi.fn(() => Promise.resolve(body)),
|
|
headers: new Headers(),
|
|
redirected: false,
|
|
statusText: "",
|
|
type: "basic" as ResponseType,
|
|
url: "",
|
|
clone: vi.fn(),
|
|
body: null,
|
|
bodyUsed: false,
|
|
arrayBuffer: vi.fn(),
|
|
blob: vi.fn(),
|
|
formData: vi.fn(),
|
|
text: vi.fn(),
|
|
bytes: vi.fn(),
|
|
} as unknown as Response;
|
|
}
|
|
|
|
describe("signInWithCredentials", (): void => {
|
|
const originalFetch = global.fetch;
|
|
|
|
beforeEach((): void => {
|
|
global.fetch = vi.fn();
|
|
});
|
|
|
|
afterEach((): void => {
|
|
global.fetch = originalFetch;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("should return data on successful response", async (): Promise<void> => {
|
|
const sessionData = { user: { id: "1", name: "Alice" }, token: "abc" };
|
|
vi.mocked(global.fetch).mockResolvedValueOnce(
|
|
mockResponse({ ok: true, status: 200, body: sessionData })
|
|
);
|
|
|
|
const result = await signInWithCredentials("alice", "password123");
|
|
|
|
expect(result).toEqual(sessionData);
|
|
expect(global.fetch).toHaveBeenCalledWith(
|
|
"http://localhost:3001/auth/sign-in/credentials",
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
credentials: "include",
|
|
body: JSON.stringify({ email: "alice", password: "password123" }),
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should throw PDA-friendly message on 401 response", async (): Promise<void> => {
|
|
vi.mocked(global.fetch).mockResolvedValueOnce(
|
|
mockResponse({ ok: false, status: 401, body: { message: "Unauthorized" } })
|
|
);
|
|
|
|
await expect(signInWithCredentials("alice", "wrong")).rejects.toThrow(
|
|
"The email and password combination wasn't recognized."
|
|
);
|
|
});
|
|
|
|
it("should throw PDA-friendly message on 401 with no body message", async (): Promise<void> => {
|
|
vi.mocked(global.fetch).mockResolvedValueOnce(
|
|
mockResponse({ ok: false, status: 401, body: {} })
|
|
);
|
|
|
|
// When there is no body message, the response object (status: 401) is used for parsing
|
|
await expect(signInWithCredentials("alice", "wrong")).rejects.toThrow(
|
|
"The email and password combination wasn't recognized."
|
|
);
|
|
});
|
|
|
|
it("should throw PDA-friendly message on 500 response", async (): Promise<void> => {
|
|
vi.mocked(global.fetch).mockResolvedValueOnce(
|
|
mockResponse({
|
|
ok: false,
|
|
status: 500,
|
|
body: { message: "Internal Server Error" },
|
|
})
|
|
);
|
|
|
|
await expect(signInWithCredentials("alice", "pass")).rejects.toThrow(
|
|
"The service is taking a break. Please try again in a moment."
|
|
);
|
|
});
|
|
|
|
it("should throw PDA-friendly message on 500 with no body message", async (): Promise<void> => {
|
|
vi.mocked(global.fetch).mockResolvedValueOnce(
|
|
mockResponse({ ok: false, status: 500, body: {} })
|
|
);
|
|
|
|
await expect(signInWithCredentials("alice", "pass")).rejects.toThrow(
|
|
"The service is taking a break. Please try again in a moment."
|
|
);
|
|
});
|
|
|
|
it("should throw PDA-friendly message on network error (fetch throws)", async (): Promise<void> => {
|
|
vi.mocked(global.fetch).mockRejectedValueOnce(new TypeError("Failed to fetch"));
|
|
|
|
await expect(signInWithCredentials("alice", "pass")).rejects.toThrow(TypeError);
|
|
});
|
|
|
|
it("should throw PDA-friendly message on 429 rate-limited response", async (): Promise<void> => {
|
|
vi.mocked(global.fetch).mockResolvedValueOnce(
|
|
mockResponse({
|
|
ok: false,
|
|
status: 429,
|
|
body: { message: "Too many requests" },
|
|
})
|
|
);
|
|
|
|
await expect(signInWithCredentials("alice", "pass")).rejects.toThrow(
|
|
"You've tried a few times. Take a moment and try again shortly."
|
|
);
|
|
});
|
|
|
|
it("should throw PDA-friendly message when response.json() throws", async (): Promise<void> => {
|
|
const errorSpy = vi.spyOn(console, "error").mockReturnValue(undefined);
|
|
const resp = mockResponse({ ok: false, status: 403 });
|
|
const jsonError = new SyntaxError("Unexpected token");
|
|
(resp.json as ReturnType<typeof vi.fn>).mockRejectedValueOnce(jsonError);
|
|
vi.mocked(global.fetch).mockResolvedValueOnce(resp);
|
|
|
|
// JSON parse fails -> logs error -> falls back to response status
|
|
await expect(signInWithCredentials("alice", "pass")).rejects.toThrow(
|
|
"The email and password combination wasn't recognized."
|
|
);
|
|
|
|
expect(errorSpy).toHaveBeenCalledWith(
|
|
"[Auth] Failed to parse error response body (HTTP 403):",
|
|
jsonError
|
|
);
|
|
errorSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("signInWithCredentials PDA-friendly language compliance", (): void => {
|
|
const originalFetch = global.fetch;
|
|
|
|
beforeEach((): void => {
|
|
global.fetch = vi.fn();
|
|
});
|
|
|
|
afterEach((): void => {
|
|
global.fetch = originalFetch;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
const errorScenarios: {
|
|
name: string;
|
|
status: number;
|
|
body: Record<string, unknown>;
|
|
}[] = [
|
|
{ name: "401 with message", status: 401, body: { message: "Unauthorized" } },
|
|
{ name: "401 without message", status: 401, body: {} },
|
|
{ name: "403 with message", status: 403, body: { message: "Forbidden" } },
|
|
{ name: "429 with message", status: 429, body: { message: "Too many requests" } },
|
|
{ name: "500 with message", status: 500, body: { message: "Internal Server Error" } },
|
|
{ name: "500 without message", status: 500, body: {} },
|
|
{ name: "502 without message", status: 502, body: {} },
|
|
{ name: "503 without message", status: 503, body: {} },
|
|
{ name: "400 unknown", status: 400, body: {} },
|
|
];
|
|
|
|
for (const scenario of errorScenarios) {
|
|
it(`should not contain forbidden words for ${scenario.name} response`, async (): Promise<void> => {
|
|
vi.mocked(global.fetch).mockResolvedValueOnce(
|
|
mockResponse({ ok: false, status: scenario.status, body: scenario.body })
|
|
);
|
|
|
|
try {
|
|
await signInWithCredentials("alice", "pass");
|
|
// Should not reach here
|
|
expect.unreachable("signInWithCredentials should have thrown");
|
|
} catch (thrown: unknown) {
|
|
expect(thrown).toBeInstanceOf(Error);
|
|
const message = (thrown as Error).message.toLowerCase();
|
|
for (const forbidden of FORBIDDEN_WORDS) {
|
|
expect(message).not.toContain(forbidden);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// AUTH-030: getAccessToken tests
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe("getAccessToken", (): void => {
|
|
afterEach((): void => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("should return null when no session exists (session.data is null)", async (): Promise<void> => {
|
|
vi.mocked(getSession).mockResolvedValueOnce({ data: null } as MockSessionData);
|
|
|
|
const result = await getAccessToken();
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("should return accessToken when session has valid, non-expired token", async (): Promise<void> => {
|
|
vi.mocked(getSession).mockResolvedValueOnce({
|
|
data: {
|
|
user: {
|
|
id: "user-1",
|
|
accessToken: "valid-token-abc",
|
|
tokenExpiresAt: Date.now() + 300_000, // 5 minutes from now
|
|
},
|
|
},
|
|
} as MockSessionData);
|
|
|
|
const result = await getAccessToken();
|
|
|
|
expect(result).toBe("valid-token-abc");
|
|
});
|
|
|
|
it("should return null when token is expired (tokenExpiresAt in the past)", async (): Promise<void> => {
|
|
vi.mocked(getSession).mockResolvedValueOnce({
|
|
data: {
|
|
user: {
|
|
id: "user-1",
|
|
accessToken: "expired-token",
|
|
tokenExpiresAt: Date.now() - 120_000, // 2 minutes ago
|
|
},
|
|
},
|
|
} as MockSessionData);
|
|
|
|
const result = await getAccessToken();
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("should return null when token expires within 60-second buffer window", async (): Promise<void> => {
|
|
vi.mocked(getSession).mockResolvedValueOnce({
|
|
data: {
|
|
user: {
|
|
id: "user-1",
|
|
accessToken: "almost-expired-token",
|
|
tokenExpiresAt: Date.now() + 30_000, // 30 seconds from now (within 60s buffer)
|
|
},
|
|
},
|
|
} as MockSessionData);
|
|
|
|
const result = await getAccessToken();
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("should return null and warn when accessToken is undefined on user object", async (): Promise<void> => {
|
|
const warnSpy = vi.spyOn(console, "warn").mockReturnValue(undefined);
|
|
vi.mocked(getSession).mockResolvedValueOnce({
|
|
data: {
|
|
user: {
|
|
id: "user-1",
|
|
// no accessToken property
|
|
},
|
|
},
|
|
} as MockSessionData);
|
|
|
|
const result = await getAccessToken();
|
|
|
|
expect(result).toBeNull();
|
|
expect(warnSpy).toHaveBeenCalledWith("[Auth] Session exists but no accessToken found");
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it("should return null and log error when getSession throws", async (): Promise<void> => {
|
|
const errorSpy = vi.spyOn(console, "error").mockReturnValue(undefined);
|
|
const sessionError = new Error("Network failure");
|
|
vi.mocked(getSession).mockRejectedValueOnce(sessionError);
|
|
|
|
const result = await getAccessToken();
|
|
|
|
expect(result).toBeNull();
|
|
expect(errorSpy).toHaveBeenCalledWith("[Auth] Failed to get access token:", sessionError);
|
|
errorSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// AUTH-030: isAdmin tests
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe("isAdmin", (): void => {
|
|
afterEach((): void => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("should return false when no session exists", async (): Promise<void> => {
|
|
vi.mocked(getSession).mockResolvedValueOnce({ data: null } as MockSessionData);
|
|
|
|
const result = await isAdmin();
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it("should return true when user.isAdmin is true", async (): Promise<void> => {
|
|
vi.mocked(getSession).mockResolvedValueOnce({
|
|
data: {
|
|
user: {
|
|
id: "admin-1",
|
|
isAdmin: true,
|
|
},
|
|
},
|
|
} as MockSessionData);
|
|
|
|
const result = await isAdmin();
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it("should return false when user.isAdmin is false", async (): Promise<void> => {
|
|
vi.mocked(getSession).mockResolvedValueOnce({
|
|
data: {
|
|
user: {
|
|
id: "user-1",
|
|
isAdmin: false,
|
|
},
|
|
},
|
|
} as MockSessionData);
|
|
|
|
const result = await isAdmin();
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it("should return false when user.isAdmin is undefined", async (): Promise<void> => {
|
|
vi.mocked(getSession).mockResolvedValueOnce({
|
|
data: {
|
|
user: {
|
|
id: "user-1",
|
|
// no isAdmin property
|
|
},
|
|
},
|
|
} as MockSessionData);
|
|
|
|
const result = await isAdmin();
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it("should return false and log error when getSession throws", async (): Promise<void> => {
|
|
const errorSpy = vi.spyOn(console, "error").mockReturnValue(undefined);
|
|
const sessionError = new Error("Network failure");
|
|
vi.mocked(getSession).mockRejectedValueOnce(sessionError);
|
|
|
|
const result = await isAdmin();
|
|
|
|
expect(result).toBe(false);
|
|
expect(errorSpy).toHaveBeenCalledWith("[Auth] Failed to check admin status:", sessionError);
|
|
errorSpy.mockRestore();
|
|
});
|
|
});
|