Files
apf_portal/libs/feature/auth/src/lib/auth.service.spec.ts
T
Julien Gautier 4a707cdb0f
CI / scan (pull_request) Successful in 3m22s
CI / commits (pull_request) Successful in 3m43s
CI / check (pull_request) Successful in 3m55s
CI / a11y (pull_request) Successful in 2m21s
CI / perf (pull_request) Successful in 7m1s
feat(portal-admin): spa auth wiring + admin shell skeleton
Phase-3a step per ADR-0020 §"Confirmation" item 4 (entry route +
admin shell). Wires the existing `feature-auth` library against the
distinct admin OIDC routes (`/api/admin/auth/*`) the BFF exposes
since PR #129, ships a lean header/sidebar/footer chrome with an
"Admin" badge so an internal user can never mistake the surface for
portal-shell, and gives the landing page a self-test panel that
confirms the auth chain end-to-end as soon as an `admin` Entra role
gets assigned.

Lib changes (libs/feature/auth)

- New AUTH_PATH_PREFIX injection token. Default factory returns
  `/auth` so portal-shell-shaped hosts keep working without an
  explicit provider. Admin hosts override with `/admin/auth`.
- AuthService composes URLs as `${bffBaseUrl}${pathPrefix}/{me,login,
  logout}`. The interceptors are unchanged — they only care about
  the BFF base URL.

portal-admin wiring

