276 lines
7.7 KiB
TypeScript
276 lines
7.7 KiB
TypeScript
import { act, renderHook } from "@testing-library/react";
|
|
import { HttpResponse, http } from "msw";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { mockMasterKey } from "../../test/fixtures/auth.fixture";
|
|
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";
|
|
|
|
vi.mock("../utils/keystore");
|
|
vi.mock("../utils/crypto");
|
|
|
|
const VITE_API_URL = "http://piku-server";
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
|
|
vi.mocked(loadMasterKey).mockResolvedValue(mockMasterKey);
|
|
vi.mocked(saveMasterKey).mockResolvedValue("masterKey");
|
|
vi.mocked(clearMasterKey).mockResolvedValue(undefined);
|
|
|
|
useAuthStore.setState({
|
|
accessToken: null,
|
|
user: null,
|
|
isInitializing: true,
|
|
});
|
|
useKeyStore.setState({ masterKey: null });
|
|
|
|
vi.mocked(CryptoUtils.deriveKeyBundle).mockResolvedValue({
|
|
masterKey: mockMasterKey,
|
|
authHash: "mock-hash",
|
|
});
|
|
});
|
|
|
|
describe("isAuthenticated", () => {
|
|
it("should be false when the access token is missing from the store", () => {
|
|
const { result } = renderHook(() => useAuth());
|
|
|
|
expect(result.current.isAuthenticated).toBe(false);
|
|
});
|
|
|
|
it("should be true when the 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("setAuthStore", () => {
|
|
it("should persist the provided master key to IndexedDB", async () => {
|
|
const { result } = renderHook(() => useAuth());
|
|
|
|
await act(async () => {
|
|
await result.current.setAuthStore(
|
|
"access-token",
|
|
mockUser,
|
|
mockMasterKey,
|
|
);
|
|
});
|
|
|
|
expect(saveMasterKey).toHaveBeenCalledWith(mockMasterKey);
|
|
});
|
|
|
|
it("should update the store with the access token and user profile", async () => {
|
|
const { result } = renderHook(() => useAuth());
|
|
|
|
await act(async () => {
|
|
await result.current.setAuthStore(
|
|
"my-access-token",
|
|
mockUser,
|
|
mockMasterKey,
|
|
);
|
|
});
|
|
|
|
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.setAuthStore("token", mockUser, mockMasterKey);
|
|
});
|
|
|
|
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(`${VITE_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 the 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 the auth store even if the API call fails", async () => {
|
|
server.use(
|
|
http.post(
|
|
`${VITE_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(`${VITE_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 and restore the master key when the session is empty", 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 preserve the master key even if the refresh attempt fails", async () => {
|
|
server.use(
|
|
http.post(
|
|
`${VITE_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).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("unlock", () => {
|
|
beforeEach(() => {
|
|
useAuthStore.setState({
|
|
accessToken: "valid-token",
|
|
user: mockUser,
|
|
isInitializing: false,
|
|
});
|
|
});
|
|
|
|
it("should derive the master key from the user password, validate it via API, and persist it", async () => {
|
|
let loginCalled = false;
|
|
server.use(
|
|
http.post(`${VITE_API_URL}/api/auth/login/`, async () => {
|
|
loginCalled = true;
|
|
return HttpResponse.json({ access: "token", user: mockUser });
|
|
}),
|
|
);
|
|
const { result } = renderHook(() => useAuth());
|
|
|
|
await act(async () => {
|
|
await result.current.unlock("password");
|
|
});
|
|
|
|
expect(CryptoUtils.deriveKeyBundle).toHaveBeenCalledWith(
|
|
"password",
|
|
mockUser.email,
|
|
);
|
|
expect(loginCalled).toBe(true);
|
|
expect(saveMasterKey).toHaveBeenCalledWith(mockMasterKey);
|
|
expect(useKeyStore.getState().masterKey).toEqual(mockMasterKey);
|
|
});
|
|
|
|
it("should logout if user is not present", async () => {
|
|
useAuthStore.setState({ user: null });
|
|
const { result } = renderHook(() => useAuth());
|
|
|
|
await act(async () => {
|
|
await result.current.unlock("password");
|
|
});
|
|
|
|
expect(CryptoUtils.deriveKeyBundle).not.toHaveBeenCalled();
|
|
expect(saveMasterKey).not.toHaveBeenCalled();
|
|
expect(useAuthStore.getState().accessToken).toBeNull();
|
|
expect(clearMasterKey).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should throw an error and not persist the key if validation fails", async () => {
|
|
server.use(
|
|
http.post(
|
|
`${VITE_API_URL}/api/auth/login/`,
|
|
() => new HttpResponse(null, { status: 400 }),
|
|
),
|
|
);
|
|
|
|
const { result } = renderHook(() => useAuth());
|
|
|
|
await act(async () => {
|
|
await expect(result.current.unlock("wrong-password")).rejects.toThrow();
|
|
});
|
|
|
|
expect(saveMasterKey).not.toHaveBeenCalled();
|
|
expect(useKeyStore.getState().masterKey).toBeNull();
|
|
});
|
|
});
|