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
43 changes: 40 additions & 3 deletions apps/gateway/src/app.module.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import type { AddressInfo } from 'node:net';
import type { INestApplication } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { AppModule } from './app.module';

const applications: INestApplication[] = [];
const TODAY_DEPENDENCY_KEYS = [
'IDENTITY_SERVICE_ORIGIN',
'PLANNING_SERVICE_ORIGIN',
'PLANNING_GATEWAY_CONTEXT_SECRET',
] as const;
let previousTodayDependencies: ReadonlyArray<
readonly [(typeof TODAY_DEPENDENCY_KEYS)[number], string | undefined]
> = [];

async function createHarness(): Promise<{
app: INestApplication;
Expand All @@ -18,15 +26,44 @@ async function createHarness(): Promise<{
return { app, baseUrl: `http://127.0.0.1:${address.port}` };
}

beforeEach(() => {
previousTodayDependencies = TODAY_DEPENDENCY_KEYS.map(
(key) => [key, process.env[key]] as const,
);
for (const key of TODAY_DEPENDENCY_KEYS) delete process.env[key];
});

afterEach(async () => {
await Promise.all(applications.splice(0).map((app) => app.close()));
try {
await Promise.all(applications.splice(0).map((app) => app.close()));
} finally {
for (const [key, value] of previousTodayDependencies) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
previousTodayDependencies = [];
}
});

describe('Gateway Today HTTP boundary', () => {
it('returns explicit non-cacheable unavailable evidence instead of fake Today data', async () => {
it('returns a bounded invalid-request problem when the required date is absent', async () => {
const { baseUrl } = await createHarness();

const response = await fetch(`${baseUrl}/v1/today`);
expect(response.status).toBe(400);
expect(response.headers.get('cache-control')).toBe('no-store');
expect(await response.json()).toEqual({
type: 'about:blank',
title: 'Today composition request is invalid',
status: 400,
code: 'invalid_today_request',
});
});

it('returns explicit non-cacheable unavailable evidence when trusted dependencies are not configured', async () => {
const { baseUrl } = await createHarness();

const response = await fetch(`${baseUrl}/v1/today?date=2026-08-10`);
expect(response.status).toBe(503);
expect(response.headers.get('cache-control')).toBe('no-store');
const body = await response.json();
Expand Down
50 changes: 46 additions & 4 deletions apps/gateway/src/app.module.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import { HttpException } from '@nestjs/common';
import { describe, expect, it } from 'vitest';
import { HealthController } from './app.module';

const TODAY_DEPENDENCY_KEYS = [
'IDENTITY_SERVICE_ORIGIN',
'PLANNING_SERVICE_ORIGIN',
'PLANNING_GATEWAY_CONTEXT_SECRET',
] as const;

/** Returns a stable problem object from one expected gateway HTTP failure. */
function problem(error: unknown): Readonly<Record<string, unknown>> {
expect(error).toBeInstanceOf(HttpException);
Expand All @@ -10,12 +16,48 @@ function problem(error: unknown): Readonly<Record<string, unknown>> {
return response as Readonly<Record<string, unknown>>;
}

describe('Gateway Today transitional boundary', () => {
it('never returns fabricated successful Today data while real composition is unavailable', () => {
/** Runs one assertion with Today upstream configuration deterministically absent. */
async function withoutTodayDependencies<T>(
operation: () => Promise<T>,
): Promise<T> {
const previous = TODAY_DEPENDENCY_KEYS.map((key) => [key, process.env[key]] as const);
for (const key of TODAY_DEPENDENCY_KEYS) delete process.env[key];
try {
return await operation();
} finally {
for (const [key, value] of previous) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
}

describe('Gateway Today HTTP boundary', () => {
it('rejects a missing Today date before any dependency configuration is consulted', async () => {
const controller = new HealthController();

try {
await controller.today(undefined, undefined);
} catch (error) {
expect((error as HttpException).getStatus()).toBe(400);
expect(problem(error)).toEqual({
type: 'about:blank',
title: 'Today composition request is invalid',
status: 400,
code: 'invalid_today_request',
});
return;
}
throw new Error('Expected an invalid Today request to fail closed');
});

it('keeps Today unavailable when trusted dependency configuration is absent', async () => {
const controller = new HealthController();

try {
controller.today();
await withoutTodayDependencies(() =>
controller.today(undefined, '2026-08-10'),
);
} catch (error) {
expect((error as HttpException).getStatus()).toBe(503);
expect(problem(error)).toEqual({
Expand All @@ -26,6 +68,6 @@ describe('Gateway Today transitional boundary', () => {
});
return;
}
throw new Error('Expected Today composition to fail closed');
throw new Error('Expected unavailable Today dependencies to fail closed');
});
});
46 changes: 36 additions & 10 deletions apps/gateway/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
import { Controller, Get, Header, HttpException, Module } from '@nestjs/common';
import {
Controller,
Get,
Header,
Headers,
HttpException,
Module,
Query,
} from '@nestjs/common';
import { PROMETHEUS_CONTENT_TYPE } from '@life-os/observability';
import { gatewayMetrics } from './observability';
import {
composePlanningToday,
GatewayTodayError,
type GatewayTodayView,
} from './today-composition';

/** Bounded problem details returned while the real Today composition is unavailable. */
/** Bounded problem details returned without dependency or credential details. */
interface GatewayProblemDetails {
readonly type: 'about:blank';
readonly title: string;
Expand All @@ -21,7 +34,7 @@ function problem(status: number, title: string, code: string): HttpException {
return new HttpException(details, status);
}

/** Exposes operational health and bounded metrics while product composition stays fail-closed. */
/** Exposes operational health, authenticated Today composition, and bounded metrics. */
@Controller()
export class HealthController {
/** Returns a credential-free liveness response for the gateway process. */
Expand All @@ -30,15 +43,28 @@ export class HealthController {
return { status: 'ok', service: 'gateway' };
}

/** Refuses to fabricate Today data until authenticated service composition is configured. */
/**
* Authenticates through Identity and returns validated Planning-owned Today
* state. Habit state remains an explicit degraded capability, never fake data.
*/
@Get('today')
@Header('Cache-Control', 'no-store')
today(): never {
throw problem(
503,
'Today composition is unavailable',
'today_composition_unavailable',
);
async today(
@Headers('cookie') cookie: string | undefined,
@Query('date') date: string | undefined,
): Promise<GatewayTodayView> {
try {
return await composePlanningToday(cookie, date ?? '', process.env);
} catch (error) {
if (error instanceof GatewayTodayError) {
throw problem(error.status, error.message, error.code);
}
throw problem(
503,
'Today composition is unavailable',
'today_composition_unavailable',
);
}
}

/** Renders the bounded in-memory metrics registry for Prometheus scrapes. */
Expand Down
Loading
Loading