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); });