Three trust boundaries checked a caller's object and then read it again to use it. Between those two reads an accessor or a Proxy can answer differently, so the value that passed validation and the value that was installed were not the same value. A credential owner's answer was read field by field outside the auth boundary: a throwing `kind` getter escaped into the transport catch and an auth outage reached operators as `NETWORK_FAILURE`. Contract composition validated a contribution and then copied it, so a policy that answered 10,000 to the ceiling check and 999,999 to the copy installed the second value. The cursor runtime validated its profile once and re-read it on every page, so raising `maxPages` after construction widened a cap that had already been checked. `src/contracts/exact-snapshot.ts` is the one descriptor-based decoder they now share: every property is read exactly once, an accessor, a symbol, an inherited or non-enumerable field and a throwing trap all resolve to a typed failure, and validation runs on the owned copy. Separately, the `responseBody: NONE` probe awaited a bare `read()`. The deadline produced a bounded public result while the raw reader kept its lease, so the body stayed locked and the outer compensator could not cancel it. The probe now takes the operation lifetime and owns the cancel and the lock release itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
650 lines
20 KiB
TypeScript
650 lines
20 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
createContractHttpExecutor,
|
|
type HttpExecutionObservation,
|
|
} from "../../src/adapters/http/http-execution-v3.ts";
|
|
import { createHttpObservationProjector } from "../../src/bootstrap/runtime-adapters.ts";
|
|
import { installRestAuthProfileRegistry } from "../../src/contracts/rest-profiles.ts";
|
|
import { TEST_LIST_HTTP_CONTRACT } from "../helpers/external-contract-fixture.ts";
|
|
|
|
const ROUTE_ID = "TEST_ROUTE";
|
|
|
|
const TEST_PROFILES = installRestAuthProfileRegistry({
|
|
TEST_AUTH: {
|
|
authProfileId: "TEST_AUTH",
|
|
transport: "BEARER_HEADER",
|
|
credentials: "omit",
|
|
allowedCredentialHeaders: ["authorization"],
|
|
requiredCredentialHeaders: ["authorization"],
|
|
},
|
|
});
|
|
|
|
function scopeSnapshot(signal: AbortSignal = new AbortController().signal) {
|
|
return Object.freeze({
|
|
generation: 1,
|
|
fingerprint: "scope-1",
|
|
identities: Object.freeze({}) as never,
|
|
signal,
|
|
isCurrent: () => true,
|
|
});
|
|
}
|
|
|
|
function bearerOperation(deadlineMs = 10_000) {
|
|
return {
|
|
...TEST_LIST_HTTP_CONTRACT,
|
|
frontend: {
|
|
...TEST_LIST_HTTP_CONTRACT.frontend,
|
|
authProfileId: "TEST_AUTH",
|
|
totalDeadlineMs: deadlineMs,
|
|
},
|
|
};
|
|
}
|
|
|
|
function jsonResponse(body: unknown): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* LIVE-01. A credential collaborator that is broken, unavailable or throwing is
|
|
* an integration failure of the auth system. Reporting it as `UNAUTHENTICATED`
|
|
* makes the composition root run its logout path, so an auth outage would sign
|
|
* every user out.
|
|
*/
|
|
describe("LIVE-01 credential integration failures are not user session failures", () => {
|
|
const brokenOwners = [
|
|
{
|
|
label: "returns UNAVAILABLE",
|
|
attach: () => Object.freeze({ kind: "UNAVAILABLE" as const }),
|
|
},
|
|
{
|
|
label: "throws synchronously",
|
|
attach: () => {
|
|
throw new Error("credential owner exploded");
|
|
},
|
|
},
|
|
{
|
|
label: "rejects asynchronously",
|
|
attach: () => Promise.reject(new Error("credential owner exploded")),
|
|
},
|
|
{
|
|
label: "returns a malformed outcome",
|
|
attach: () => ({ kind: "TOTALLY_UNKNOWN" }) as never,
|
|
},
|
|
];
|
|
|
|
for (const owner of brokenOwners) {
|
|
it(`closes as AUTH_INTEGRATION_FAILURE when the owner ${owner.label}`, async () => {
|
|
const fetcher = vi.fn(async () => jsonResponse([]));
|
|
const executor = createContractHttpExecutor({
|
|
baseUrl: "https://api.example/",
|
|
maxRetryAttempts: 0,
|
|
authProfiles: TEST_PROFILES,
|
|
attachCredentials: owner.attach,
|
|
fetcher: fetcher as unknown as typeof fetch,
|
|
});
|
|
|
|
const outcome = await executor.execute(
|
|
bearerOperation(),
|
|
{ limit: 1 },
|
|
{ routeId: ROUTE_ID, scope: scopeSnapshot() },
|
|
);
|
|
|
|
expect(outcome.kind).toBe("AUTH_INTEGRATION_FAILURE");
|
|
expect(outcome.effect).toBe("NOT_APPLICABLE");
|
|
expect(fetcher).toHaveBeenCalledTimes(0);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* NS-01. Reading `patch.kind` and `patch.headers` off the raw answer put the
|
|
* credential decode outside the auth boundary: a throwing getter escaped into
|
|
* the transport catch and the outage was reported as `NETWORK_FAILURE`, so
|
|
* the operator saw a network incident instead of an auth integration one.
|
|
*/
|
|
const hostileOwners = [
|
|
{
|
|
label: "exposes a throwing kind getter",
|
|
attach: () =>
|
|
Object.defineProperty({}, "kind", {
|
|
enumerable: true,
|
|
get() {
|
|
throw new TypeError("hostile kind getter");
|
|
},
|
|
}) as never,
|
|
},
|
|
{
|
|
label: "exposes a throwing headers getter",
|
|
attach: () =>
|
|
Object.defineProperty({ kind: "READY" }, "headers", {
|
|
enumerable: true,
|
|
get() {
|
|
throw new TypeError("hostile headers getter");
|
|
},
|
|
}) as never,
|
|
},
|
|
{
|
|
label: "throws from an ownKeys trap",
|
|
attach: () =>
|
|
new Proxy(
|
|
{ kind: "READY", headers: { authorization: "Bearer ok" } },
|
|
{
|
|
ownKeys() {
|
|
throw new TypeError("hostile ownKeys trap");
|
|
},
|
|
},
|
|
) as never,
|
|
},
|
|
{
|
|
label: "throws from a getOwnPropertyDescriptor trap",
|
|
attach: () =>
|
|
new Proxy(
|
|
{ kind: "READY", headers: { authorization: "Bearer ok" } },
|
|
{
|
|
getOwnPropertyDescriptor() {
|
|
throw new TypeError("hostile descriptor trap");
|
|
},
|
|
},
|
|
) as never,
|
|
},
|
|
{
|
|
label: "carries the outcome only on its prototype",
|
|
attach: () =>
|
|
Object.create({
|
|
kind: "READY",
|
|
headers: { authorization: "Bearer ok" },
|
|
}) as never,
|
|
},
|
|
{
|
|
label: "carries an extra own field",
|
|
attach: () =>
|
|
Object.freeze({
|
|
kind: "READY",
|
|
headers: Object.freeze({ authorization: "Bearer ok" }),
|
|
injected: true,
|
|
}) as never,
|
|
},
|
|
{
|
|
label: "carries a symbol field",
|
|
attach: () =>
|
|
Object.freeze({
|
|
kind: "READY",
|
|
headers: Object.freeze({ authorization: "Bearer ok" }),
|
|
[Symbol.for("injected")]: true,
|
|
}) as never,
|
|
},
|
|
{
|
|
label: "hides the outcome behind a non-enumerable own field",
|
|
attach: () =>
|
|
Object.defineProperties(
|
|
{ kind: "READY" },
|
|
{
|
|
headers: {
|
|
enumerable: false,
|
|
value: { authorization: "Bearer ok" },
|
|
},
|
|
},
|
|
) as never,
|
|
},
|
|
];
|
|
|
|
for (const owner of hostileOwners) {
|
|
it(`closes as AUTH_INTEGRATION_FAILURE when the owner ${owner.label}`, async () => {
|
|
const fetcher = vi.fn(async () => jsonResponse([]));
|
|
const executor = createContractHttpExecutor({
|
|
baseUrl: "https://api.example/",
|
|
maxRetryAttempts: 0,
|
|
authProfiles: TEST_PROFILES,
|
|
attachCredentials: owner.attach,
|
|
fetcher: fetcher as unknown as typeof fetch,
|
|
});
|
|
|
|
const outcome = await executor.execute(
|
|
bearerOperation(),
|
|
{ limit: 1 },
|
|
{ routeId: ROUTE_ID, scope: scopeSnapshot() },
|
|
);
|
|
|
|
expect(outcome.kind).toBe("AUTH_INTEGRATION_FAILURE");
|
|
expect(outcome.effect).toBe("NOT_APPLICABLE");
|
|
expect(fetcher).toHaveBeenCalledTimes(0);
|
|
});
|
|
}
|
|
|
|
it("reads each field exactly once so a stateful answer cannot swap it", async () => {
|
|
const fetcher = vi.fn(async () => jsonResponse([]));
|
|
const kindReads: string[] = [];
|
|
const executor = createContractHttpExecutor({
|
|
baseUrl: "https://api.example/",
|
|
maxRetryAttempts: 0,
|
|
authProfiles: TEST_PROFILES,
|
|
attachCredentials: () =>
|
|
new Proxy(
|
|
{ kind: "READY", headers: { authorization: "Bearer first" } },
|
|
{
|
|
getOwnPropertyDescriptor(target, key) {
|
|
if (key === "kind") {
|
|
kindReads.push(key);
|
|
return {
|
|
configurable: true,
|
|
enumerable: true,
|
|
// A second read would answer with a different verdict.
|
|
value: kindReads.length > 1 ? "UNAUTHENTICATED" : "READY",
|
|
};
|
|
}
|
|
return Reflect.getOwnPropertyDescriptor(target, key);
|
|
},
|
|
},
|
|
) as never,
|
|
fetcher: fetcher as unknown as typeof fetch,
|
|
});
|
|
|
|
const outcome = await executor.execute(
|
|
bearerOperation(),
|
|
{ limit: 1 },
|
|
{ routeId: ROUTE_ID, scope: scopeSnapshot() },
|
|
);
|
|
|
|
expect(outcome.kind).toBe("SUCCESS");
|
|
expect(kindReads).toHaveLength(1);
|
|
});
|
|
|
|
it("sends an owned header snapshot rather than the owner's live object", async () => {
|
|
const headers: Record<string, string> = { authorization: "Bearer first" };
|
|
let sentHeaders: Record<string, string> | undefined;
|
|
const fetcher = vi.fn(async (_url: unknown, init?: RequestInit) => {
|
|
sentHeaders = init?.headers as Record<string, string>;
|
|
return jsonResponse([]);
|
|
});
|
|
const executor = createContractHttpExecutor({
|
|
baseUrl: "https://api.example/",
|
|
maxRetryAttempts: 0,
|
|
authProfiles: TEST_PROFILES,
|
|
// The owner keeps a live reference to the object it handed over.
|
|
attachCredentials: () => ({ kind: "READY", headers }) as never,
|
|
fetcher: fetcher as unknown as typeof fetch,
|
|
});
|
|
|
|
const outcome = await executor.execute(
|
|
bearerOperation(),
|
|
{ limit: 1 },
|
|
{ routeId: ROUTE_ID, scope: scopeSnapshot() },
|
|
);
|
|
|
|
expect(outcome.kind).toBe("SUCCESS");
|
|
headers.authorization = "Bearer swapped";
|
|
expect(sentHeaders?.["Authorization"] ?? sentHeaders?.["authorization"]).toBe(
|
|
"Bearer first",
|
|
);
|
|
});
|
|
|
|
it("still reports a real absent session as UNAUTHENTICATED", async () => {
|
|
const fetcher = vi.fn(async () => jsonResponse([]));
|
|
const executor = createContractHttpExecutor({
|
|
baseUrl: "https://api.example/",
|
|
maxRetryAttempts: 0,
|
|
authProfiles: TEST_PROFILES,
|
|
attachCredentials: () =>
|
|
Object.freeze({ kind: "UNAUTHENTICATED" as const }),
|
|
fetcher: fetcher as unknown as typeof fetch,
|
|
});
|
|
|
|
const outcome = await executor.execute(
|
|
bearerOperation(),
|
|
{ limit: 1 },
|
|
{ routeId: ROUTE_ID, scope: scopeSnapshot() },
|
|
);
|
|
|
|
expect(outcome.kind).toBe("UNAUTHENTICATED");
|
|
expect(fetcher).toHaveBeenCalledTimes(0);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* LIVE-04. The total deadline must bound the physical wait, not merely be
|
|
* checked between awaits. A non-cooperative `fetch` or body reader that ignores
|
|
* the abort signal cannot hold the port result open, and a value that arrives
|
|
* after the deadline already owns the execution must not be admitted.
|
|
*/
|
|
describe("LIVE-04 the total deadline owns every physical wait", () => {
|
|
it("does not wait for a non-cooperative fetch past the deadline", async () => {
|
|
const executor = createContractHttpExecutor({
|
|
baseUrl: "https://api.example/",
|
|
maxRetryAttempts: 0,
|
|
authProfiles: TEST_PROFILES,
|
|
attachCredentials: () =>
|
|
Object.freeze({
|
|
kind: "READY" as const,
|
|
headers: { authorization: "Bearer t" },
|
|
}),
|
|
fetcher: (() => new Promise<Response>(() => {})) as unknown as typeof fetch,
|
|
});
|
|
|
|
const outcome = await executor.execute(
|
|
bearerOperation(5),
|
|
{ limit: 1 },
|
|
{ routeId: ROUTE_ID, scope: scopeSnapshot() },
|
|
);
|
|
|
|
expect(outcome.kind).toBe("TRANSPORT_FAILURE");
|
|
expect(
|
|
outcome.kind === "TRANSPORT_FAILURE" ? outcome.failure.kind : null,
|
|
).toBe("TIMEOUT");
|
|
});
|
|
|
|
/**
|
|
* NS-03. The `NONE` probe used to await `reader.read()` with no signal, so a
|
|
* deadline produced a bounded public result while the raw reader kept its
|
|
* lease on the body: the connection and the buffer stayed held after the
|
|
* operation had already ended.
|
|
*/
|
|
it("cancels and releases the NONE probe reader when the deadline owns the execution", async () => {
|
|
let pulls = 0;
|
|
let cancels = 0;
|
|
const neverEndingBody = new ReadableStream<Uint8Array>({
|
|
pull() {
|
|
pulls += 1;
|
|
return new Promise<void>(() => {});
|
|
},
|
|
cancel() {
|
|
cancels += 1;
|
|
},
|
|
});
|
|
const response = new Response(neverEndingBody, { status: 200 });
|
|
const executor = createContractHttpExecutor({
|
|
baseUrl: "https://api.example/",
|
|
maxRetryAttempts: 0,
|
|
authProfiles: TEST_PROFILES,
|
|
attachCredentials: () =>
|
|
Object.freeze({
|
|
kind: "READY" as const,
|
|
headers: { authorization: "Bearer t" },
|
|
}),
|
|
fetcher: (async () => response) as unknown as typeof fetch,
|
|
});
|
|
const noBodyOperation = {
|
|
...bearerOperation(20),
|
|
contract: {
|
|
...bearerOperation(20).contract,
|
|
responseBody: "NONE" as const,
|
|
},
|
|
};
|
|
|
|
const outcome = await executor.execute(
|
|
noBodyOperation as never,
|
|
{ limit: 1 },
|
|
{ routeId: ROUTE_ID, scope: scopeSnapshot() },
|
|
);
|
|
|
|
expect(outcome.kind).toBe("TRANSPORT_FAILURE");
|
|
expect(
|
|
outcome.kind === "TRANSPORT_FAILURE" ? outcome.failure.kind : null,
|
|
).toBe("TIMEOUT");
|
|
expect(pulls).toBe(1);
|
|
await vi.waitFor(() => {
|
|
expect(cancels).toBe(1);
|
|
});
|
|
expect(response.body?.locked).toBe(false);
|
|
});
|
|
|
|
it("does not wait for a non-cooperative body reader past the deadline", async () => {
|
|
const neverEndingBody = new ReadableStream<Uint8Array>({
|
|
pull() {
|
|
return new Promise<void>(() => {});
|
|
},
|
|
});
|
|
const executor = createContractHttpExecutor({
|
|
baseUrl: "https://api.example/",
|
|
maxRetryAttempts: 0,
|
|
authProfiles: TEST_PROFILES,
|
|
attachCredentials: () =>
|
|
Object.freeze({
|
|
kind: "READY" as const,
|
|
headers: { authorization: "Bearer t" },
|
|
}),
|
|
fetcher: (async () =>
|
|
new Response(neverEndingBody, {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
})) as unknown as typeof fetch,
|
|
});
|
|
|
|
const outcome = await executor.execute(
|
|
bearerOperation(20),
|
|
{ limit: 1 },
|
|
{ routeId: ROUTE_ID, scope: scopeSnapshot() },
|
|
);
|
|
|
|
expect(outcome.kind).toBe("TRANSPORT_FAILURE");
|
|
expect(
|
|
outcome.kind === "TRANSPORT_FAILURE" ? outcome.failure.kind : null,
|
|
).toBe("TIMEOUT");
|
|
});
|
|
|
|
it("does not admit a body that completes after the deadline owns the execution", async () => {
|
|
let releaseBody: (() => void) | undefined;
|
|
const lateBody = new ReadableStream<Uint8Array>({
|
|
pull(controller) {
|
|
return new Promise<void>((resolve) => {
|
|
releaseBody = () => {
|
|
// The executor is expected to have cancelled this reader already;
|
|
// enqueueing into the closed controller then throws, which is the
|
|
// late producer this scenario is about.
|
|
try {
|
|
controller.enqueue(new TextEncoder().encode("[]"));
|
|
controller.close();
|
|
} catch {
|
|
// The stream was already cancelled by the deadline owner.
|
|
}
|
|
resolve();
|
|
};
|
|
});
|
|
},
|
|
});
|
|
const executor = createContractHttpExecutor({
|
|
baseUrl: "https://api.example/",
|
|
maxRetryAttempts: 0,
|
|
authProfiles: TEST_PROFILES,
|
|
attachCredentials: () =>
|
|
Object.freeze({
|
|
kind: "READY" as const,
|
|
headers: { authorization: "Bearer t" },
|
|
}),
|
|
fetcher: (async () =>
|
|
new Response(lateBody, {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
})) as unknown as typeof fetch,
|
|
});
|
|
|
|
const pending = executor.execute(
|
|
bearerOperation(10),
|
|
{ limit: 1 },
|
|
{ routeId: ROUTE_ID, scope: scopeSnapshot() },
|
|
);
|
|
setTimeout(() => releaseBody?.(), 40);
|
|
const outcome = await pending;
|
|
|
|
expect(outcome.kind).toBe("TRANSPORT_FAILURE");
|
|
expect(
|
|
outcome.kind === "TRANSPORT_FAILURE" ? outcome.failure.kind : null,
|
|
).toBe("TIMEOUT");
|
|
});
|
|
|
|
it("preserves the caller and the scope as distinct cancellation owners", async () => {
|
|
const observations: HttpExecutionObservation[] = [];
|
|
const makeExecutor = () =>
|
|
createContractHttpExecutor({
|
|
baseUrl: "https://api.example/",
|
|
maxRetryAttempts: 0,
|
|
authProfiles: TEST_PROFILES,
|
|
attachCredentials: () =>
|
|
Object.freeze({
|
|
kind: "READY" as const,
|
|
headers: { authorization: "Bearer t" },
|
|
}),
|
|
fetcher: (() =>
|
|
new Promise<Response>(() => {})) as unknown as typeof fetch,
|
|
observe: (observation) => observations.push(observation),
|
|
});
|
|
|
|
const callerController = new AbortController();
|
|
const callerPending = makeExecutor().execute(
|
|
bearerOperation(10_000),
|
|
{ limit: 1 },
|
|
{
|
|
routeId: ROUTE_ID,
|
|
scope: scopeSnapshot(),
|
|
signal: callerController.signal,
|
|
},
|
|
);
|
|
callerController.abort();
|
|
expect((await callerPending).kind).toBe("CANCELLED");
|
|
expect(observations.at(-1)?.cancellationOwner).toBe("CALLER");
|
|
|
|
const scopeController = new AbortController();
|
|
const scopePending = makeExecutor().execute(
|
|
bearerOperation(10_000),
|
|
{ limit: 1 },
|
|
{ routeId: ROUTE_ID, scope: scopeSnapshot(scopeController.signal) },
|
|
);
|
|
scopeController.abort();
|
|
expect((await scopePending).kind).toBe("TRANSPORT_FAILURE");
|
|
expect(observations.at(-1)?.cancellationOwner).toBe("SCOPE_FENCE");
|
|
});
|
|
|
|
it("observes a late native rejection without an unhandled rejection", async () => {
|
|
const rejections: unknown[] = [];
|
|
const onUnhandled = (event: PromiseRejectionEvent) => {
|
|
rejections.push(event.reason);
|
|
event.preventDefault();
|
|
};
|
|
globalThis.addEventListener?.(
|
|
"unhandledrejection",
|
|
onUnhandled as EventListener,
|
|
);
|
|
try {
|
|
const executor = createContractHttpExecutor({
|
|
baseUrl: "https://api.example/",
|
|
maxRetryAttempts: 0,
|
|
authProfiles: TEST_PROFILES,
|
|
attachCredentials: () =>
|
|
Object.freeze({
|
|
kind: "READY" as const,
|
|
headers: { authorization: "Bearer t" },
|
|
}),
|
|
fetcher: (() =>
|
|
new Promise<Response>((_resolve, reject) => {
|
|
setTimeout(() => reject(new Error("late native failure")), 30);
|
|
})) as unknown as typeof fetch,
|
|
});
|
|
|
|
const outcome = await executor.execute(
|
|
bearerOperation(5),
|
|
{ limit: 1 },
|
|
{ routeId: ROUTE_ID, scope: scopeSnapshot() },
|
|
);
|
|
expect(outcome.kind).toBe("TRANSPORT_FAILURE");
|
|
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
expect(rejections).toEqual([]);
|
|
} finally {
|
|
globalThis.removeEventListener?.(
|
|
"unhandledrejection",
|
|
onUnhandled as EventListener,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
/**
|
|
* LIVE-05. A deadline TIMEOUT is an operational failure of the API call, not a
|
|
* caller decision. Excluding it from `api.request.failed` hides exactly the
|
|
* class of outage the telemetry exists to surface.
|
|
*/
|
|
describe("LIVE-05 deadline timeouts reach failure telemetry", () => {
|
|
function projectorHarness() {
|
|
const emitted: string[] = [];
|
|
const recorded: string[] = [];
|
|
const project = createHttpObservationProjector({
|
|
diagnostics: {
|
|
record: (input) => recorded.push(input.eventId),
|
|
},
|
|
telemetry: {
|
|
emit: (eventName) => emitted.push(eventName),
|
|
},
|
|
});
|
|
return { emitted, recorded, project };
|
|
}
|
|
|
|
const base = Object.freeze({
|
|
routeId: ROUTE_ID,
|
|
operationId: "TEST_LIST_ENTITIES",
|
|
diagnosticsOperation: "test.read",
|
|
errorKind: "TIMEOUT",
|
|
attemptCount: 1,
|
|
durationMs: 10,
|
|
effect: "NOT_APPLICABLE" as const,
|
|
terminalReason: "TIMEOUT",
|
|
});
|
|
|
|
it("emits exactly one api.request.failed for a deadline timeout", () => {
|
|
const harness = projectorHarness();
|
|
harness.project(
|
|
Object.freeze({
|
|
...base,
|
|
outcome: "TRANSPORT_FAILURE" as const,
|
|
cancellationOwner: "DEADLINE" as const,
|
|
}),
|
|
);
|
|
expect(harness.emitted).toEqual(["api.request.failed"]);
|
|
expect(harness.recorded).toEqual(["http.request.completed"]);
|
|
});
|
|
|
|
it("emits nothing for caller, route and shutdown cancellation", () => {
|
|
for (const owner of [
|
|
"CALLER",
|
|
"ROUTE_TRANSITION",
|
|
"SCOPE_FENCE",
|
|
"APPLICATION_SHUTDOWN",
|
|
] as const) {
|
|
const harness = projectorHarness();
|
|
harness.project(
|
|
Object.freeze({
|
|
...base,
|
|
outcome: "CANCELLED" as const,
|
|
errorKind: "REQUEST_ABORTED",
|
|
cancellationOwner: owner,
|
|
}),
|
|
);
|
|
expect(harness.emitted).toEqual([]);
|
|
}
|
|
});
|
|
|
|
it("still emits for an ordinary network and auth integration failure", () => {
|
|
const network = projectorHarness();
|
|
network.project(
|
|
Object.freeze({
|
|
...base,
|
|
outcome: "TRANSPORT_FAILURE" as const,
|
|
errorKind: "NETWORK_FAILURE",
|
|
terminalReason: "NETWORK_FAILURE",
|
|
}),
|
|
);
|
|
expect(network.emitted).toEqual(["api.request.failed"]);
|
|
|
|
const auth = projectorHarness();
|
|
auth.project(
|
|
Object.freeze({
|
|
...base,
|
|
outcome: "AUTH_INTEGRATION_FAILURE" as const,
|
|
errorKind: "CREDENTIAL_OWNER_FAILED",
|
|
terminalReason: "AUTH_INTEGRATION_FAILURE",
|
|
}),
|
|
);
|
|
expect(auth.emitted).toEqual(["api.request.failed"]);
|
|
});
|
|
});
|