test: Phase 4 new dimensions — proptest, mock chaos modes, jest-axe a11y (#4501)

This commit is contained in:
Steven Enamakel
2026-07-04 09:56:25 -07:00
committed by GitHub
parent f89c5e0473
commit acc3c3fabe
12 changed files with 423 additions and 12 deletions
Generated
+24 -1
View File
@@ -4363,6 +4363,7 @@ dependencies = [
"postgres",
"ppt-rs",
"prometheus",
"proptest",
"prost",
"rand 0.10.1",
"rdev",
@@ -5046,12 +5047,16 @@ version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
dependencies = [
"bit-set",
"bit-vec",
"bitflags 2.11.1",
"num-traits",
"rand 0.9.4",
"rand_chacha 0.9.0",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
"tempfile",
"unarray",
]
@@ -5129,6 +5134,12 @@ version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]]
name = "quick-error"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quick-error"
version = "2.0.1"
@@ -5728,6 +5739,18 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "rusty-fork"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
dependencies = [
"fnv",
"quick-error 1.2.3",
"tempfile",
"wait-timeout",
]
[[package]]
name = "ryu"
version = "1.0.23"
@@ -6629,7 +6652,7 @@ dependencies = [
"fax",
"flate2",
"half",
"quick-error",
"quick-error 2.0.1",
"weezl",
"zune-jpeg",
]
+3
View File
@@ -300,6 +300,9 @@ filetime = "0.2"
# beat without real-time waits. Test-only — the runtime feature set in
# `[dependencies]` (`full`) intentionally excludes it.
tokio = { version = "1", features = ["test-util"] }
# Property-based testing for the adversarial-input surfaces (command classifier,
# encryption round-trip) — plan.md §6.3. Version already resolved in Cargo.lock.
proptest = "1"
[features]
default = ["tokenjuice-treesitter"]
+2
View File
@@ -137,6 +137,7 @@
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/d3-force": "^3.0.10",
"@types/debug": "^4.1.12",
"@types/jest-axe": "^3.5.9",
"@types/node": "^25.0.10",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
@@ -158,6 +159,7 @@
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"husky": "^9.1.7",
"jest-axe": "^10.0.0",
"jsdom": "^28.0.0",
"knip": "^6.3.1",
"postcss": "^8.5.6",
@@ -0,0 +1,80 @@
/**
* Accessibility smoke lane (plan.md §7 — the frontend had zero a11y assertions).
* Renders a few high-traffic, self-contained components and runs axe-core over
* the output, asserting no violations. jsdom can't compute layout, so
* color-contrast is auto-skipped; this catches the structural a11y bugs that
* matter — missing roles/labels, invalid ARIA, unlabelled controls.
*
* Kept deliberately small and provider-light so it stays fast and stable; grow
* it screen-by-screen rather than pulling in the full app shell.
*/
import { configureStore } from '@reduxjs/toolkit';
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
import { Provider } from 'react-redux';
import { describe, expect, it, vi } from 'vitest';
import chatRuntimeReducer, {
type ArtifactSnapshot,
type PendingApproval,
setPendingApprovalForThread,
} from '../../store/chatRuntimeSlice';
import ApprovalRequestCard from '../chat/ApprovalRequestCard';
import ArtifactCard from '../chat/ArtifactCard';
vi.mock('../../services/artifactDownloadService', () => ({
saveArtifactViaDialog: vi.fn(),
revealArtifactInFileManager: vi.fn(),
}));
vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
async function expectNoViolations(container: HTMLElement) {
const results = await axe(container);
expect(results.violations).toEqual([]);
}
describe('accessibility smoke', () => {
it('ArtifactCard (ready) has no axe violations', async () => {
const artifact: ArtifactSnapshot = {
artifactId: 'a-1',
kind: 'presentation',
title: 'Quarterly Deck',
status: 'ready',
sizeBytes: 4096,
path: 'a-1/deck.pptx',
updatedAt: 0,
};
const { container } = render(<ArtifactCard artifact={artifact} />);
await expectNoViolations(container);
});
it('ArtifactCard (failed with error) has no axe violations', async () => {
const artifact: ArtifactSnapshot = {
artifactId: 'a-2',
kind: 'document',
title: 'Report',
status: 'failed',
error: 'producer crashed',
updatedAt: 0,
};
const { container } = render(<ArtifactCard artifact={artifact} onRetry={vi.fn()} />);
await expectNoViolations(container);
});
it('ApprovalRequestCard has no axe violations', async () => {
const approval: PendingApproval = {
requestId: 'req-1',
toolName: 'shell',
message: 'Run `shell` — shell (18 bytes of arguments)',
command: 'pip show yfinance',
};
const store = configureStore({ reducer: { chatRuntime: chatRuntimeReducer } });
store.dispatch(setPendingApprovalForThread({ threadId: 't1', approval }));
const { container } = render(
<Provider store={store}>
<ApprovalRequestCard threadId="t1" approval={approval} />
</Provider>
);
await expectNoViolations(container);
});
});
+16 -3
View File
@@ -434,9 +434,22 @@ P0, most P1 "untested" claims were **inaccurate against current `main`**. Verifi
the ~20 controller domains with no `tests/` reference (the Phase 0 inventory allowlist to burn
down). Approval×turn integration, journey spec, and the Playwright approval mirror need WDIO/PW.
**Phase 4 — new dimensions (ongoing)**
- proptest + cargo-fuzz targets; scoped cargo-mutants; jest-axe lane; migration fixture;
duration report; mock chaos modes + contract tests.
**Phase 4 — new dimensions** — ⏳ partial (PR: `test/phase4-new-dimensions`). Unlike the §4 backlog,
these are genuinely greenfield (the suite had zero of them), so they were built and validated:
- [x] **proptest** (was absent): added as a dev-dep; property suite for the command classifier +
path check (never-panic on arbitrary/unicode input; fail-closed redirect floor). NOTE: an
encryption round-trip property was trialled but dropped — under coverage Argon2id is ~2.5s/case,
so it held the lib-test binary ~60s and deterministically exposed a pre-existing env-var race in
the unrelated `config::schema::load` env-overlay tests; the round-trip stays covered by the
Phase 1 fixed-input tests.
- [x] **Mock chaos modes** (§5.3): `httpFaultRules` gains `mode: "reset"` (connection reset) and
`mode: "malformed"` (non-JSON 200) beyond clean statuses; `scripts/mock-api/chaos.test.mjs`
drives the real server (auto-run by the Phase 0 `test:scripts` job).
- [x] **jest-axe a11y smoke lane** (§7 — the frontend had zero a11y assertions): axe-core over
ArtifactCard + ApprovalRequestCard asserting no violations.
- [ ] Still open (larger tooling / own PRs): cargo-fuzz targets, scoped cargo-mutants on the P0
zones, criterion micro-benches, migration snapshot fixture, per-suite duration report,
mock↔backend contract test.
---
+102
View File
@@ -220,6 +220,9 @@ importers:
'@types/debug':
specifier: ^4.1.12
version: 4.1.13
'@types/jest-axe':
specifier: ^3.5.9
version: 3.5.9
'@types/node':
specifier: ^25.0.10
version: 25.6.0
@@ -283,6 +286,9 @@ importers:
husky:
specifier: ^9.1.7
version: 9.1.7
jest-axe:
specifier: ^10.0.0
version: 10.0.0
jsdom:
specifier: ^28.0.0
version: 28.1.0(@noble/hashes@2.2.0)
@@ -888,6 +894,10 @@ packages:
resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
'@jest/schemas@29.6.3':
resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
'@jest/schemas@30.0.5':
resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -1765,6 +1775,9 @@ packages:
resolution: {integrity: sha512-avtjtIQ019sZW3FklpmNNsQOnYZjCHpnVxgDGElfZb+AaR4AvtHNlxXLJp+iqEfSK+Xok8MJarJqIgCaWcF40Q==}
engines: {node: '>= 14'}
'@sinclair/typebox@0.27.10':
resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==}
'@sinclair/typebox@0.34.49':
resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==}
@@ -2009,6 +2022,12 @@ packages:
'@types/istanbul-reports@3.0.4':
resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==}
'@types/jest-axe@3.5.9':
resolution: {integrity: sha512-z98CzR0yVDalCEuhGXXO4/zN4HHuSebAukXDjTLJyjEAgoUf1H1i+sr7SUB/mz8CRS/03/XChsx0dcLjHkndoQ==}
'@types/jest@30.0.0':
resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@@ -2460,6 +2479,14 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
axe-core@3.5.6:
resolution: {integrity: sha512-LEUDjgmdJoA3LqklSTwKYqkjcZ4HKc4ddIYGSAiSkr46NTjzg2L9RNB+lekO9P7Dlpa87+hBtzc2Fzn/+GUWMQ==}
engines: {node: '>=4'}
axe-core@4.10.2:
resolution: {integrity: sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w==}
engines: {node: '>=4'}
b4a@1.8.0:
resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==}
peerDependencies:
@@ -3029,6 +3056,10 @@ packages:
didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
diff-sequences@29.6.3:
resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
diff@5.2.2:
resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==}
engines: {node: '>=0.3.1'}
@@ -4009,10 +4040,26 @@ packages:
javascript-natural-sort@0.7.1:
resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==}
jest-axe@10.0.0:
resolution: {integrity: sha512-9QR0M7//o5UVRnEUUm68IsGapHrcKGakYy9dKWWMX79LmeUKguDI6DREyljC5I13j78OUmtKLF5My6ccffLFBg==}
engines: {node: '>= 16.0.0'}
jest-diff@29.7.0:
resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
jest-diff@30.3.0:
resolution: {integrity: sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
jest-get-type@29.6.3:
resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
jest-matcher-utils@29.2.2:
resolution: {integrity: sha512-4DkJ1sDPT+UX2MR7Y3od6KtvRi9Im1ZGLGgdLFLm4lPexbTaCgJW5NN3IOXlQHF7NSHY/VHhflQ+WoKtD/vyCw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
jest-matcher-utils@30.3.0:
resolution: {integrity: sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -4859,6 +4906,10 @@ packages:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
pretty-format@29.7.0:
resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
pretty-format@30.3.0:
resolution: {integrity: sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -6635,6 +6686,10 @@ snapshots:
'@types/node': 25.6.0
jest-regex-util: 30.0.1
'@jest/schemas@29.6.3':
dependencies:
'@sinclair/typebox': 0.27.10
'@jest/schemas@30.0.5':
dependencies:
'@sinclair/typebox': 0.34.49
@@ -7329,6 +7384,8 @@ snapshots:
- encoding
- supports-color
'@sinclair/typebox@0.27.10': {}
'@sinclair/typebox@0.34.49': {}
'@sindresorhus/merge-streams@4.0.0': {}
@@ -7547,6 +7604,16 @@ snapshots:
dependencies:
'@types/istanbul-lib-report': 3.0.3
'@types/jest-axe@3.5.9':
dependencies:
'@types/jest': 30.0.0
axe-core: 3.5.6
'@types/jest@30.0.0':
dependencies:
expect: 30.3.0
pretty-format: 30.3.0
'@types/json-schema@7.0.15': {}
'@types/json5@0.0.29': {}
@@ -8215,6 +8282,10 @@ snapshots:
dependencies:
possible-typed-array-names: 1.1.0
axe-core@3.5.6: {}
axe-core@4.10.2: {}
b4a@1.8.0: {}
bail@2.0.2: {}
@@ -8814,6 +8885,8 @@ snapshots:
didyoumean@1.2.2: {}
diff-sequences@29.6.3: {}
diff@5.2.2: {}
diff@8.0.4: {}
@@ -10018,6 +10091,20 @@ snapshots:
javascript-natural-sort@0.7.1: {}
jest-axe@10.0.0:
dependencies:
axe-core: 4.10.2
chalk: 4.1.2
jest-matcher-utils: 29.2.2
lodash.merge: 4.6.2
jest-diff@29.7.0:
dependencies:
chalk: 4.1.2
diff-sequences: 29.6.3
jest-get-type: 29.6.3
pretty-format: 29.7.0
jest-diff@30.3.0:
dependencies:
'@jest/diff-sequences': 30.3.0
@@ -10025,6 +10112,15 @@ snapshots:
chalk: 4.1.2
pretty-format: 30.3.0
jest-get-type@29.6.3: {}
jest-matcher-utils@29.2.2:
dependencies:
chalk: 4.1.2
jest-diff: 29.7.0
jest-get-type: 29.6.3
pretty-format: 29.7.0
jest-matcher-utils@30.3.0:
dependencies:
'@jest/get-type': 30.1.0
@@ -11195,6 +11291,12 @@ snapshots:
ansi-styles: 5.2.0
react-is: 17.0.2
pretty-format@29.7.0:
dependencies:
'@jest/schemas': 29.6.3
ansi-styles: 5.2.0
react-is: 18.3.1
pretty-format@30.3.0:
dependencies:
'@jest/schemas': 30.0.5
+88
View File
@@ -0,0 +1,88 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
getMockServerPort,
resetMockBehavior,
setMockBehavior,
startMockServer,
stopMockServer,
} from "./index.mjs";
// Chaos-toolkit coverage for the mock backend's httpFaultRules engine
// (plan.md §5.3). Beyond clean HTTP error statuses, authors need to reproduce
// two real outage shapes: a connection reset mid-response, and a 200 with a
// non-JSON body. These tests drive the real server over a socket.
function base() {
const port = getMockServerPort();
assert.ok(port, "mock server must be running");
return `http://127.0.0.1:${port}`;
}
function setFaultRules(rules) {
setMockBehavior("httpFaultRules", JSON.stringify(rules));
}
test.beforeEach(async () => {
await stopMockServer();
resetMockBehavior();
await startMockServer();
});
test.afterEach(async () => {
await stopMockServer();
});
test("mode:reset tears down the connection (client sees a network error)", async () => {
setFaultRules([{ contains: "/chaos-reset", mode: "reset" }]);
await assert.rejects(
fetch(`${base()}/chaos-reset`),
(err) => err instanceof TypeError || /reset|hang up|ECONNRESET|fetch failed/i.test(String(err)),
"a reset rule must surface as a client-side network error, not a clean response",
);
});
test("mode:malformed returns a 200 with a body that is not valid JSON", async () => {
setFaultRules([{ contains: "/chaos-malformed", mode: "malformed" }]);
const res = await fetch(`${base()}/chaos-malformed`);
assert.equal(res.status, 200, "malformed mode defaults to a 200 status");
const text = await res.text();
assert.ok(text.length > 0, "malformed body must be non-empty");
assert.throws(
() => JSON.parse(text),
"the malformed body must not parse as JSON (that is the whole point)",
);
});
test("mode:malformed honours a custom status and body override", async () => {
setFaultRules([
{ contains: "/chaos-custom", mode: "malformed", status: 502, body: "upstream said <html>" },
]);
const res = await fetch(`${base()}/chaos-custom`);
assert.equal(res.status, 502);
assert.equal(await res.text(), "upstream said <html>");
});
test("default mode still injects a clean HTTP error status + JSON envelope", async () => {
// Regression guard: adding chaos modes must not change the existing
// status-injection behaviour when no mode is set.
setFaultRules([{ contains: "/chaos-status", status: 503, error: "down for maintenance" }]);
const res = await fetch(`${base()}/chaos-status`);
assert.equal(res.status, 503);
const parsed = await res.json();
assert.equal(parsed.success, false);
assert.equal(parsed.error, "down for maintenance");
});
test("a request that matches no fault rule is unaffected", async () => {
setFaultRules([{ contains: "/chaos-reset", mode: "reset" }]);
const res = await fetch(`${base()}/__admin/health`);
assert.equal(res.status, 200, "unmatched requests must pass through to the real handler");
});
+28
View File
@@ -155,6 +155,34 @@ async function maybeApplyGlobalBehavior(ctx) {
for (const rule of rules) {
if (!ruleMatches(rule, ctx)) continue;
const mode = typeof rule.mode === "string" ? rule.mode : "status";
// Chaos mode "reset": tear down the socket mid-response so the client sees
// a connection reset (ECONNRESET / "socket hang up") rather than a clean
// HTTP status — a real outage shape the status path can't reproduce.
if (mode === "reset") {
console.warn(
`[MockServer] Injected connection reset ${ctx.method} ${ctx.url}`,
);
ctx.res.socket?.destroy();
return true;
}
// Chaos mode "malformed": a 200 carrying a non-JSON body, so the caller's
// JSON parse throws instead of receiving a clean error envelope.
if (mode === "malformed") {
const status = Number(rule.status || 200);
const raw = typeof rule.body === "string" ? rule.body : "<<not-json>>{";
console.warn(
`[MockServer] Injected malformed body ${ctx.method} ${ctx.url} -> ${status}`,
);
setCors(ctx.res);
ctx.res.writeHead(status, { "Content-Type": "application/json" });
ctx.res.end(raw);
return true;
}
// Default mode "status": a clean HTTP error status with a JSON body.
const status = Number(rule.status || 500);
const body =
rule.body && typeof rule.body === "object"
+14 -8
View File
@@ -526,21 +526,27 @@ impl EnvLookup for HashMapEnv {
#[test]
fn env_overlay_toggles_agent_tracing_capture_content() {
// Off by default; the opt-in env var enables prompt/reply export.
// Serialize with the sibling env-overlay tests (TEST_ENV_LOCK note at the
// top of the file) so a concurrent test's env mutation can't race in.
let _g = env_lock();
// ON by default since #4498 (`default_capture_content() == true` — traces
// without content aren't actionable in Langfuse). This assertion was left
// asserting the pre-#4498 `false` default and is corrected here.
let mut cfg = Config::default();
assert!(!cfg.observability.agent_tracing.capture_content);
cfg.apply_env_overlay_with(
&HashMapEnv::new().with("OPENHUMAN_AGENT_TRACING_CAPTURE_CONTENT", "true"),
);
assert!(cfg.observability.agent_tracing.capture_content);
// An explicit falsy value turns it back off.
let mut cfg = Config::default();
cfg.observability.agent_tracing.capture_content = true;
// An explicit falsy env value turns it off.
cfg.apply_env_overlay_with(
&HashMapEnv::new().with("OPENHUMAN_AGENT_TRACING_CAPTURE_CONTENT", "off"),
);
assert!(!cfg.observability.agent_tracing.capture_content);
// A truthy value turns it back on.
cfg.apply_env_overlay_with(
&HashMapEnv::new().with("OPENHUMAN_AGENT_TRACING_CAPTURE_CONTENT", "true"),
);
assert!(cfg.observability.agent_tracing.capture_content);
}
#[test]
+10
View File
@@ -314,4 +314,14 @@ mod tests {
let payload = k.encrypt(b"").expect("encrypt empty");
assert_eq!(k.decrypt(&payload).expect("decrypt empty"), b"");
}
// NOTE: an `encrypt_decrypt_round_trips_for_arbitrary_input` proptest was
// trialled here but removed: under coverage instrumentation Argon2id runs
// ~2.5s/case, so a 24-case property held the lib-test binary for ~60s and
// deterministically widened a pre-existing env-var race in the unrelated
// `config::schema::load` env-overlay tests (they mutate process-global env
// without per-test serialization). The round-trip is already covered by the
// fixed-input tests above (round-trip, tamper, KDF determinism); the
// property-based *fuzzing* value lives in the fast, panic-focused
// `security::policy::proptest_tests` instead.
}
+3
View File
@@ -20,3 +20,6 @@ use std::path::{Path, PathBuf};
#[cfg(test)]
#[path = "policy_tests.rs"]
mod tests;
#[cfg(test)]
mod proptest_tests;
@@ -0,0 +1,53 @@
//! Property-based tests for the autonomy classifier's adversarial-input
//! surfaces (plan.md §6.3 — proptest was previously absent). The command
//! classifier and path checks run on fully untrusted strings from the
//! LLM/user, so the central properties are "never panics on any input" and a
//! handful of fail-closed invariants that must hold for *all* inputs, not just
//! the hand-enumerated cases in `policy_tests.rs`.
use super::types::{CommandClass, SecurityPolicy};
use proptest::prelude::*;
proptest! {
/// The classifier + allowlist must never panic, regardless of shell
/// metacharacters, redirects, pipes, or embedded control characters.
#[test]
fn classifier_never_panics_on_regex_strings(cmd in ".*") {
let p = SecurityPolicy::default();
let _ = p.classify_command(&cmd);
let _ = p.command_risk_level(&cmd);
let _ = p.is_command_allowed(&cmd);
let _ = p.check_gated_command(&cmd);
}
/// Same, but over fully arbitrary unicode (includes NUL, newlines, and
/// multibyte scalars the `.*` strategy skips).
#[test]
fn classifier_never_panics_on_arbitrary_unicode(cmd in any::<String>()) {
let p = SecurityPolicy::default();
let _ = p.classify_command(&cmd);
let _ = p.command_risk_level(&cmd);
let _ = p.is_command_allowed(&cmd);
}
/// Fail-closed floor: a quote-free command with an appended unquoted
/// redirect always classifies as at least `Write` (`cmd > file` writes
/// `file`), no matter what the base command is.
#[test]
fn appended_redirect_forces_at_least_write(prefix in "[a-z][a-z ]{0,40}") {
let p = SecurityPolicy::default();
let cmd = format!("{prefix} > out.txt");
prop_assert!(
p.classify_command(&cmd) >= CommandClass::Write,
"an unquoted redirect must lift the class to >= Write: {cmd:?}"
);
}
/// The string-level path check must never panic on arbitrary paths
/// (traversal, NUL bytes, tilde, URL-encoding, unicode).
#[test]
fn path_string_check_never_panics(path in ".*") {
let p = SecurityPolicy::default();
let _ = p.is_path_string_allowed(&path);
}
}