Files
Stirling-PDF/frontend/src/proprietary/auth/springAuthClient.test.ts
T

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

483 lines
15 KiB
TypeScript
Raw Normal View History

import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
2026-04-23 14:52:25 +01:00
import {
consumePostLoginRedirectPath,
isSafePostLoginRedirect,
POST_LOGIN_REDIRECT_STORAGE_KEY,
setPostLoginRedirectPath,
springAuth,
} from "@app/auth/springAuthClient";
import { startOAuthNavigation } from "@app/extensions/oauthNavigation";
import apiClient from "@app/services/apiClient";
2026-04-17 10:50:16 +01:00
import {
AxiosError,
type AxiosResponse,
type InternalAxiosRequestConfig,
} from "axios";
2025-11-20 20:51:53 +00:00
// Mock apiClient
vi.mock("@app/services/apiClient");
vi.mock("@app/extensions/oauthNavigation", () => ({
2026-01-09 18:21:16 +00:00
startOAuthNavigation: vi.fn().mockResolvedValue(false),
}));
2025-11-20 20:51:53 +00:00
describe("SpringAuthClient", () => {
2025-11-20 20:51:53 +00:00
beforeEach(() => {
// Clear localStorage before each test
localStorage.clear();
// Clear all mocks
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("getSession", () => {
it("should return null session when no JWT in localStorage", async () => {
2025-11-20 20:51:53 +00:00
const result = await springAuth.getSession();
expect(result.data.session).toBeNull();
expect(result.error).toBeNull();
expect(apiClient.get).not.toHaveBeenCalled();
});
it("should validate JWT and return session when JWT exists", async () => {
const mockToken = "mock-jwt-token";
2025-11-20 20:51:53 +00:00
const mockUser = {
id: "123",
email: "test@example.com",
username: "testuser",
role: "USER",
2025-11-20 20:51:53 +00:00
};
localStorage.setItem("stirling_jwt", mockToken);
2025-11-20 20:51:53 +00:00
vi.mocked(apiClient.get).mockResolvedValueOnce({
status: 200,
data: { user: mockUser },
2026-04-01 09:21:26 +01:00
} as unknown as AxiosResponse);
2025-11-20 20:51:53 +00:00
const result = await springAuth.getSession();
expect(apiClient.get).toHaveBeenCalledWith("/api/v1/auth/me", {
2025-11-20 20:51:53 +00:00
headers: { Authorization: `Bearer ${mockToken}` },
suppressErrorToast: true,
2026-02-16 21:57:42 +00:00
skipAuthRedirect: true,
2025-11-20 20:51:53 +00:00
});
expect(result.data.session).toBeTruthy();
expect(result.data.session?.user).toEqual(mockUser);
expect(result.data.session?.access_token).toBe(mockToken);
expect(result.error).toBeNull();
});
it("should clear invalid JWT on 401 error", async () => {
const mockToken = "invalid-jwt-token";
localStorage.setItem("stirling_jwt", mockToken);
2025-11-20 20:51:53 +00:00
2026-04-17 10:50:16 +01:00
const mockError = new AxiosError(
"Unauthorized",
"ERR_BAD_REQUEST",
undefined,
undefined,
{
status: 401,
statusText: "Unauthorized",
data: {},
headers: {},
config: {} as InternalAxiosRequestConfig,
},
);
2025-11-20 20:51:53 +00:00
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
const result = await springAuth.getSession();
expect(localStorage.getItem("stirling_jwt")).toBeNull();
2025-11-20 20:51:53 +00:00
expect(result.data.session).toBeNull();
// 401 is handled gracefully, so error should be null
expect(result.error).toBeNull();
});
it("should clear invalid JWT on 403 error", async () => {
const mockToken = "forbidden-jwt-token";
localStorage.setItem("stirling_jwt", mockToken);
2025-11-20 20:51:53 +00:00
2026-04-17 10:50:16 +01:00
const mockError = new AxiosError(
"Forbidden",
"ERR_BAD_REQUEST",
undefined,
undefined,
{
status: 403,
statusText: "Forbidden",
data: {},
headers: {},
config: {} as InternalAxiosRequestConfig,
},
);
2025-11-20 20:51:53 +00:00
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
const result = await springAuth.getSession();
expect(localStorage.getItem("stirling_jwt")).toBeNull();
2025-11-20 20:51:53 +00:00
expect(result.data.session).toBeNull();
// 403 is handled gracefully, so error should be null
expect(result.error).toBeNull();
});
});
describe("signInWithPassword", () => {
it("should successfully sign in with email and password", async () => {
2025-11-20 20:51:53 +00:00
const credentials = {
email: "test@example.com",
password: "password123",
2025-11-20 20:51:53 +00:00
};
const mockToken = "new-jwt-token";
2025-11-20 20:51:53 +00:00
const mockUser = {
id: "123",
2025-11-20 20:51:53 +00:00
email: credentials.email,
username: credentials.email,
role: "USER",
2025-11-20 20:51:53 +00:00
};
vi.mocked(apiClient.post).mockResolvedValueOnce({
status: 200,
data: {
user: mockUser,
session: {
access_token: mockToken,
expires_in: 3600,
},
},
2026-04-01 09:21:26 +01:00
} as unknown as AxiosResponse);
2025-11-20 20:51:53 +00:00
// Spy on window.dispatchEvent
const dispatchEventSpy = vi.spyOn(window, "dispatchEvent");
2025-11-20 20:51:53 +00:00
const result = await springAuth.signInWithPassword(credentials);
expect(apiClient.post).toHaveBeenCalledWith(
"/api/v1/auth/login",
2025-11-20 20:51:53 +00:00
{
username: credentials.email,
password: credentials.password,
},
{ withCredentials: true },
2025-11-20 20:51:53 +00:00
);
expect(localStorage.getItem("stirling_jwt")).toBe(mockToken);
2026-04-17 10:50:16 +01:00
expect(dispatchEventSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: "jwt-available" }),
);
2025-11-20 20:51:53 +00:00
expect(result.user).toEqual(mockUser);
expect(result.session?.access_token).toBe(mockToken);
expect(result.error).toBeNull();
});
it("should return error on failed login", async () => {
2025-11-20 20:51:53 +00:00
const credentials = {
email: "wrong@example.com",
password: "wrongpassword",
2025-11-20 20:51:53 +00:00
};
const errorMessage = "Invalid credentials";
2025-11-20 20:51:53 +00:00
const mockError = Object.assign(new Error(errorMessage), {
isAxiosError: true,
response: {
status: 401,
data: { message: errorMessage },
},
});
vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
const result = await springAuth.signInWithPassword(credentials);
expect(result.user).toBeNull();
expect(result.session).toBeNull();
expect(result.error).toBeTruthy();
expect(result.error?.message).toBe(errorMessage);
});
});
describe("signUp", () => {
it("should successfully register new user", async () => {
2025-11-20 20:51:53 +00:00
const credentials = {
email: "newuser@example.com",
password: "newpassword123",
2025-11-20 20:51:53 +00:00
};
const mockUser = {
id: "456",
2025-11-20 20:51:53 +00:00
email: credentials.email,
username: credentials.email,
role: "USER",
2025-11-20 20:51:53 +00:00
};
vi.mocked(apiClient.post).mockResolvedValueOnce({
status: 200,
data: { user: mockUser },
2026-04-01 09:21:26 +01:00
} as unknown as AxiosResponse);
2025-11-20 20:51:53 +00:00
const result = await springAuth.signUp(credentials);
expect(apiClient.post).toHaveBeenCalledWith(
"/api/v1/user/register",
2025-11-20 20:51:53 +00:00
{
username: credentials.email,
password: credentials.password,
},
{ withCredentials: true },
2025-11-20 20:51:53 +00:00
);
expect(result.user).toEqual(mockUser);
expect(result.session).toBeNull(); // No auto-login on signup
expect(result.error).toBeNull();
});
it("should return error on failed registration", async () => {
2025-11-20 20:51:53 +00:00
const credentials = {
email: "existing@example.com",
password: "password123",
2025-11-20 20:51:53 +00:00
};
const errorMessage = "User already exists";
2025-11-20 20:51:53 +00:00
const mockError = Object.assign(new Error(errorMessage), {
isAxiosError: true,
response: {
status: 409,
data: { message: errorMessage },
},
});
vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
const result = await springAuth.signUp(credentials);
expect(result.user).toBeNull();
expect(result.session).toBeNull();
expect(result.error).toBeTruthy();
expect(result.error?.message).toBe(errorMessage);
});
});
describe("signOut", () => {
it("should successfully sign out and clear JWT", async () => {
const mockToken = "jwt-to-clear";
localStorage.setItem("stirling_jwt", mockToken);
2025-11-20 20:51:53 +00:00
vi.mocked(apiClient.post).mockResolvedValueOnce({
status: 200,
data: {},
2026-04-01 09:21:26 +01:00
} as unknown as AxiosResponse);
2025-11-20 20:51:53 +00:00
const result = await springAuth.signOut();
expect(apiClient.post).toHaveBeenCalledWith(
"/api/v1/auth/logout",
2025-11-20 20:51:53 +00:00
null,
expect.objectContaining({ withCredentials: true }),
2025-11-20 20:51:53 +00:00
);
expect(localStorage.getItem("stirling_jwt")).toBeNull();
2025-11-20 20:51:53 +00:00
expect(result.error).toBeNull();
});
it("should clear JWT even if logout request fails", async () => {
const mockToken = "jwt-to-clear";
localStorage.setItem("stirling_jwt", mockToken);
2025-11-20 20:51:53 +00:00
vi.mocked(apiClient.post).mockRejectedValueOnce({
isAxiosError: true,
response: { status: 500 },
message: "Server error",
2025-11-20 20:51:53 +00:00
});
const result = await springAuth.signOut();
expect(localStorage.getItem("stirling_jwt")).toBeNull();
2025-11-20 20:51:53 +00:00
expect(result.error).toBeTruthy();
});
});
describe("refreshSession", () => {
it("should refresh JWT token successfully", async () => {
const newToken = "refreshed-jwt-token";
2025-11-20 20:51:53 +00:00
const mockUser = {
id: "123",
email: "test@example.com",
username: "testuser",
role: "USER",
2025-11-20 20:51:53 +00:00
};
vi.mocked(apiClient.post).mockResolvedValueOnce({
status: 200,
data: {
user: mockUser,
session: {
access_token: newToken,
expires_in: 3600,
},
},
2026-04-01 09:21:26 +01:00
} as unknown as AxiosResponse);
2025-11-20 20:51:53 +00:00
const result = await springAuth.refreshSession();
expect(localStorage.getItem("stirling_jwt")).toBe(newToken);
2026-02-16 21:57:42 +00:00
// Note: refreshSession does not dispatch jwt-available event, only notifies listeners
2025-11-20 20:51:53 +00:00
expect(result.data.session?.access_token).toBe(newToken);
expect(result.error).toBeNull();
});
it("should clear JWT and return error on 401", async () => {
localStorage.setItem("stirling_jwt", "expired-token");
2025-11-20 20:51:53 +00:00
vi.mocked(apiClient.post).mockRejectedValueOnce({
isAxiosError: true,
response: { status: 401 },
message: "Token expired",
2025-11-20 20:51:53 +00:00
});
const result = await springAuth.refreshSession();
expect(localStorage.getItem("stirling_jwt")).toBeNull();
2025-11-20 20:51:53 +00:00
expect(result.data.session).toBeNull();
expect(result.error).toBeTruthy();
});
});
describe("signInWithOAuth", () => {
it("should redirect to OAuth provider", async () => {
2025-11-20 20:51:53 +00:00
const mockAssign = vi.fn();
Object.defineProperty(window, "location", {
2025-11-20 20:51:53 +00:00
value: { assign: mockAssign },
writable: true,
});
2026-01-09 18:21:16 +00:00
vi.mocked(startOAuthNavigation).mockResolvedValueOnce(false);
2025-11-20 20:51:53 +00:00
const result = await springAuth.signInWithOAuth({
provider: "/oauth2/authorization/github",
options: { redirectTo: "/auth/callback" },
2025-11-20 20:51:53 +00:00
});
2026-04-17 10:50:16 +01:00
expect(startOAuthNavigation).toHaveBeenCalledWith(
"/oauth2/authorization/github",
);
expect(mockAssign).toHaveBeenCalledWith("/oauth2/authorization/github");
2025-11-20 20:51:53 +00:00
expect(result.error).toBeNull();
});
2026-01-09 18:21:16 +00:00
it("should skip redirect when handled by extension", async () => {
2026-01-09 18:21:16 +00:00
const mockAssign = vi.fn();
Object.defineProperty(window, "location", {
2026-01-09 18:21:16 +00:00
value: { assign: mockAssign },
writable: true,
});
vi.mocked(startOAuthNavigation).mockResolvedValueOnce(true);
const result = await springAuth.signInWithOAuth({
provider: "/oauth2/authorization/github",
options: { redirectTo: "/auth/callback" },
2026-01-09 18:21:16 +00:00
});
2026-04-17 10:50:16 +01:00
expect(startOAuthNavigation).toHaveBeenCalledWith(
"/oauth2/authorization/github",
);
2026-01-09 18:21:16 +00:00
expect(mockAssign).not.toHaveBeenCalled();
expect(result.error).toBeNull();
});
2025-11-20 20:51:53 +00:00
});
2026-04-23 14:52:25 +01:00
describe("post-login redirect path", () => {
beforeEach(() => {
sessionStorage.clear();
});
describe("isSafePostLoginRedirect", () => {
it("accepts same-origin paths with a single leading slash", () => {
expect(isSafePostLoginRedirect("/share/abc123")).toBe(true);
expect(isSafePostLoginRedirect("/workbench")).toBe(true);
expect(isSafePostLoginRedirect("/share/abc?x=1")).toBe(true);
});
it("rejects empty, null, or non-string values", () => {
expect(isSafePostLoginRedirect("")).toBe(false);
expect(isSafePostLoginRedirect(null)).toBe(false);
expect(isSafePostLoginRedirect(undefined)).toBe(false);
expect(isSafePostLoginRedirect(42 as unknown)).toBe(false);
});
it("rejects protocol-relative and absolute URLs (open-redirect guard)", () => {
expect(isSafePostLoginRedirect("//evil.example")).toBe(false);
expect(isSafePostLoginRedirect("http://evil.example")).toBe(false);
expect(isSafePostLoginRedirect("https://evil.example/x")).toBe(false);
expect(isSafePostLoginRedirect("/\\evil")).toBe(false);
});
it("rejects auth-plumbing paths to avoid login loops", () => {
expect(isSafePostLoginRedirect("/login")).toBe(false);
expect(isSafePostLoginRedirect("/login?foo=1")).toBe(false);
expect(isSafePostLoginRedirect("/auth/callback")).toBe(false);
expect(isSafePostLoginRedirect("/oauth2/authorization/google")).toBe(
false,
);
expect(isSafePostLoginRedirect("/saml2/authenticate/x")).toBe(false);
});
});
describe("setPostLoginRedirectPath", () => {
it("stores a safe path in sessionStorage", () => {
setPostLoginRedirectPath("/share/abc123");
expect(sessionStorage.getItem(POST_LOGIN_REDIRECT_STORAGE_KEY)).toBe(
"/share/abc123",
);
});
it("clears any existing entry when given an unsafe value", () => {
sessionStorage.setItem(POST_LOGIN_REDIRECT_STORAGE_KEY, "/share/old");
setPostLoginRedirectPath("//evil.example");
expect(
sessionStorage.getItem(POST_LOGIN_REDIRECT_STORAGE_KEY),
).toBeNull();
});
it("clears any existing entry when given null", () => {
sessionStorage.setItem(POST_LOGIN_REDIRECT_STORAGE_KEY, "/share/old");
setPostLoginRedirectPath(null);
expect(
sessionStorage.getItem(POST_LOGIN_REDIRECT_STORAGE_KEY),
).toBeNull();
});
});
describe("consumePostLoginRedirectPath", () => {
it("returns the stored path and clears it (single-use)", () => {
sessionStorage.setItem(
POST_LOGIN_REDIRECT_STORAGE_KEY,
"/share/abc123",
);
expect(consumePostLoginRedirectPath()).toBe("/share/abc123");
expect(
sessionStorage.getItem(POST_LOGIN_REDIRECT_STORAGE_KEY),
).toBeNull();
});
it("returns null (and still clears) when the stored value is unsafe", () => {
sessionStorage.setItem(
POST_LOGIN_REDIRECT_STORAGE_KEY,
"//evil.example",
);
expect(consumePostLoginRedirectPath()).toBeNull();
expect(
sessionStorage.getItem(POST_LOGIN_REDIRECT_STORAGE_KEY),
).toBeNull();
});
it("returns null when nothing is stored", () => {
expect(consumePostLoginRedirectPath()).toBeNull();
});
});
});
2025-11-20 20:51:53 +00:00
});