fix(#411): QA-005 — production logging, error classification, session-expired state
logAuthError now always logs (not dev-only). Replaced isBackendError with parseAuthError-based classification. signOut uses proper error type. Session expiry sets explicit session_expired state. Login page logs in prod. Fixed pre-existing lint violations in auth package (campsite rule). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
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",
|
||||
@@ -31,7 +38,8 @@ vi.mock("./config", () => ({
|
||||
}));
|
||||
|
||||
// Import after mocks are set up
|
||||
const { signInWithCredentials, getAccessToken, isAdmin, getSession } = await import("./auth-client");
|
||||
const { signInWithCredentials, getAccessToken, isAdmin, getSession } =
|
||||
await import("./auth-client");
|
||||
|
||||
/**
|
||||
* Helper to build a mock Response object that behaves like the Fetch API Response.
|
||||
@@ -159,14 +167,22 @@ describe("signInWithCredentials", (): void => {
|
||||
});
|
||||
|
||||
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 });
|
||||
vi.mocked(resp.json).mockRejectedValueOnce(new SyntaxError("Unexpected token"));
|
||||
const jsonError = new SyntaxError("Unexpected token");
|
||||
(resp.json as ReturnType<typeof vi.fn>).mockRejectedValueOnce(jsonError);
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce(resp);
|
||||
|
||||
// json().catch(() => ({})) returns {}, so no message -> falls back to response status
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -182,11 +198,11 @@ describe("signInWithCredentials PDA-friendly language compliance", (): void => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const errorScenarios: Array<{
|
||||
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" } },
|
||||
@@ -229,7 +245,7 @@ describe("getAccessToken", (): void => {
|
||||
});
|
||||
|
||||
it("should return null when no session exists (session.data is null)", async (): Promise<void> => {
|
||||
vi.mocked(getSession).mockResolvedValueOnce({ data: null } as any);
|
||||
vi.mocked(getSession).mockResolvedValueOnce({ data: null } as MockSessionData);
|
||||
|
||||
const result = await getAccessToken();
|
||||
|
||||
@@ -245,7 +261,7 @@ describe("getAccessToken", (): void => {
|
||||
tokenExpiresAt: Date.now() + 300_000, // 5 minutes from now
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
} as MockSessionData);
|
||||
|
||||
const result = await getAccessToken();
|
||||
|
||||
@@ -261,7 +277,7 @@ describe("getAccessToken", (): void => {
|
||||
tokenExpiresAt: Date.now() - 120_000, // 2 minutes ago
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
} as MockSessionData);
|
||||
|
||||
const result = await getAccessToken();
|
||||
|
||||
@@ -277,14 +293,15 @@ describe("getAccessToken", (): void => {
|
||||
tokenExpiresAt: Date.now() + 30_000, // 30 seconds from now (within 60s buffer)
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
} as MockSessionData);
|
||||
|
||||
const result = await getAccessToken();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when accessToken is undefined on user object", async (): Promise<void> => {
|
||||
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: {
|
||||
@@ -292,11 +309,25 @@ describe("getAccessToken", (): void => {
|
||||
// no accessToken property
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
} 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -310,7 +341,7 @@ describe("isAdmin", (): void => {
|
||||
});
|
||||
|
||||
it("should return false when no session exists", async (): Promise<void> => {
|
||||
vi.mocked(getSession).mockResolvedValueOnce({ data: null } as any);
|
||||
vi.mocked(getSession).mockResolvedValueOnce({ data: null } as MockSessionData);
|
||||
|
||||
const result = await isAdmin();
|
||||
|
||||
@@ -325,7 +356,7 @@ describe("isAdmin", (): void => {
|
||||
isAdmin: true,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
} as MockSessionData);
|
||||
|
||||
const result = await isAdmin();
|
||||
|
||||
@@ -340,7 +371,7 @@ describe("isAdmin", (): void => {
|
||||
isAdmin: false,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
} as MockSessionData);
|
||||
|
||||
const result = await isAdmin();
|
||||
|
||||
@@ -355,10 +386,22 @@ describe("isAdmin", (): void => {
|
||||
// no isAdmin property
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
} 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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user