- environment.ts: same shape as portal-shell, same BFF base URL
  (both SPAs talk to one BFF per ADR-0020 §"Where does the admin
  app live"), same CSRF cookie name in v1.
- app.config.ts: HttpClient with the standard interceptor chain
  (credentials → csrf → unauthorized), AUTH_BFF_BASE_URL from env,
  AUTH_PATH_PREFIX = '/admin/auth', AUTH_CSRF_COOKIE_NAME from env.

Admin shell components

- AdminHeader: APF wordmark + persistent "Admin" badge + auth widget.
  No global search / notifications / help cluster — admins land on
  tabular workloads, not discovery. Per ADR-0020 §"UX style is
  data-dense".
- AdminSidebar: static menu listing the four ADR-0020 v1 modules.
  Audit log is a live router link (target of PR 8); the others are
  aria-disabled placeholders with a "Soon" badge so the navigation
  shape is visible even before they ship.
- AdminFooter: copyright + persistent "Admin surface" tag — the
  badge stays in view for long workloads where the header may have
  scrolled off.

Home page

- Auth self-test panel: signed-in payload (display name, tenant,
  oid) when authenticated, Sign in button when anonymous, error
  state when the BFF is unreachable.
- Roadmap list quoting ADR-0020's v1 catalogue so a returning
  contributor sees what's pending without grepping.

Notes

- LayoutStateService isn't consumed yet (no collapse toggle in v1),
  but the theme preference still threads through because both apps
  read the same localStorage key — toggle in portal-shell, see it
  honoured in portal-admin.
- Strings are plain English in v1. The admin app's $localize plumbing
  is wired but adding markers to the shell chrome here would require
  regenerating messages.fr.xlf and i18nMissingTranslation=error fails
  the prod build on every gap. Full i18n is its own follow-up.
- v1 reuses the shared `portal_csrf` cookie name. A user with both
  portals open could see CSRF cookie overwrites on session creation;
  splitting to `portal_admin_csrf` is a follow-up if the pattern
  becomes common.
- Build verified locally: 86.65 KB gzip initial / 300 KB budget,
  4.46 KB CSS / 150 KB budget.

Tests: +15 specs (AdminHeader 5, AdminSidebar 3, AdminFooter 2,
Home 4, App 1 expanded). 1 new test in feature-auth AuthService for
the AUTH_PATH_PREFIX override path.
2026-05-14 16:54:10 +02:00

149 lines
5.5 KiB
TypeScript

import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { AUTH_BFF_BASE_URL, AUTH_NAVIGATOR, AUTH_PATH_PREFIX } from './auth.config';
import { AuthService } from './auth.service';
import type { CurrentUser } from './auth.types';
const BFF_BASE = 'http://bff.test/api';
const ME_URL = `${BFF_BASE}/auth/me`;
const USER: CurrentUser = {
oid: 'user-oid',
tid: 'tenant-id',
username: 'jane.doe@apf.example',
displayName: 'Jane Doe',
};
interface Fixture {
service: AuthService;
http: HttpTestingController;
navigate: ReturnType<typeof vi.fn>;
}
function setup(): Fixture {
const navigate = vi.fn();
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE },
{ provide: AUTH_NAVIGATOR, useValue: navigate },
AuthService,
],
});
const service = TestBed.inject(AuthService);
const http = TestBed.inject(HttpTestingController);
return { service, http, navigate };
}
describe('AuthService', () => {
afterEach(() => {
TestBed.resetTestingModule();
});
describe('bootstrap fetch', () => {
it('starts in the loading state before /me resolves', () => {
const { service, http } = setup();
expect(service.state().kind).toBe('loading');
expect(service.isLoading()).toBe(true);
expect(service.currentUser()).toBeNull();
// Drain the in-flight bootstrap request to keep the controller clean.
http.expectOne(ME_URL).flush({}, { status: 401, statusText: 'Unauthorized' });
});
it('transitions to authenticated when /me returns the user', async () => {
const { service, http } = setup();
http.expectOne(ME_URL).flush(USER);
// Let the awaited firstValueFrom commit the state.
await Promise.resolve();
expect(service.state()).toEqual({ kind: 'authenticated', user: USER });
expect(service.currentUser()).toEqual(USER);
expect(service.isLoading()).toBe(false);
});
it('transitions to anonymous on 401', async () => {
const { service, http } = setup();
http
.expectOne(ME_URL)
.flush({ error: 'unauthenticated' }, { status: 401, statusText: 'Unauthorized' });
await Promise.resolve();
expect(service.state().kind).toBe('anonymous');
expect(service.currentUser()).toBeNull();
});
it('transitions to error on a non-401 failure (network / 5xx / malformed)', async () => {
const { service, http } = setup();
http.expectOne(ME_URL).flush('boom', { status: 500, statusText: 'Internal Server Error' });
await Promise.resolve();
expect(service.state().kind).toBe('error');
expect(service.currentUser()).toBeNull();
});
});
describe('refresh()', () => {
it('can be called again after the bootstrap fetch and re-fetches /me', async () => {
const { service, http } = setup();
http.expectOne(ME_URL).flush(USER);
await Promise.resolve();
expect(service.state().kind).toBe('authenticated');
const promise = service.refresh();
http
.expectOne(ME_URL)
.flush({ error: 'unauthenticated' }, { status: 401, statusText: 'Unauthorized' });
await promise;
expect(service.state().kind).toBe('anonymous');
});
});
describe('URL accessors', () => {
it('derives me / login / logout URLs from the injected base + default /auth prefix', () => {
const { service, http } = setup();
expect(service.meUrl).toBe(`${BFF_BASE}/auth/me`);
expect(service.loginUrl).toBe(`${BFF_BASE}/auth/login`);
expect(service.logoutUrl).toBe(`${BFF_BASE}/auth/logout`);
http.expectOne(ME_URL).flush({}, { status: 401, statusText: 'Unauthorized' });
});
it('honours AUTH_PATH_PREFIX when the host provides a non-default value (admin surface)', () => {
const navigate = vi.fn();
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE },
{ provide: AUTH_NAVIGATOR, useValue: navigate },
{ provide: AUTH_PATH_PREFIX, useValue: '/admin/auth' },
AuthService,
],
});
const service = TestBed.inject(AuthService);
const http = TestBed.inject(HttpTestingController);
expect(service.meUrl).toBe(`${BFF_BASE}/admin/auth/me`);
expect(service.loginUrl).toBe(`${BFF_BASE}/admin/auth/login`);
expect(service.logoutUrl).toBe(`${BFF_BASE}/admin/auth/logout`);
// The auto-refresh on first inject fires against the override path.
http
.expectOne(`${BFF_BASE}/admin/auth/me`)
.flush({}, { status: 401, statusText: 'Unauthorized' });
});
});
describe('login() / logout()', () => {
it('navigates to /auth/login via AUTH_NAVIGATOR', () => {
const { service, http, navigate } = setup();
service.login();
expect(navigate).toHaveBeenCalledWith(`${BFF_BASE}/auth/login`);
http.expectOne(ME_URL).flush({}, { status: 401, statusText: 'Unauthorized' });
});
it('navigates to /auth/logout via AUTH_NAVIGATOR', () => {
const { service, http, navigate } = setup();
service.logout();
expect(navigate).toHaveBeenCalledWith(`${BFF_BASE}/auth/logout`);
http.expectOne(ME_URL).flush({}, { status: 401, statusText: 'Unauthorized' });
});
});
});