diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..4229c76 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -22,3 +22,6 @@ dist-ssr *.njsproj *.sln *.sw? + +# Test +coverage/ diff --git a/frontend/src/api/apiClient.test.ts b/frontend/src/api/apiClient.test.ts new file mode 100644 index 0000000..a11a507 --- /dev/null +++ b/frontend/src/api/apiClient.test.ts @@ -0,0 +1,143 @@ +import { HttpResponse, http } from "msw"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { mockUser } from "../../test/fixtures/user.fixture"; +import { server } from "../../test/mocks/server"; +import { useAuthStore } from "../store/useAuthStore"; +import { api } from "./apiClient"; + +const API_URL = "http://piku-server"; + +beforeEach(() => { + useAuthStore.setState({ + accessToken: null, + user: null, + isInitializing: false, + }); +}); + +beforeAll(() => { + vi.stubEnv("API_URL", API_URL); +}); + +afterAll(() => { + vi.unstubAllEnvs(); +}); + +describe("request interceptor", () => { + it("should attach Bearer token from the auth store to outgoing requests", async () => { + useAuthStore.getState().setAuth("my-token", mockUser); + + let capturedAuthHeader = ""; + server.use( + http.get(`${API_URL}/api/auth/me/`, ({ request }) => { + capturedAuthHeader = request.headers.get("Authorization") ?? ""; + return HttpResponse.json(mockUser); + }), + ); + + await api.get("/api/auth/me/"); + + expect(capturedAuthHeader).toBe("Bearer my-token"); + }); + + it("should not send Authorization header when the store has no token", async () => { + let capturedAuthHeader: string | null; + server.use( + http.get(`${API_URL}/api/auth/me/`, ({ request }) => { + capturedAuthHeader = request.headers.get("Authorization"); + return HttpResponse.json({}); + }), + ); + + await api.get("/api/auth/me/"); + + expect(capturedAuthHeader).toBeNull(); + }); +}); + +describe("response interceptor", () => { + it("should call /refresh once on 401, then retry the original request with the new token", async () => { + useAuthStore.getState().setAuth("expired-token", mockUser); + let meApiCallCount = 0; + let _refreshApiCallCount = 0; + + server.use( + http.get(`${API_URL}/api/auth/me/`, ({ request }) => { + meApiCallCount++; + if (request.headers.get("Authorization") === "Bearer expired-token") { + return new HttpResponse(null, { status: 401 }); + } + return HttpResponse.json(mockUser); + }), + http.post(`${API_URL}/api/auth/refresh/`, () => { + _refreshApiCallCount++; + return HttpResponse.json({ access: "refreshed-token" }); + }), + ); + + const response = await api.get("/api/auth/me/"); + + expect(_refreshApiCallCount).toBe(1); + expect(meApiCallCount).toBe(2); + expect(response.data).toEqual(mockUser); + }); + + it("should update the auth store access token after a successful refresh", async () => { + useAuthStore.getState().setAuth("expired-token", mockUser); + + server.use( + http.get(`${API_URL}/api/auth/me/`, ({ request }) => { + if (request.headers.get("Authorization") === "Bearer expired-token") { + return new HttpResponse(null, { status: 401 }); + } + return HttpResponse.json(mockUser); + }), + http.post(`${API_URL}/api/auth/refresh/`, () => + HttpResponse.json({ access: "refreshed-token" }), + ), + ); + + await api.get("/api/auth/me/"); + + expect(useAuthStore.getState().accessToken).toBe("refreshed-token"); + }); + + it("should call clearAuth and return the latest error when refresh also fails", async () => { + useAuthStore.getState().setAuth("expired-token", mockUser); + + server.use( + http.get( + `${API_URL}/api/auth/me/`, + () => + new HttpResponse(JSON.stringify({ detail: "Invalid token" }), { + status: 401, + }), + ), + http.post( + `${API_URL}/api/auth/refresh/`, + () => + new HttpResponse(JSON.stringify({ detail: "Refresh failed" }), { + status: 401, + }), + ), + ); + + await expect(api.get("/api/auth/me/")).rejects.toThrow( + expect.objectContaining({ + response: expect.objectContaining({ + data: { detail: "Refresh failed" }, + }), + }), + ); + expect(useAuthStore.getState().accessToken).toBeNull(); + expect(useAuthStore.getState().user).toBeNull(); + }); +}); diff --git a/frontend/src/components/RouteGuards.test.tsx b/frontend/src/components/RouteGuards.test.tsx new file mode 100644 index 0000000..ca2e76d --- /dev/null +++ b/frontend/src/components/RouteGuards.test.tsx @@ -0,0 +1,127 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { beforeEach, describe, expect, it } from "vitest"; +import { mockUser } from "../../test/fixtures/user.fixture"; +import { useAuthStore } from "../store/useAuthStore"; +import { ProtectedRoute, PublicRoute } from "./RouteGuards"; + +function renderGuard(ui: React.ReactNode, mountPath: "/protected" | "/public") { + return render( + + + Login Page} /> + Drawer Page} /> + + + + , + ); +} + +beforeEach(() => { + useAuthStore.setState({ + accessToken: null, + user: null, + isInitializing: true, + }); +}); + +describe("ProtectedRoute", () => { + it("should show SplashScreen while auth is initializing", () => { + useAuthStore.setState({ + isInitializing: true, + accessToken: null, + user: null, + }); + renderGuard( + +
Secret
+
, + "/protected", + ); + + expect(screen.getByText(/Initializing Identity/i)).toBeInTheDocument(); + expect(screen.queryByText("Secret")).not.toBeInTheDocument(); + }); + + it("should redirect unauthenticated users to /login", () => { + useAuthStore.setState({ + isInitializing: false, + accessToken: null, + user: null, + }); + renderGuard( + +
Secret
+
, + "/protected", + ); + expect(screen.getByText("Login Page")).toBeInTheDocument(); + expect(screen.queryByText("Secret")).not.toBeInTheDocument(); + }); + + it("should render page for authenticated users", () => { + useAuthStore.setState({ + isInitializing: false, + accessToken: "token", + user: mockUser, + }); + renderGuard( + +
Secret
+
, + "/protected", + ); + + expect(screen.getByText("Secret")).toBeInTheDocument(); + }); +}); + +describe("PublicRoute", () => { + it("should show SplashScreen while auth is initializing", () => { + useAuthStore.setState({ + isInitializing: true, + accessToken: null, + user: null, + }); + renderGuard( + +
Login Page
+
, + "/public", + ); + expect(screen.getByText(/Initializing Identity/i)).toBeInTheDocument(); + expect(screen.queryByText("Login Page")).not.toBeInTheDocument(); + }); + + it("should redirect authenticated users to /drawer", () => { + useAuthStore.setState({ + isInitializing: false, + accessToken: "token", + user: mockUser, + }); + renderGuard( + +
Login Form
+
, + "/public", + ); + expect(screen.getByText("Drawer Page")).toBeInTheDocument(); + expect(screen.queryByText("Login Form")).not.toBeInTheDocument(); + }); + + it("should render page for unauthenticated users", () => { + useAuthStore.setState({ + isInitializing: false, + accessToken: null, + user: null, + }); + renderGuard( + +
Login Form
+
, + "/public", + ); + expect(screen.getByText("Login Form")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/hooks/useAuth.test.ts b/frontend/src/hooks/useAuth.test.ts new file mode 100644 index 0000000..82a3c5f --- /dev/null +++ b/frontend/src/hooks/useAuth.test.ts @@ -0,0 +1,233 @@ +import { act, renderHook } from "@testing-library/react"; +import { HttpResponse, http } from "msw"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { mockUser } from "../../test/fixtures/user.fixture"; +import { server } from "../../test/mocks/server"; +import { useAuthStore } from "../store/useAuthStore"; +import { useKeyStore } from "../store/useKeyStore"; +import { CryptoUtils } from "../utils/crypto"; +import { + clearMasterKey, + loadMasterKey, + saveMasterKey, +} from "../utils/keystore"; +import { useAuth } from "./useAuth"; + +const API_URL = "http://piku-server"; + +vi.mock("../utils/crypto", () => ({ + CryptoUtils: { + deriveMasterKey: vi + .fn() + .mockResolvedValue({ type: "secret" } as unknown as CryptoKey), + }, +})); + +vi.mock("../utils/keystore", () => ({ + saveMasterKey: vi.fn().mockResolvedValue(undefined), + loadMasterKey: vi + .fn() + .mockResolvedValue({ type: "secret" } as unknown as CryptoKey), + clearMasterKey: vi.fn().mockResolvedValue(undefined), +})); + +beforeAll(() => { + vi.stubEnv("API_URL", API_URL); +}); + +afterAll(() => { + vi.unstubAllEnvs(); +}); + +beforeEach(() => { + vi.clearAllMocks(); + useAuthStore.setState({ + accessToken: null, + user: null, + isInitializing: true, + }); + useKeyStore.setState({ masterKey: null }); +}); + +describe("isAuthenticated", () => { + it("should be false when access token is not present in the store", () => { + const { result } = renderHook(() => useAuth()); + + expect(result.current.isAuthenticated).toBe(false); + }); + + it("should be true when access token is present in the store", () => { + useAuthStore.setState({ + accessToken: "token", + user: mockUser, + isInitializing: false, + }); + const { result } = renderHook(() => useAuth()); + + expect(result.current.isAuthenticated).toBe(true); + }); +}); + +describe("login", () => { + it("should derive the master key using the provided password and email (salt)", async () => { + const { result } = renderHook(() => useAuth()); + + await act(async () => { + await result.current.login("access-token", mockUser, "test-password"); + }); + + expect(CryptoUtils.deriveMasterKey).toHaveBeenCalledWith( + "test-password", + mockUser.email, + ); + }); + + it("should persist the derived master key to IndexedDB", async () => { + const { result } = renderHook(() => useAuth()); + + await act(async () => { + await result.current.login("access-token", mockUser, "my-password"); + }); + + expect(saveMasterKey).toHaveBeenCalledTimes(1); + }); + + it("should set the auth store with the provided access token and user profile", async () => { + const { result } = renderHook(() => useAuth()); + + await act(async () => { + await result.current.login("my-access-token", mockUser, "my-password"); + }); + + expect(useAuthStore.getState().accessToken).toBe("my-access-token"); + expect(useAuthStore.getState().user).toEqual(mockUser); + }); + + it("should load the master key into the key store", async () => { + const { result } = renderHook(() => useAuth()); + + await act(async () => { + await result.current.login("token", mockUser, "my-password"); + }); + + expect(useKeyStore.getState().masterKey).not.toBeNull(); + }); +}); + +describe("logout", () => { + beforeEach(() => { + useAuthStore.setState({ + accessToken: "active-token", + user: mockUser, + isInitializing: false, + }); + }); + + it("should call the logout API endpoint", async () => { + let logoutCalled = false; + server.use( + http.post(`${API_URL}/api/auth/logout/`, () => { + logoutCalled = true; + return HttpResponse.json({}); + }), + ); + + const { result } = renderHook(() => useAuth()); + await act(async () => { + await result.current.logout(); + }); + expect(logoutCalled).toBe(true); + }); + + it("should clear the master key from both the key store and IndexedDB", async () => { + const { result } = renderHook(() => useAuth()); + + await act(async () => { + await result.current.logout(); + }); + + expect(useKeyStore.getState().masterKey).toBeNull(); + expect(clearMasterKey).toHaveBeenCalledTimes(1); + }); + + it("should clear auth store (access token + user) and master key even if API fails", async () => { + server.use( + http.post( + `${API_URL}/api/auth/logout/`, + () => new HttpResponse(null, { status: 500 }), + ), + ); + + const { result } = renderHook(() => useAuth()); + await act(async () => { + await result.current.logout(); + }); + + expect(useAuthStore.getState().accessToken).toBeNull(); + expect(useAuthStore.getState().user).toBeNull(); + }); +}); + +describe("initialize", () => { + it("should skip the refresh call when a session is already in memory", async () => { + useAuthStore.setState({ + accessToken: "live-token", + user: mockUser, + isInitializing: true, + }); + let refreshCalled = false; + server.use( + http.post(`${API_URL}/api/auth/refresh/`, () => { + refreshCalled = true; + return HttpResponse.json({ access: "new-token" }); + }), + ); + + const { result } = renderHook(() => useAuth()); + await act(async () => { + await result.current.initialize(); + }); + + expect(refreshCalled).toBe(false); + expect(useAuthStore.getState().isInitializing).toBe(false); + }); + + it("should call /refresh restore master key from IndexedDB when session not in memory", async () => { + const { result } = renderHook(() => useAuth()); + + await act(async () => { + await result.current.initialize(); + }); + + expect(useAuthStore.getState().accessToken).toBe("new-access-token"); + expect(useAuthStore.getState().user).toEqual(mockUser); + expect(loadMasterKey).toHaveBeenCalledTimes(1); + expect(useKeyStore.getState().masterKey).not.toBeNull(); + }); + + it("should clear auth + key store when refresh fails", async () => { + server.use( + http.post( + `${API_URL}/api/auth/refresh/`, + () => new HttpResponse(null, { status: 401 }), + ), + ); + const { result } = renderHook(() => useAuth()); + + await act(async () => { + await result.current.initialize(); + }); + + expect(useAuthStore.getState().accessToken).toBeNull(); + expect(useAuthStore.getState().user).toBeNull(); + expect(useKeyStore.getState().masterKey).toBeNull(); + }); +}); diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 61e8cab..8425e25 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -36,6 +36,8 @@ export const useAuth = () => { const logout = async () => { try { await api.post(endpoints.LOGOUT); + } catch (error) { + console.error("Logout failed:", error); } finally { clearAuth(); setMasterKey(null); diff --git a/frontend/test/mocks/server.ts b/frontend/test/mocks/server.ts index 74d8553..f93a9c4 100644 --- a/frontend/test/mocks/server.ts +++ b/frontend/test/mocks/server.ts @@ -2,31 +2,27 @@ import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { mockUser } from "../fixtures/user.fixture"; -const apiServerHost = "http://piku-server"; +const API_URL = "http://piku-server"; export const successHandlers = [ - http.post(`http://${apiServerHost}/api/auth/login/`, () => + http.post(`${API_URL}/api/auth/login/`, () => HttpResponse.json({ access: "mock-access-token" }), ), - http.post(`http://${apiServerHost}/api/auth/refresh/`, () => + http.post(`${API_URL}/api/auth/refresh/`, () => HttpResponse.json({ access: "new-access-token" }), ), - http.get(`http://${apiServerHost}/api/auth/me/`, () => - HttpResponse.json(mockUser), - ), - http.post(`http://${apiServerHost}/api/auth/logout/`, () => - HttpResponse.json({}), - ), + http.get(`${API_URL}/api/auth/me/`, () => HttpResponse.json(mockUser)), + http.post(`${API_URL}/api/auth/logout/`, () => HttpResponse.json({})), ]; export const errorHandlers = [ - http.post(`http://${apiServerHost}/api/auth/login/`, () => + http.post(`${API_URL}/api/auth/login/`, () => HttpResponse.json({ error: "Invalid credentials" }, { status: 400 }), ), - http.post(`http://${apiServerHost}/api/auth/refresh/`, () => + http.post(`${API_URL}/api/auth/refresh/`, () => HttpResponse.json({ error: "Invalid Token" }, { status: 401 }), ), - http.get(`http://${apiServerHost}/api/auth/me/`, () => + http.get(`${API_URL}/api/auth/me/`, () => HttpResponse.json({ error: "Invalid Token" }, { status: 401 }), ), ]; diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index 1d29c88..7701107 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -21,5 +21,5 @@ "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src", "test"] }