296 lines
9.3 KiB
TypeScript
296 lines
9.3 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
AnalysisCancelledError,
|
|
AnalysisCapabilityUnavailableError,
|
|
AnalysisInputError,
|
|
AnalysisOutputError,
|
|
AnalysisProtocolError,
|
|
AnalysisTimeoutError,
|
|
ArtifactOperationError,
|
|
BinaryTargetError,
|
|
ConfigurationError,
|
|
EvidenceFileError,
|
|
EvidenceIntegrityError,
|
|
EvidenceLimitError,
|
|
HopperCancelledError,
|
|
HopperProcessError,
|
|
HopperProtocolError,
|
|
HopperRemoteError,
|
|
HopperStartError,
|
|
HopperTimeoutError,
|
|
InvestigationWorkspaceError,
|
|
NoBinaryOpenError,
|
|
ProviderAdapterError,
|
|
ProviderSelectionError,
|
|
PermissionRequiredError,
|
|
UnknownRegistryError,
|
|
projectAnalysisError,
|
|
type AnalysisError,
|
|
type AnalysisErrorTag,
|
|
} from "../src/domain/errors.js";
|
|
import { ProcessCaptureError } from "../src/application/ProcessHarness.js";
|
|
import {
|
|
analysisErrorJsonSchema,
|
|
analysisErrorProjectionSchema,
|
|
} from "../src/contracts/errorSchemas.js";
|
|
|
|
describe("analysis error projection", () => {
|
|
it("uses the correct provider-neutral protocol tag", () => {
|
|
expect(new AnalysisProtocolError("invalid")._tag).toBe(
|
|
"AnalysisProtocolError",
|
|
);
|
|
});
|
|
|
|
it("projects stable, safe Linux startup failure codes", () => {
|
|
const expected = [
|
|
[70, "private_display_unavailable"],
|
|
[71, "x11_authorization_failed"],
|
|
[72, "unsupported_hopper_build"],
|
|
[73, "invalid_launch_command"],
|
|
[74, "process_ownership_mismatch"],
|
|
[75, "hopper_exited_during_startup"],
|
|
[76, "unsupported_demo_dialog"],
|
|
[77, "unexpected_display_geometry"],
|
|
[78, "x11_input_failed"],
|
|
[79, "runtime_dependency_unavailable"],
|
|
] as const;
|
|
for (const [exitCode, code] of expected) {
|
|
const projected = projectAnalysisError(new HopperProcessError(exitCode));
|
|
expect(projected).toMatchObject({
|
|
code: "provider_unavailable",
|
|
details: { failure_code: code, exit_code: exitCode },
|
|
});
|
|
expect(projected.message.length).toBeGreaterThan(20);
|
|
expect(JSON.stringify(projected)).not.toContain("/proc/");
|
|
}
|
|
});
|
|
|
|
it("projects every typed failure without secrets", () => {
|
|
const secretCause = new Error("secret-token");
|
|
const byTag = {
|
|
AnalysisProtocolError: new AnalysisProtocolError("protocol failed"),
|
|
AnalysisInputError: new AnalysisInputError("overview", {
|
|
cause: secretCause,
|
|
}),
|
|
AnalysisOutputError: new AnalysisOutputError(
|
|
"overview",
|
|
"invalid shape",
|
|
{
|
|
cause: secretCause,
|
|
},
|
|
),
|
|
AnalysisCapabilityUnavailableError:
|
|
new AnalysisCapabilityUnavailableError("fixture", "overview", "absent"),
|
|
AnalysisCancelledError: new AnalysisCancelledError("overview"),
|
|
AnalysisTimeoutError: new AnalysisTimeoutError("overview", 50),
|
|
ProviderSelectionError: new ProviderSelectionError("overview"),
|
|
ProviderAdapterError: new ProviderAdapterError("fixture", "overview", {
|
|
cause: secretCause,
|
|
}),
|
|
ArtifactOperationError: new ArtifactOperationError(
|
|
"inventory_artifact",
|
|
"integrity",
|
|
),
|
|
ProcessCaptureError: new ProcessCaptureError("capture failed", {
|
|
cause: secretCause,
|
|
}),
|
|
EvidenceIntegrityError: new EvidenceIntegrityError("integrity failed", {
|
|
cause: secretCause,
|
|
}),
|
|
EvidenceLimitError: new EvidenceLimitError("records", 10),
|
|
EvidenceFileError: new EvidenceFileError("read", "outside-root", {
|
|
cause: secretCause,
|
|
}),
|
|
InvestigationWorkspaceError: new InvestigationWorkspaceError(
|
|
"update",
|
|
"revision-conflict",
|
|
{ cause: secretCause },
|
|
),
|
|
UnknownRegistryError: new UnknownRegistryError("revision-conflict", {
|
|
cause: secretCause,
|
|
}),
|
|
HopperTimeoutError: new HopperTimeoutError(100),
|
|
HopperCancelledError: new HopperCancelledError(),
|
|
HopperProtocolError: new HopperProtocolError("wire failed", {
|
|
cause: secretCause,
|
|
}),
|
|
HopperRemoteError: new HopperRemoteError(7, "safe"),
|
|
HopperProcessError: new HopperProcessError(1),
|
|
HopperStartError: new HopperStartError({ cause: secretCause }),
|
|
ConfigurationError: new ConfigurationError("configuration failed", {
|
|
cause: secretCause,
|
|
}),
|
|
NoBinaryOpenError: new NoBinaryOpenError(),
|
|
BinaryTargetError: new BinaryTargetError(
|
|
"/secret/local/path",
|
|
"invalid",
|
|
{
|
|
cause: secretCause,
|
|
},
|
|
),
|
|
PermissionRequiredError: new PermissionRequiredError(
|
|
{
|
|
capability: "evidence_read",
|
|
roots: ["/workspace/evidence.json"],
|
|
executables: [],
|
|
environment_names: ["TOKEN_NAME"],
|
|
network: "none",
|
|
mount: false,
|
|
operation_identity: "read:evidence",
|
|
},
|
|
{ environment_names: ["TOKEN_NAME"] },
|
|
null,
|
|
false,
|
|
true,
|
|
),
|
|
} satisfies Readonly<Record<AnalysisErrorTag, AnalysisError>>;
|
|
const errors = Object.values(byTag);
|
|
const projected = errors.map(projectAnalysisError);
|
|
expect(projected).toHaveLength(25);
|
|
const serialized = JSON.stringify(projected);
|
|
for (const hidden of [
|
|
"secret-token",
|
|
"AnalysisCapabilityUnavailableError",
|
|
"declaredSha256",
|
|
])
|
|
expect(serialized).not.toContain(hidden);
|
|
expect(serialized).toContain("/secret/local/path");
|
|
expect(serialized).toContain('"environment_names":["TOKEN_NAME"]');
|
|
expect(
|
|
projectAnalysisError(byTag.AnalysisCapabilityUnavailableError),
|
|
).toMatchObject({
|
|
category: "unsupported_provider",
|
|
});
|
|
expect(
|
|
projectAnalysisError(
|
|
new ArtifactOperationError("inventory_artifact", "integrity", {
|
|
logicalPath: "main.js",
|
|
declaredSha256: "a".repeat(64),
|
|
calculatedSha256: "b".repeat(64),
|
|
unpacked: true,
|
|
}),
|
|
),
|
|
).toMatchObject({
|
|
category: "integrity_mismatch",
|
|
details: {
|
|
logical_path: "main.js",
|
|
declared_sha256: "a".repeat(64),
|
|
calculated_sha256: "b".repeat(64),
|
|
unpacked: true,
|
|
},
|
|
});
|
|
expect(projected.every(({ message }) => message.length > 0)).toBe(true);
|
|
expect(
|
|
projected.every(
|
|
(value) => analysisErrorProjectionSchema.safeParse(value).success,
|
|
),
|
|
).toBe(true);
|
|
expect(analysisErrorJsonSchema).toMatchObject({ oneOf: expect.any(Array) });
|
|
expect(
|
|
projectAnalysisError(
|
|
new ProcessCaptureError("terminal cleanup failed", {
|
|
reason: "cleanup_incomplete",
|
|
cleanupResources: ["process_group"],
|
|
}),
|
|
),
|
|
).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: { cleanup: "incomplete", resources: ["process_group"] },
|
|
});
|
|
expect(
|
|
projected
|
|
.map(({ message }) => message)
|
|
.filter(
|
|
(message) =>
|
|
!/try again|run `rea doctor`|current target|when ready|smaller request|smaller artifact|fresh copy|re-import|reduce|inline evidence|configured evidence directory|evidence file|allow overwrite|refresh the current state|reported setting|call open_binary|supported file|review capture policy|review the requested scope/iu.test(
|
|
message,
|
|
),
|
|
),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("validates every closed error-reason variant against the generated contract", () => {
|
|
const variants: AnalysisError[] = [
|
|
...(
|
|
[
|
|
"cancelled",
|
|
"format",
|
|
"integrity",
|
|
"limit",
|
|
"path",
|
|
"unavailable",
|
|
"io",
|
|
] as const
|
|
).map(
|
|
(reason) => new ArtifactOperationError("inventory_artifact", reason),
|
|
),
|
|
...(
|
|
[
|
|
"disabled",
|
|
"outside-root",
|
|
"not-file",
|
|
"too-large",
|
|
"exists",
|
|
"invalid-json",
|
|
"io",
|
|
] as const
|
|
).map((reason) => new EvidenceFileError("read", reason)),
|
|
...(
|
|
[
|
|
"disabled",
|
|
"outside-root",
|
|
"not-file",
|
|
"too-large",
|
|
"invalid-json",
|
|
"integrity",
|
|
"locked",
|
|
"revision-conflict",
|
|
"name-conflict",
|
|
"io",
|
|
] as const
|
|
).map((reason) => new InvestigationWorkspaceError("update", reason)),
|
|
...(
|
|
[
|
|
"not-found",
|
|
"already-exists",
|
|
"revision-conflict",
|
|
"invalid-transition",
|
|
"integrity",
|
|
"limit",
|
|
] as const
|
|
).map((reason) => new UnknownRegistryError(reason)),
|
|
...(
|
|
[
|
|
"capture_failed",
|
|
"cleanup_incomplete",
|
|
"permission_required",
|
|
"cancelled",
|
|
] as const
|
|
).map(
|
|
(reason) =>
|
|
new ProcessCaptureError("SECRET capture diagnostic", { reason }),
|
|
),
|
|
...(
|
|
[
|
|
"remote",
|
|
"authorization",
|
|
"invalid_request",
|
|
"bridge_exception",
|
|
] as const
|
|
).map((diagnostic) => new HopperRemoteError(9, "safe", diagnostic)),
|
|
];
|
|
|
|
expect(variants).toHaveLength(38);
|
|
for (const variant of variants) {
|
|
const projected = projectAnalysisError(variant);
|
|
const parsed = analysisErrorProjectionSchema.safeParse(projected);
|
|
expect(parsed, JSON.stringify({ variant, projected })).toMatchObject({
|
|
success: true,
|
|
});
|
|
expect(projected.code).toMatch(/^[a-z][a-z0-9_]*$/u);
|
|
expect(JSON.stringify(projected)).not.toContain("SECRET");
|
|
}
|
|
});
|
|
});
|