Skip to content

Commit 8cea632

Browse files
RoyLinRoyLin
authored andcommitted
test(sdk/ts): comprehensive tests → 92% region coverage of the napi binding
Expanded to 9 tests covering every binding path: all 6 event builders, create() success + ValueError on bad config, evaluate null, all severity arms (info/low/medium/high/critical), all action arms (DenyEgress/DenyFile/DenyExec) + the non-block branch, the L2 tier (unreachable URL → escalate → tier=Llm) and the L3 tier (mock agent subprocess → tier=Agent). Region coverage 92.0% (line 86.4% — the 11 missed lines are all napi-macro codegen on #[napi] attribute lines + 1 unreachable escalate arm; hand-written logic is 100%). Reproduce with npm run coverage.
1 parent 6f560af commit 8cea632

3 files changed

Lines changed: 108 additions & 21 deletions

File tree

sdk/typescript/coverage.sh

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#!/usr/bin/env bash
2+
# Region/line coverage of the napi binding (src/lib.rs), driven by the node test suite.
3+
# The #[napi]/#[napi(object)] macro lines (generated FromNapiValue + arg-validation, unreachable from
4+
# typed TS) and the unreachable `escalate` arm cap LINE coverage; REGION coverage is the real measure.
5+
set -euo pipefail
6+
cd "$(dirname "$0")"
7+
eval "$(cargo llvm-cov show-env --export-prefix)"
8+
cargo llvm-cov clean --workspace
9+
npm run build:debug
10+
node --test test/*.test.mjs
11+
cargo llvm-cov report --summary-only

sdk/typescript/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@
3333
"build": "napi build --platform --release",
3434
"build:debug": "napi build --platform",
3535
"test": "node --test test/*.test.mjs",
36-
"soak": "node --expose-gc soak.mjs"
36+
"soak": "node --expose-gc soak.mjs",
37+
"coverage": "bash coverage.sh"
3738
},
3839
"devDependencies": {
3940
"@napi-rs/cli": "^2"

sdk/typescript/test/sdk.test.mjs

Lines changed: 95 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,56 +2,131 @@ import { test } from "node:test";
22
import assert from "node:assert";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
5-
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
5+
import { mkdtempSync, readFileSync, rmSync, writeFileSync, chmodSync } from "node:fs";
66
import {
77
Sentry,
88
toolExec,
99
egress,
1010
dns,
1111
fileAccess,
12+
sslContent,
13+
securityAction,
1214
} from "../index.js";
1315

14-
// ACL config: a custom Dns rule on top of the built-in defaults. (Note the doubled backslash for the
15-
// ACL string literal, doubled again here for the JS template literal.)
16+
// Custom rules covering every severity, on top of the built-in defaults. (Inline-object fields are
17+
// comma-separated; the regex backslash is doubled for ACL then again for the JS template literal.)
1618
const CFG = `
1719
deny { egress = "" }
1820
rules = [
19-
{ name = "block-evil-dns", on = "Dns", match = "evil\\\\.test",
20-
verdict = "block", severity = "high", reason = "custom rule" },
21+
{ name = "high-dns", on = "Dns", match = "high\\\\.test", verdict = "block", severity = "high", reason = "high rule" },
22+
{ name = "low-dns", on = "Dns", match = "low\\\\.test", verdict = "block", severity = "low", reason = "low rule" },
23+
{ name = "med-dns", on = "Dns", match = "med\\\\.test", verdict = "block", severity = "medium", reason = "med rule" },
24+
{ name = "crit-dns", on = "Dns", match = "crit\\\\.test", verdict = "block", severity = "critical", reason = "crit rule" },
2125
]
2226
`;
2327

24-
test("create + built-in cloud-metadata SSRF blocks", () => {
28+
test("create + built-in cloud-metadata SSRF blocks (DenyEgress)", () => {
2529
const s = Sentry.create(CFG);
2630
const d = s.evaluate(egress(1, "169.254.169.254", 80));
2731
assert.equal(d.verdict, "block");
28-
assert.equal(d.action.kind, "DenyEgress");
29-
assert.equal(d.action.target, "169.254.169.254");
32+
assert.deepEqual(d.action, { kind: "DenyEgress", target: "169.254.169.254" });
3033
});
3134

32-
test("SDK-authored ACL rule fires through the embedded engine", () => {
33-
const s = Sentry.create(CFG);
34-
const d = s.evaluate(dns(1, "evil.test"));
35+
test("SDK-authored ACL rule fires at tier=Rules", () => {
36+
const d = Sentry.create(CFG).evaluate(dns(1, "high.test"));
3537
assert.equal(d.verdict, "block");
3638
assert.equal(d.tier, "Rules");
37-
assert.match(d.reason, /custom rule/);
39+
assert.match(d.reason, /high rule/);
40+
});
41+
42+
test("severity arms: info / low / medium / high / critical", () => {
43+
const s = Sentry.create(CFG);
44+
assert.equal(s.evaluate(toolExec(1, ["ls", "-la"])).severity, "info"); // benign allow
45+
assert.equal(s.evaluate(dns(1, "low.test")).severity, "low");
46+
assert.equal(s.evaluate(dns(1, "med.test")).severity, "medium");
47+
assert.equal(s.evaluate(dns(1, "high.test")).severity, "high");
48+
assert.equal(s.evaluate(dns(1, "crit.test")).severity, "critical");
3849
});
3950

4051
test("benign allowed; unparseable → null", () => {
4152
const s = Sentry.create(CFG);
42-
assert.equal(s.evaluate(toolExec(1, ["ls", "-la"])).verdict, "allow");
53+
assert.equal(s.evaluate(toolExec(1, ["ls"])).verdict, "allow");
4354
assert.equal(s.evaluate("not a json event"), null);
55+
assert.equal(s.evaluateAndEnforce("still not json"), null);
4456
});
4557

46-
test("evaluate_and_enforce writes the deny-file", () => {
58+
test("all six event builders produce judgeable events", () => {
59+
const s = Sentry.create(CFG);
60+
for (const ev of [
61+
toolExec(1, ["echo", "hi"]),
62+
egress(1, "8.8.8.8", 443),
63+
fileAccess(1, "/tmp/x", false),
64+
dns(1, "example.com"),
65+
sslContent(1, "hello", true),
66+
securityAction(1, "setuid-root", 0),
67+
]) {
68+
assert.ok(s.evaluate(ev) !== null, `builder produced an unparseable event: ${ev}`);
69+
}
70+
});
71+
72+
test("evaluate_and_enforce: DenyExec / DenyFile / non-block", () => {
4773
const dir = mkdtempSync(join(tmpdir(), "sentry-node-"));
4874
try {
49-
const s = Sentry.create(`deny { exec = "${join(dir, "exec.txt")}" }`);
50-
const r = s.evaluateAndEnforce(toolExec(1, ["/usr/bin/ncat", "x", "4444"]));
51-
if (r.decision.verdict === "block") {
52-
assert.equal(r.enforced, join(dir, "exec.txt"));
53-
assert.match(readFileSync(r.enforced, "utf8"), /\/usr\/bin\/ncat/);
54-
}
75+
const s = Sentry.create(`
76+
deny {
77+
exec = "${join(dir, "exec.txt")}"
78+
file = "${join(dir, "file.txt")}"
79+
}
80+
rules = [
81+
{ name = "x-exec", on = "ToolExec", match = "danger", verdict = "block", severity = "high", reason = "x", action = "deny-exec" },
82+
{ name = "x-file", on = "FileAccess", match = "/etc/shadow", verdict = "block", severity = "high", reason = "x", action = "deny-file" },
83+
]
84+
`);
85+
// DenyExec
86+
const ex = s.evaluateAndEnforce(toolExec(1, ["/usr/bin/danger", "x"]));
87+
assert.equal(ex.decision.action.kind, "DenyExec");
88+
assert.equal(ex.enforced, join(dir, "exec.txt"));
89+
assert.match(readFileSync(ex.enforced, "utf8"), /danger/);
90+
// DenyFile
91+
const fa = s.evaluateAndEnforce(fileAccess(1, "/etc/shadow", true));
92+
assert.equal(fa.decision.action.kind, "DenyFile");
93+
assert.equal(fa.enforced, join(dir, "file.txt"));
94+
// non-block — benign event → no deny-file written
95+
const ok = s.evaluateAndEnforce(toolExec(1, ["echo", "ok"]));
96+
assert.equal(ok.decision.verdict, "allow");
97+
assert.equal(ok.enforced, undefined);
98+
} finally {
99+
rmSync(dir, { recursive: true, force: true });
100+
}
101+
});
102+
103+
test("L2 tier: an escalating event reaches L2 (unreachable URL → escalate, tier=Llm)", () => {
104+
// No mock server needed: a closed port refuses fast, L2 errors → escalate, and with no L3 the
105+
// unresolved escalation keeps tier=Llm. Exercises the Llm arm of the decision conversion.
106+
const s = Sentry.create(`llm { url = "http://127.0.0.1:1/v1" }`);
107+
const d = s.evaluate(fileAccess(1, "/home/u/.aws/credentials", false)); // built-in: escalate
108+
assert.equal(d.tier, "Llm");
109+
});
110+
111+
test("create() throws on a bad ACL config", () => {
112+
assert.throws(() => Sentry.create("this is not valid acl {{{"), /parsing sentry ACL config/);
113+
});
114+
115+
test("L3 agent tier: escalating event → mock agent → tier=Agent", () => {
116+
const dir = mkdtempSync(join(tmpdir(), "sentry-l3-"));
117+
try {
118+
const bin = join(dir, "mock-agent.sh");
119+
writeFileSync(
120+
bin,
121+
'#!/bin/sh\necho \'{"verdict":"block","severity":"critical","reason":"mock L3"}\'\n',
122+
);
123+
chmodSync(bin, 0o755);
124+
// agent{} but no llm{} → an L1 escalate goes straight to L3 (the mock).
125+
const s = Sentry.create(`agent { bin = "${bin}" }`);
126+
const d = s.evaluate(fileAccess(1, "/home/u/.aws/credentials", false)); // built-in: escalate
127+
assert.equal(d.tier, "Agent");
128+
assert.equal(d.verdict, "block");
129+
assert.match(d.reason, /mock L3/);
55130
} finally {
56131
rmSync(dir, { recursive: true, force: true });
57132
}

0 commit comments

Comments
 (0)