aegida-console / lib / auth / user-service.test.ts
user-service.test.ts
Raw
// @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" });
  });
});