From cdcd295adaf0c656fbfa4d477665f1e1a6a3cad2 Mon Sep 17 00:00:00 2001 From: Daniel Truong Date: Fri, 14 Aug 2026 17:33:23 +0000 Subject: [PATCH] fix: fail the admin boot when /api/config does not answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fetch swallowed its failure and carried on with env.js. In prod that file is a ConfigMap holding only configEndpoint and KEYCLOAK_CLIENT_ID, so a swallowed failure builds Keycloak with no URL at all; in dev and test the image's env.js supplies the dev loginproxy, so staff would be sent to the wrong identity provider with the sysadmin check decided by dev's roles and the banner still reading "dev". init() is already awaited before Keycloak starts, so a throw means no IdP is ever contacted. A 200 is guarded too: KEYCLOAK_URL and KEYCLOAK_REALM both default to null in the model and the controller hydrates rather than leans, so a half-seeded document is a valid response whose nulls would clobber working values. eagle-public deliberately keeps its swallow — it is unauthenticated and every env.js default it falls back to is still correct. Two related fixes here because they share the file: /admin/env.js was served immutable for a year, since `location = /env.js` never matches a request arriving as /admin/env.js and the static-asset regex won instead; and the build now greps the built env.js, because sed exits 0 when it matches nothing. --- Dockerfile | 8 ++- src/app/services/config.service.spec.ts | 94 +++++++++++++++++++++++++ src/app/services/config.service.ts | 29 ++++---- src/main.ts | 11 +++ 4 files changed, 127 insertions(+), 15 deletions(-) create mode 100644 src/app/services/config.service.spec.ts diff --git a/Dockerfile b/Dockerfile index 6e9574ede..351d34923 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,11 +28,15 @@ COPY . . # Configure env.js for deployed environment: # - configEndpoint=true: App fetches config from /api/config at runtime # - All other config (ENVIRONMENT, ANALYTICS_API_URL, etc.) comes from API -RUN sed -i 's/configEndpoint = false/configEndpoint = true/' src/env.js +RUN sed -i 's/window.__env.configEndpoint = false/window.__env.configEndpoint = true/' src/env.js # Build production bundle RUN yarn build +# Fail the build if the sed above matched nothing, or env.js dropped out of the assets list +RUN grep -qF 'window.__env.configEndpoint = true;' dist/env.js \ + || { echo 'env.js rewrite did not take: configEndpoint'; exit 1; } + # ----------------------------------------------------------------------------- # Stage 2: Production nginx Server # ----------------------------------------------------------------------------- @@ -118,7 +122,7 @@ server { try_files $uri $uri/ /admin/index.html; # Runtime config — must never be cached (changes between deployments) - location = /env.js { + location ~ ^/(admin/)?env\.js$ { expires -1; add_header Cache-Control "no-cache, no-store, must-revalidate"; } diff --git a/src/app/services/config.service.spec.ts b/src/app/services/config.service.spec.ts new file mode 100644 index 000000000..ed99e638b --- /dev/null +++ b/src/app/services/config.service.spec.ts @@ -0,0 +1,94 @@ +import { ConfigService } from './config.service'; +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { LoggingService } from './logging.service'; +import { LoadingStateService } from './loading-state.service'; + +describe('ConfigService', () => { + let service: ConfigService; + let savedEnv: any; + + // Minimal env.js stand-in — deployed shape, so fetchRemoteConfig runs. + const deployedEnv = { + configEndpoint: true, + ENVIRONMENT: 'dev', + KEYCLOAK_CLIENT_ID: 'eagle-admin-console', + KEYCLOAK_URL: 'https://dev.loginproxy.gov.bc.ca/auth', + KEYCLOAK_REALM: 'eao-epic' + }; + + function fakeResponse(ok: boolean, body: any) { + return Promise.resolve({ ok, status: ok ? 200 : 503, json: () => Promise.resolve(body) } as any); + } + + beforeEach(() => { + savedEnv = window.__env; + + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ConfigService, + { provide: LoggingService, useValue: jasmine.createSpyObj('LoggingService', ['error', 'warn', 'info', 'debug']) }, + { provide: LoadingStateService, useValue: jasmine.createSpyObj('LoadingStateService', ['startLoading', 'stopLoading']) } + ] + }); + + service = TestBed.inject(ConfigService); + }); + + afterEach(() => { + window.__env = savedEnv; + }); + + it('rejects when /api/config returns a non-2xx response', async () => { + window.__env = { ...deployedEnv }; + spyOn(window, 'fetch').and.returnValue(fakeResponse(false, {})); + + await expectAsync(service.init()).toBeRejected(); + }); + + it('rejects when the fetch itself fails', async () => { + window.__env = { ...deployedEnv }; + spyOn(window, 'fetch').and.returnValue(Promise.reject(new TypeError('Failed to fetch'))); + + await expectAsync(service.init()).toBeRejected(); + }); + + it('rejects a 200 with no KEYCLOAK_URL and merges nothing from it', async () => { + window.__env = { ...deployedEnv }; + spyOn(window, 'fetch').and.returnValue(fakeResponse(true, { ENVIRONMENT: 'prod', KEYCLOAK_REALM: 'eao-epic' })); + + await expectAsync(service.init()).toBeRejected(); + expect(service.config().KEYCLOAK_URL).toBe('https://dev.loginproxy.gov.bc.ca/auth'); + expect(service.config().ENVIRONMENT).toBe('dev'); + }); + + it('rejects a partially seeded document whose KEYCLOAK_URL is null', async () => { + window.__env = { ...deployedEnv }; + spyOn(window, 'fetch').and.returnValue(fakeResponse(true, { KEYCLOAK_URL: null, KEYCLOAK_REALM: null })); + + await expectAsync(service.init()).toBeRejected(); + }); + + it('skips the fetch entirely when configEndpoint is false (local dev)', async () => { + window.__env = { ...deployedEnv, configEndpoint: false }; + const fetchSpy = spyOn(window, 'fetch'); + + await expectAsync(service.init()).toBeResolved(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('preserves KEYCLOAK_CLIENT_ID from env.js over the API value', async () => { + window.__env = { ...deployedEnv }; + spyOn(window, 'fetch').and.returnValue(fakeResponse(true, { + KEYCLOAK_CLIENT_ID: 'eagle-api-console', + KEYCLOAK_URL: 'https://loginproxy.gov.bc.ca/auth', + KEYCLOAK_REALM: 'eao-epic' + })); + + await service.init(); + expect(service.config().KEYCLOAK_CLIENT_ID).toBe('eagle-admin-console'); + }); +}); diff --git a/src/app/services/config.service.ts b/src/app/services/config.service.ts index 0df58e4b3..bd8cca73f 100644 --- a/src/app/services/config.service.ts +++ b/src/app/services/config.service.ts @@ -36,8 +36,9 @@ declare global { * * DEPLOYED (configEndpoint = true): * - Dockerfile sed sets configEndpoint to true - * - App fetches /api/config on startup (nginx serves from ConfigMap) - * - ConfigMap values override env.js (except KEYCLOAK_CLIENT_ID — preserved) + * - App fetches /api/config on startup — today rproxy answers it from a ConfigMap, and + * eagle-api serves it from MongoDB once the nginx exact-match block is removed + * - API values override env.js (except KEYCLOAK_CLIENT_ID — preserved) * * Lists are lazy-loaded on first access via getLists(), not during init. */ @@ -96,21 +97,23 @@ export class ConfigService { } /** - * Fetch remote config from /api/config (deployed only, non-blocking). - * nginx serves this from ConfigMap. On success merges over env.js values. + * Fetch remote config from /api/config (deployed only, blocking). + * On success merges over env.js values. * KEYCLOAK_CLIENT_ID is always preserved from env.js. + * + * Throws on transport failure or on a payload with no Keycloak URL/realm — + * booting on stale env.js defaults would point staff at the wrong identity provider. */ private async fetchRemoteConfig(): Promise { - try { - const response = await fetch('/api/config', { signal: AbortSignal.timeout(5000) }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const apiConfig: EnvConfig = await response.json(); - const preservedClientId = this._config().KEYCLOAK_CLIENT_ID; - this._config.set({ ...this._config(), ...apiConfig, KEYCLOAK_CLIENT_ID: preservedClientId }); - this.logger.debug('merged with API config:', 'ConfigService', this._config()); - } catch (e) { - this.logger.error('API config fetch failed, using env.js defaults', 'ConfigService', e); + const response = await fetch('/api/config', { signal: AbortSignal.timeout(5000) }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const apiConfig: EnvConfig = await response.json(); + if (!apiConfig.KEYCLOAK_URL || !apiConfig.KEYCLOAK_REALM) { + throw new Error('ConfigService: /api/config returned no KEYCLOAK_URL/REALM'); } + const preservedClientId = this._config().KEYCLOAK_CLIENT_ID; + this._config.set({ ...this._config(), ...apiConfig, KEYCLOAK_CLIENT_ID: preservedClientId }); + this.logger.debug('merged with API config:', 'ConfigService', this._config()); } public ensureListsLoaded(): Promise { diff --git a/src/main.ts b/src/main.ts index a6a0f8c69..e22f55581 100644 --- a/src/main.ts +++ b/src/main.ts @@ -53,4 +53,15 @@ bootstrapApplication(AppComponent, { { provide: HTTP_INTERCEPTORS, useClass: TokenInterceptor, multi: true }, { provide: ErrorHandler, useClass: GlobalErrorHandler }, ] +}).catch(err => { + // Bootstrap failed — config fetch or Keycloak. Stay cause-agnostic: keycloak.service + // rejects with no argument, so never blame a specific step in the user-facing text. + console.error('Bootstrap failed:', err); + const alert = document.createElement('p'); + alert.setAttribute('role', 'alert'); + alert.textContent = 'The EPIC admin console could not start. Please try again shortly.'; + // Replace app-root rather than appending: index.html ships a placeholder spinner inside it, + // and nothing else removes it, so appending leaves the page saying "loading" and "could not + // start" at the same time. + (document.querySelector('app-root') ?? document.body).replaceChildren(alert); });