diff --git a/packages/core-audit/src/with-audit.chain.test.ts b/packages/core-audit/src/with-audit.chain.test.ts new file mode 100644 index 0000000..232a2f3 --- /dev/null +++ b/packages/core-audit/src/with-audit.chain.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi } from "vitest"; +import { withSpan } from "@repo/core-shared/instrumentation"; +import { withCapture } from "@repo/core-shared/instrumentation"; +import { isInstrumented, isCaptured, isAudited } from "@repo/core-shared/conformance"; +import { withAudit } from "@/with-audit"; +import type { IAuditLog } from "@/audit-log.interface"; +import type { ITracer, ISpan } from "@repo/core-shared/instrumentation"; +import type { ILogger } from "@repo/core-shared/instrumentation"; + +function makeTracer(): ITracer { + return { + startSpan: vi.fn(async (_opts, fn) => { + const span: ISpan = { setAttribute: () => {}, setStatus: () => {} }; + return fn(span); + }), + }; +} + +function makeLogger(): ILogger { + return { + captureException: vi.fn(), + captureMessage: vi.fn(), + addBreadcrumb: vi.fn(), + setUser: vi.fn(), + }; +} + +function makeAuditLog(): IAuditLog { + return { + record: vi.fn().mockResolvedValue(undefined), + eraseSubject: vi.fn().mockResolvedValue(undefined), + }; +} + +describe("brand propagation across the full wrapper chain", () => { + it("withSpan(withCapture(withAudit(fn))) carries all three brands on the outer wrapper", () => { + const tracer = makeTracer(); + const logger = makeLogger(); + const auditLog = makeAuditLog(); + const fn = async (input: { id: string }) => ({ ok: true, id: input.id }); + + const wrapped = withSpan( + tracer, + { name: "test.chain", op: "use-case" }, + withCapture( + logger, + { feature: "test", layer: "use-case" }, + withAudit(auditLog, fn), + ), + ); + + expect(isInstrumented(wrapped)).toBe(true); + expect(isCaptured(wrapped)).toBe(true); + expect(isAudited(wrapped)).toBe(true); + }); + + it("the full chain preserves input/output behaviour end-to-end", async () => { + const tracer = makeTracer(); + const logger = makeLogger(); + const auditLog = makeAuditLog(); + const fn = async (input: { id: string }) => ({ ok: true, id: input.id }); + + const wrapped = withSpan( + tracer, + { name: "test.chain", op: "use-case" }, + withCapture( + logger, + { feature: "test", layer: "use-case" }, + withAudit(auditLog, fn), + ), + ); + + const result = await wrapped({ id: "abc" }); + expect(result).toEqual({ ok: true, id: "abc" }); + }); +}); diff --git a/packages/core-shared/src/conformance/brand-runtime.ts b/packages/core-shared/src/conformance/brand-runtime.ts index 73e404e..c378fe5 100644 --- a/packages/core-shared/src/conformance/brand-runtime.ts +++ b/packages/core-shared/src/conformance/brand-runtime.ts @@ -17,9 +17,11 @@ type Brand = "__instrumented" | "__captured" | "__audited"; /** * Attaches the brand as a non-enumerable property on the given function. - * Returns the same reference (no allocation). Idempotent: calling twice with - * the same brand throws because the property is non-configurable — wrappers - * never double-wrap, so this is the correct safety net. + * Returns the same reference (no allocation). Calling twice with the same + * brand on the same fn is a no-op (matching descriptors) — the engine silently + * accepts a redundant `defineProperty` call when every descriptor attribute + * matches the existing one. Redefining with different attributes (e.g. flipping + * `configurable`) would throw, but wrappers never do that. */ export function attachBrand(fn: F, brand: Brand): F { Object.defineProperty(fn, brand, { diff --git a/packages/core-shared/src/conformance/brands.ts b/packages/core-shared/src/conformance/brands.ts index eeb1045..4ff9fe3 100644 --- a/packages/core-shared/src/conformance/brands.ts +++ b/packages/core-shared/src/conformance/brands.ts @@ -1,9 +1,11 @@ /** * Phantom-type brands attached at wrap time by `withSpan`, `withCapture`, - * and `withAudit`. Pure type-level — no runtime cost, no proxy, no - * `Object.assign`. The conformance system uses these as the type-level - * seam the binding signature checks; a use-case factory that hasn't been - * wrapped is not assignable to a `ProductionUseCase<...>` slot. + * and `withAudit`. The brand has a type-level form (this file) and a + * non-enumerable runtime counterpart (see `./brand-runtime.ts`). The runtime + * cost is one `Object.defineProperty` call per wrap. The conformance system + * uses these as the type-level seam the binding signature checks; a use-case + * factory that hasn't been wrapped is not assignable to a + * `ProductionUseCase<...>` slot. */ export type Instrumented = F & { readonly __instrumented: true }; export type Captured = F & { readonly __captured: true }; diff --git a/packages/core-shared/src/instrumentation/with-capture.ts b/packages/core-shared/src/instrumentation/with-capture.ts index 9807a81..8652115 100644 --- a/packages/core-shared/src/instrumentation/with-capture.ts +++ b/packages/core-shared/src/instrumentation/with-capture.ts @@ -28,6 +28,8 @@ export function withCapture( tags: Record, fn: (...args: Args) => Promise, ): Captured<(...args: Args) => Promise> { + const PROPAGATED_BRANDS = ["__instrumented", "__audited"] as const; + const wrapped: (...args: Args) => Promise = async (...args) => { try { return await fn(...args); @@ -39,6 +41,14 @@ export function withCapture( throw err; } }; + // Propagate brands from the inner function (e.g. __audited from withAudit, + // __instrumented if already spanned) so the outermost binding carries all brands. + // __captured is omitted here because it is attached explicitly below. + for (const brand of PROPAGATED_BRANDS) { + if ((fn as unknown as Record)[brand] === true) { + attachBrand(wrapped, brand); + } + } attachBrand(wrapped, "__captured"); return wrapped as Captured<(...args: Args) => Promise>; }