// @vitest-environment node import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const fetchMock = vi.fn(); vi.mock("server-only", () => ({})); import { UserServiceAuthError, authenticateWithUserService, } from "@/lib/auth/user-service"; const validProfile = { id: 42, login: "alice", full_name: "Alice Smith", position: "Engineer", email: "alice@example.com", last_name: "Smith", first_name: "Alice", middle_name: "Jane", roles: ["admin", "employee"], is_active: true, created_at: "2026-07-01T12:00:00.000Z", updated_at: "2026-07-02T12:00:00.000Z", }; beforeEach(() => { vi.stubEnv("USER_SERVICE_URL", "http://user-service:8080/"); fetchMock.mockReset(); vi.stubGlobal("fetch", fetchMock); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllEnvs(); }); describe("user-service authentication", () => { it("posts identifier and password to the configured user-service", async () => { fetchMock.mockResolvedValue(Response.json(validProfile)); await expect( authenticateWithUserService("alice@example.com", "secret"), ).resolves.toMatchObject({ id: "42", login: "alice", fullName: "Alice Smith", roles: ["admin", "employee"], }); expect(fetchMock).toHaveBeenCalledWith( "http://user-service:8080/api/user/auth", expect.objectContaining({ method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json", }, body: JSON.stringify({ identifier: "alice@example.com", password: "secret", }), cache: "no-store", }), ); }); it("rejects upstream IDs that cannot be represented exactly", async () => { fetchMock.mockResolvedValue( Response.json({ ...validProfile, id: Number.MAX_SAFE_INTEGER + 1 }), ); await expect( authenticateWithUserService("alice", "secret"), ).rejects.toMatchObject({ reason: "unavailable" }); }); it("rejects upstream invalid credentials without exposing upstream details", async () => { fetchMock.mockResolvedValue(new Response(null, { status: 401 })); await expect( authenticateWithUserService("alice", "wrong-password"), ).rejects.toMatchObject({ name: "UserServiceAuthError", reason: "invalid_credentials", } satisfies Partial); }); it("treats inactive and malformed upstream profiles as unavailable", async () => { fetchMock .mockResolvedValueOnce(Response.json({ ...validProfile, is_active: false })) .mockResolvedValueOnce(Response.json({ ...validProfile, roles: ["admin", 1] })); await expect( authenticateWithUserService("alice", "secret"), ).rejects.toMatchObject({ reason: "unavailable" }); await expect( authenticateWithUserService("alice", "secret"), ).rejects.toMatchObject({ reason: "unavailable" }); }); it("maps transport and server failures to unavailable", async () => { fetchMock .mockRejectedValueOnce(new Error("network unavailable")) .mockResolvedValueOnce(new Response(null, { status: 500 })); await expect( authenticateWithUserService("alice", "secret"), ).rejects.toMatchObject({ reason: "unavailable" }); await expect( authenticateWithUserService("alice", "secret"), ).rejects.toMatchObject({ reason: "unavailable" }); }); it("limits an upstream request to five seconds", async () => { const timeoutController = new AbortController(); const timeout = vi .spyOn(AbortSignal, "timeout") .mockReturnValue(timeoutController.signal); fetchMock.mockImplementation( (_input: RequestInfo | URL, init?: RequestInit) => new Promise((_resolve, reject) => { init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true, }); }), ); const authentication = authenticateWithUserService("alice", "secret"); expect(timeout).toHaveBeenCalledWith(5_000); timeoutController.abort(); await expect(authentication).rejects.toMatchObject({ reason: "unavailable" }); }); });