2026-04-10 17:41:19 +01:00
|
|
|
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
|
|
|
|
import { springAuth } from "@app/auth/springAuthClient";
|
|
|
|
|
import { startOAuthNavigation } from "@app/extensions/oauthNavigation";
|
|
|
|
|
import apiClient from "@app/services/apiClient";
|
|
|
|
|
import { AxiosError, type AxiosResponse, type InternalAxiosRequestConfig } from "axios";
|
2025-11-20 20:51:53 +00:00
|
|
|
|
|
|
|
|
// Mock apiClient
|
2026-04-10 17:41:19 +01:00
|
|
|
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
|
|
|
|
2026-04-10 17:41:19 +01: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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-10 17:41:19 +01:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-10 17:41:19 +01:00
|
|
|
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 = {
|
2026-04-10 17:41:19 +01:00
|
|
|
id: "123",
|
|
|
|
|
email: "test@example.com",
|
|
|
|
|
username: "testuser",
|
|
|
|
|
role: "USER",
|
2025-11-20 20:51:53 +00:00
|
|
|
};
|
|
|
|
|
|
2026-04-10 17:41:19 +01: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();
|
|
|
|
|
|
2026-04-10 17:41:19 +01:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-10 17:41:19 +01:00
|
|
|
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-10 17:41:19 +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();
|
|
|
|
|
|
2026-04-10 17:41:19 +01:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-10 17:41:19 +01:00
|
|
|
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-10 17:41:19 +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();
|
|
|
|
|
|
2026-04-10 17:41:19 +01:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-10 17:41:19 +01:00
|
|
|
describe("signInWithPassword", () => {
|
|
|
|
|
it("should successfully sign in with email and password", async () => {
|
2025-11-20 20:51:53 +00:00
|
|
|
const credentials = {
|
2026-04-10 17:41:19 +01:00
|
|
|
email: "test@example.com",
|
|
|
|
|
password: "password123",
|
2025-11-20 20:51:53 +00:00
|
|
|
};
|
|
|
|
|
|
2026-04-10 17:41:19 +01:00
|
|
|
const mockToken = "new-jwt-token";
|
2025-11-20 20:51:53 +00:00
|
|
|
const mockUser = {
|
2026-04-10 17:41:19 +01:00
|
|
|
id: "123",
|
2025-11-20 20:51:53 +00:00
|
|
|
email: credentials.email,
|
|
|
|
|
username: credentials.email,
|
2026-04-10 17:41:19 +01:00
|
|
|
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
|
2026-04-10 17:41:19 +01:00
|
|
|
const dispatchEventSpy = vi.spyOn(window, "dispatchEvent");
|
2025-11-20 20:51:53 +00:00
|
|
|
|
|
|
|
|
const result = await springAuth.signInWithPassword(credentials);
|
|
|
|
|
|
|
|
|
|
expect(apiClient.post).toHaveBeenCalledWith(
|
2026-04-10 17:41:19 +01:00
|
|
|
"/api/v1/auth/login",
|
2025-11-20 20:51:53 +00:00
|
|
|
{
|
|
|
|
|
username: credentials.email,
|
|
|
|
|
password: credentials.password,
|
|
|
|
|
},
|
2026-04-10 17:41:19 +01:00
|
|
|
{ withCredentials: true },
|
2025-11-20 20:51:53 +00:00
|
|
|
);
|
2026-04-10 17:41:19 +01:00
|
|
|
expect(localStorage.getItem("stirling_jwt")).toBe(mockToken);
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-10 17:41:19 +01:00
|
|
|
it("should return error on failed login", async () => {
|
2025-11-20 20:51:53 +00:00
|
|
|
const credentials = {
|
2026-04-10 17:41:19 +01:00
|
|
|
email: "wrong@example.com",
|
|
|
|
|
password: "wrongpassword",
|
2025-11-20 20:51:53 +00:00
|
|
|
};
|
|
|
|
|
|
2026-04-10 17:41:19 +01: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);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-10 17:41:19 +01:00
|
|
|
describe("signUp", () => {
|
|
|
|
|
it("should successfully register new user", async () => {
|
2025-11-20 20:51:53 +00:00
|
|
|
const credentials = {
|
2026-04-10 17:41:19 +01:00
|
|
|
email: "newuser@example.com",
|
|
|
|
|
password: "newpassword123",
|
2025-11-20 20:51:53 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const mockUser = {
|
2026-04-10 17:41:19 +01:00
|
|
|
id: "456",
|
2025-11-20 20:51:53 +00:00
|
|
|
email: credentials.email,
|
|
|
|
|
username: credentials.email,
|
2026-04-10 17:41:19 +01:00
|
|
|
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(
|
2026-04-10 17:41:19 +01:00
|
|
|
"/api/v1/user/register",
|
2025-11-20 20:51:53 +00:00
|
|
|
{
|
|
|
|
|
username: credentials.email,
|
|
|
|
|
password: credentials.password,
|
|
|
|
|
},
|
2026-04-10 17:41:19 +01:00
|
|
|
{ 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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-10 17:41:19 +01:00
|
|
|
it("should return error on failed registration", async () => {
|
2025-11-20 20:51:53 +00:00
|
|
|
const credentials = {
|
2026-04-10 17:41:19 +01:00
|
|
|
email: "existing@example.com",
|
|
|
|
|
password: "password123",
|
2025-11-20 20:51:53 +00:00
|
|
|
};
|
|
|
|
|
|
2026-04-10 17:41:19 +01: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);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-10 17:41:19 +01:00
|
|
|
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(
|
2026-04-10 17:41:19 +01:00
|
|
|
"/api/v1/auth/logout",
|
2025-11-20 20:51:53 +00:00
|
|
|
null,
|
2026-04-10 17:41:19 +01:00
|
|
|
expect.objectContaining({ withCredentials: true }),
|
2025-11-20 20:51:53 +00:00
|
|
|
);
|
2026-04-10 17:41:19 +01:00
|
|
|
expect(localStorage.getItem("stirling_jwt")).toBeNull();
|
2025-11-20 20:51:53 +00:00
|
|
|
expect(result.error).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-10 17:41:19 +01:00
|
|
|
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 },
|
2026-04-10 17:41:19 +01:00
|
|
|
message: "Server error",
|
2025-11-20 20:51:53 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = await springAuth.signOut();
|
|
|
|
|
|
2026-04-10 17:41:19 +01:00
|
|
|
expect(localStorage.getItem("stirling_jwt")).toBeNull();
|
2025-11-20 20:51:53 +00:00
|
|
|
expect(result.error).toBeTruthy();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-10 17:41:19 +01:00
|
|
|
describe("refreshSession", () => {
|
|
|
|
|
it("should refresh JWT token successfully", async () => {
|
|
|
|
|
const newToken = "refreshed-jwt-token";
|
2025-11-20 20:51:53 +00:00
|
|
|
const mockUser = {
|
2026-04-10 17:41:19 +01:00
|
|
|
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();
|
|
|
|
|
|
2026-04-10 17:41:19 +01:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-10 17:41:19 +01:00
|
|
|
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 },
|
2026-04-10 17:41:19 +01:00
|
|
|
message: "Token expired",
|
2025-11-20 20:51:53 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = await springAuth.refreshSession();
|
|
|
|
|
|
2026-04-10 17:41:19 +01:00
|
|
|
expect(localStorage.getItem("stirling_jwt")).toBeNull();
|
2025-11-20 20:51:53 +00:00
|
|
|
expect(result.data.session).toBeNull();
|
|
|
|
|
expect(result.error).toBeTruthy();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-10 17:41:19 +01:00
|
|
|
describe("signInWithOAuth", () => {
|
|
|
|
|
it("should redirect to OAuth provider", async () => {
|
2025-11-20 20:51:53 +00:00
|
|
|
const mockAssign = vi.fn();
|
2026-04-10 17:41:19 +01:00
|
|
|
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({
|
2026-04-10 17:41:19 +01:00
|
|
|
provider: "/oauth2/authorization/github",
|
|
|
|
|
options: { redirectTo: "/auth/callback" },
|
2025-11-20 20:51:53 +00:00
|
|
|
});
|
|
|
|
|
|
2026-04-10 17:41:19 +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
|
|
|
|
2026-04-10 17:41:19 +01:00
|
|
|
it("should skip redirect when handled by extension", async () => {
|
2026-01-09 18:21:16 +00:00
|
|
|
const mockAssign = vi.fn();
|
2026-04-10 17:41:19 +01:00
|
|
|
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({
|
2026-04-10 17:41:19 +01:00
|
|
|
provider: "/oauth2/authorization/github",
|
|
|
|
|
options: { redirectTo: "/auth/callback" },
|
2026-01-09 18:21:16 +00:00
|
|
|
});
|
|
|
|
|
|
2026-04-10 17:41:19 +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
|
|
|
});
|
|
|
|
|
});
|