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"]
}