Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
# -----------------------------------------------------------------------------
Expand Down Expand Up @@ -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";
}
Expand Down
94 changes: 94 additions & 0 deletions src/app/services/config.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
29 changes: 16 additions & 13 deletions src/app/services/config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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<void> {
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<void> {
Expand Down
11 changes: 11 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});