// @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<UserServiceAuthError>);
});
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" });
});
});