feat(portal-bff): ai-client skeleton — vendored protos + grpc client + Principal mapper
CI / commits (pull_request) Successful in 3m34s
CI / scan (pull_request) Successful in 3m47s
CI / check (pull_request) Successful in 6m15s
CI / a11y (pull_request) Successful in 2m26s
Docs site / build (pull_request) Successful in 1m7s
CI / perf (pull_request) Successful in 4m33s
CI / commits (pull_request) Successful in 3m34s
CI / scan (pull_request) Successful in 3m47s
CI / check (pull_request) Successful in 6m15s
CI / a11y (pull_request) Successful in 2m26s
Docs site / build (pull_request) Successful in 1m7s
CI / perf (pull_request) Successful in 4m33s
Lands the BFF-side skeleton for the apf-ai-service relay per ADR-0024, step 2 of the chantier (skeleton + tests against an in-process fake gRPC server; no live HTTP endpoint yet — that ships in the SSE-bridge PR). What ships: - contract/proto vendoring at apps/portal-bff/src/grpc/proto/apf-ai/ (common, chat, rag, ingestion, models). pnpm run grpc:sync refreshes from the sibling apf-ai-service tree; both .proto and the ts-proto TypeScript stubs under grpc/gen/ are committed for hermetic builds and reviewable diffs. - Codegen via pnpm run grpc:codegen using grpc-tools' bundled protoc and ts-proto (outputServices=grpc-js, esModuleInterop, forceLong, useOptionals=messages). Generated tree is excluded from Prettier and ESLint so hand-rules do not chase codegen output. - assertAiServiceConfig() pre-flight validator alongside the other config validators (AI_SERVICE_GRPC_ENDPOINT, AI_SERVICE_CLIENT_ID, AI_SERVICE_GRPC_TLS). - AiClientModule provides ChatClient / RagClient / IngestionClient / ModelsClient wrappers, a GrpcMetadataBuilder that stamps every call with x-client-id + x-correlation-id (W3C trace-id from OTel when a span is active, explicit override or UUID fallback otherwise), and a PrincipalMapper that hashes the Entra oid via HashUserIdService so the proto Principal.subject matches audit.events.actor_id_hash exactly (ADR-0013 cross-reference contract). - Specs cover: env validator, principal mapper, metadata builder (OTel span / override / fallback paths), chat client streaming + cancellation against an in-process fake gRPC ChatService, rag client unary happy path + error propagation, and module bootstrap. AiClientModule is NOT imported in AppModule yet — the SSE-bridge controller and its live route ship in the next PR.
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
||||
import { trace } from '@opentelemetry/api';
|
||||
import { GrpcMetadataBuilder } from './grpc-metadata.builder';
|
||||
import type { AiServiceConfig } from '../../config/check-ai-service-config';
|
||||
|
||||
/**
|
||||
* Locks the metadata-injection contract per ADR-0024 §"Metadata contract":
|
||||
*
|
||||
* - x-client-id ← AI_SERVICE_CLIENT_ID env (via AI_CONFIG).
|
||||
* - x-correlation-id ← active OTel span's trace-id when present;
|
||||
* explicit `correlationId` override wins over
|
||||
* the span; otherwise a fresh UUID is minted
|
||||
* so the AI service can still join its audit
|
||||
* row to a unique id even when the portal
|
||||
* request is outside a trace.
|
||||
*/
|
||||
|
||||
const CONFIG: AiServiceConfig = {
|
||||
endpoint: 'apf-ai-service:8080',
|
||||
clientId: 'apf-portal-test',
|
||||
useTls: false,
|
||||
};
|
||||
|
||||
let activeSpan: ReturnType<typeof trace.getActiveSpan> = undefined;
|
||||
let getActiveSpanSpy: ReturnType<typeof jest.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
activeSpan = undefined;
|
||||
getActiveSpanSpy = jest.spyOn(trace, 'getActiveSpan').mockImplementation(() => activeSpan);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getActiveSpanSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('GrpcMetadataBuilder', () => {
|
||||
it('always emits x-client-id from the config', () => {
|
||||
const builder = new GrpcMetadataBuilder(CONFIG);
|
||||
const meta = builder.build();
|
||||
expect(meta.get('x-client-id')).toEqual(['apf-portal-test']);
|
||||
});
|
||||
|
||||
it('uses the active OTel trace-id as x-correlation-id when a span is active', () => {
|
||||
activeSpan = {
|
||||
spanContext: () => ({
|
||||
traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
spanId: 'bbbbbbbbbbbbbbbb',
|
||||
traceFlags: 1,
|
||||
}),
|
||||
} as ReturnType<typeof trace.getActiveSpan>;
|
||||
|
||||
const builder = new GrpcMetadataBuilder(CONFIG);
|
||||
const meta = builder.build();
|
||||
expect(meta.get('x-correlation-id')).toEqual(['aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa']);
|
||||
});
|
||||
|
||||
it('prefers the explicit options.correlationId over the active span', () => {
|
||||
activeSpan = {
|
||||
spanContext: () => ({
|
||||
traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
spanId: 'bbbbbbbbbbbbbbbb',
|
||||
traceFlags: 1,
|
||||
}),
|
||||
} as ReturnType<typeof trace.getActiveSpan>;
|
||||
|
||||
const builder = new GrpcMetadataBuilder(CONFIG);
|
||||
const meta = builder.build({ correlationId: 'caller-supplied-id' });
|
||||
expect(meta.get('x-correlation-id')).toEqual(['caller-supplied-id']);
|
||||
});
|
||||
|
||||
it('falls back to a UUID v4 when no span is active and no override is passed', () => {
|
||||
const builder = new GrpcMetadataBuilder(CONFIG);
|
||||
const meta = builder.build();
|
||||
const correlationIds = meta.get('x-correlation-id') as string[];
|
||||
expect(correlationIds).toHaveLength(1);
|
||||
expect(correlationIds[0]).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a fresh Metadata instance on every call (no shared mutation)', () => {
|
||||
const builder = new GrpcMetadataBuilder(CONFIG);
|
||||
const a = builder.build();
|
||||
const b = builder.build();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user