cedar-mcp-server 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.editorconfig +12 -0
- package/.github/workflows/ci.yml +31 -0
- package/.github/workflows/release.yml +42 -0
- package/.nvmrc +1 -0
- package/CHANGELOG.md +241 -0
- package/CONTRIBUTING.md +83 -0
- package/LICENSE +182 -0
- package/README.md +1635 -0
- package/SECURITY.md +37 -0
- package/dist/http-server.d.ts +61 -0
- package/dist/http-server.d.ts.map +1 -0
- package/dist/http-server.js +194 -0
- package/dist/http-server.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +270 -0
- package/dist/index.js.map +1 -0
- package/dist/parser/policy-ast.d.ts +49 -0
- package/dist/parser/policy-ast.d.ts.map +1 -0
- package/dist/parser/policy-ast.js +311 -0
- package/dist/parser/policy-ast.js.map +1 -0
- package/dist/prompts/index.d.ts +38 -0
- package/dist/prompts/index.d.ts.map +1 -0
- package/dist/prompts/index.js +172 -0
- package/dist/prompts/index.js.map +1 -0
- package/dist/resources/ref-resolver.d.ts +23 -0
- package/dist/resources/ref-resolver.d.ts.map +1 -0
- package/dist/resources/ref-resolver.js +128 -0
- package/dist/resources/ref-resolver.js.map +1 -0
- package/dist/resources/store-manager.d.ts +64 -0
- package/dist/resources/store-manager.d.ts.map +1 -0
- package/dist/resources/store-manager.js +221 -0
- package/dist/resources/store-manager.js.map +1 -0
- package/dist/server.d.ts +18 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +539 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/advise/avp-rules.d.ts +49 -0
- package/dist/tools/advise/avp-rules.d.ts.map +1 -0
- package/dist/tools/advise/avp-rules.js +59 -0
- package/dist/tools/advise/avp-rules.js.map +1 -0
- package/dist/tools/advise/cedar-patterns.d.ts +24 -0
- package/dist/tools/advise/cedar-patterns.d.ts.map +1 -0
- package/dist/tools/advise/cedar-patterns.js +57 -0
- package/dist/tools/advise/cedar-patterns.js.map +1 -0
- package/dist/tools/advise/context-builder.d.ts +28 -0
- package/dist/tools/advise/context-builder.d.ts.map +1 -0
- package/dist/tools/advise/context-builder.js +89 -0
- package/dist/tools/advise/context-builder.js.map +1 -0
- package/dist/tools/advise/gotchas.d.ts +15 -0
- package/dist/tools/advise/gotchas.d.ts.map +1 -0
- package/dist/tools/advise/gotchas.js +83 -0
- package/dist/tools/advise/gotchas.js.map +1 -0
- package/dist/tools/advise.d.ts +96 -0
- package/dist/tools/advise.d.ts.map +1 -0
- package/dist/tools/advise.js +258 -0
- package/dist/tools/advise.js.map +1 -0
- package/dist/tools/authorize-batch.d.ts +35 -0
- package/dist/tools/authorize-batch.d.ts.map +1 -0
- package/dist/tools/authorize-batch.js +262 -0
- package/dist/tools/authorize-batch.js.map +1 -0
- package/dist/tools/authorize.d.ts +115 -0
- package/dist/tools/authorize.d.ts.map +1 -0
- package/dist/tools/authorize.js +373 -0
- package/dist/tools/authorize.js.map +1 -0
- package/dist/tools/check-change.d.ts +19 -0
- package/dist/tools/check-change.d.ts.map +1 -0
- package/dist/tools/check-change.js +91 -0
- package/dist/tools/check-change.js.map +1 -0
- package/dist/tools/diff-schema.d.ts +103 -0
- package/dist/tools/diff-schema.d.ts.map +1 -0
- package/dist/tools/diff-schema.js +379 -0
- package/dist/tools/diff-schema.js.map +1 -0
- package/dist/tools/diff-stores.d.ts +45 -0
- package/dist/tools/diff-stores.d.ts.map +1 -0
- package/dist/tools/diff-stores.js +222 -0
- package/dist/tools/diff-stores.js.map +1 -0
- package/dist/tools/explain.d.ts +80 -0
- package/dist/tools/explain.d.ts.map +1 -0
- package/dist/tools/explain.js +187 -0
- package/dist/tools/explain.js.map +1 -0
- package/dist/tools/format.d.ts +11 -0
- package/dist/tools/format.d.ts.map +1 -0
- package/dist/tools/format.js +20 -0
- package/dist/tools/format.js.map +1 -0
- package/dist/tools/generate-sample.d.ts +28 -0
- package/dist/tools/generate-sample.d.ts.map +1 -0
- package/dist/tools/generate-sample.js +568 -0
- package/dist/tools/generate-sample.js.map +1 -0
- package/dist/tools/link-template.d.ts +17 -0
- package/dist/tools/link-template.d.ts.map +1 -0
- package/dist/tools/link-template.js +78 -0
- package/dist/tools/link-template.js.map +1 -0
- package/dist/tools/list-template-links.d.ts +16 -0
- package/dist/tools/list-template-links.d.ts.map +1 -0
- package/dist/tools/list-template-links.js +22 -0
- package/dist/tools/list-template-links.js.map +1 -0
- package/dist/tools/list-templates.d.ts +16 -0
- package/dist/tools/list-templates.d.ts.map +1 -0
- package/dist/tools/list-templates.js +36 -0
- package/dist/tools/list-templates.js.map +1 -0
- package/dist/tools/translate.d.ts +11 -0
- package/dist/tools/translate.d.ts.map +1 -0
- package/dist/tools/translate.js +53 -0
- package/dist/tools/translate.js.map +1 -0
- package/dist/tools/validate-entities.d.ts +19 -0
- package/dist/tools/validate-entities.d.ts.map +1 -0
- package/dist/tools/validate-entities.js +88 -0
- package/dist/tools/validate-entities.js.map +1 -0
- package/dist/tools/validate-schema.d.ts +22 -0
- package/dist/tools/validate-schema.d.ts.map +1 -0
- package/dist/tools/validate-schema.js +89 -0
- package/dist/tools/validate-schema.js.map +1 -0
- package/dist/tools/validate-template.d.ts +18 -0
- package/dist/tools/validate-template.d.ts.map +1 -0
- package/dist/tools/validate-template.js +59 -0
- package/dist/tools/validate-template.js.map +1 -0
- package/dist/tools/validate.d.ts +90 -0
- package/dist/tools/validate.d.ts.map +1 -0
- package/dist/tools/validate.js +351 -0
- package/dist/tools/validate.js.map +1 -0
- package/dist/utils/format-detector.d.ts +49 -0
- package/dist/utils/format-detector.d.ts.map +1 -0
- package/dist/utils/format-detector.js +298 -0
- package/dist/utils/format-detector.js.map +1 -0
- package/examples/README.md +36 -0
- package/examples/abac-multi-tenant/README.md +150 -0
- package/examples/abac-multi-tenant/entities/users-and-docs.json +33 -0
- package/examples/abac-multi-tenant/policies/member-read-internal.cedar +9 -0
- package/examples/abac-multi-tenant/policies/owner-full-access.cedar +9 -0
- package/examples/abac-multi-tenant/policies/premium-share-guard.cedar +9 -0
- package/examples/abac-multi-tenant/policies/private-doc-guard.cedar +13 -0
- package/examples/abac-multi-tenant/run.ts +92 -0
- package/examples/abac-multi-tenant/schema.json +60 -0
- package/examples/api-gateway-path-routing/README.md +154 -0
- package/examples/api-gateway-path-routing/entities/users-and-roles.json +20 -0
- package/examples/api-gateway-path-routing/policies/admin-full-access.cedar +6 -0
- package/examples/api-gateway-path-routing/policies/developer-projects.cedar +14 -0
- package/examples/api-gateway-path-routing/policies/viewer-readonly.cedar +10 -0
- package/examples/api-gateway-path-routing/run.ts +108 -0
- package/examples/api-gateway-path-routing/schema.json +54 -0
- package/examples/rbac-document-management/README.md +167 -0
- package/examples/rbac-document-management/entities/users-and-docs.json +43 -0
- package/examples/rbac-document-management/policies/admin.cedar +6 -0
- package/examples/rbac-document-management/policies/editor.cedar +6 -0
- package/examples/rbac-document-management/policies/top-secret-forbid.cedar +13 -0
- package/examples/rbac-document-management/policies/viewer.cedar +6 -0
- package/examples/rbac-document-management/run.ts +87 -0
- package/examples/rbac-document-management/schema.json +57 -0
- package/package.json +50 -0
- package/src/http-server.ts +239 -0
- package/src/index.ts +294 -0
- package/src/parser/policy-ast.ts +345 -0
- package/src/prompts/README.md +3 -0
- package/src/prompts/index.ts +217 -0
- package/src/resources/ref-resolver.ts +134 -0
- package/src/resources/store-manager.ts +248 -0
- package/src/server.ts +711 -0
- package/src/tools/advise/avp-rules.ts +70 -0
- package/src/tools/advise/cedar-patterns.ts +73 -0
- package/src/tools/advise/context-builder.ts +109 -0
- package/src/tools/advise/gotchas.ts +92 -0
- package/src/tools/advise.ts +366 -0
- package/src/tools/authorize-batch.ts +345 -0
- package/src/tools/authorize.ts +464 -0
- package/src/tools/check-change.ts +119 -0
- package/src/tools/diff-schema.ts +510 -0
- package/src/tools/diff-stores.ts +298 -0
- package/src/tools/explain.ts +278 -0
- package/src/tools/format.ts +33 -0
- package/src/tools/generate-sample.ts +665 -0
- package/src/tools/link-template.ts +109 -0
- package/src/tools/list-template-links.ts +41 -0
- package/src/tools/list-templates.ts +55 -0
- package/src/tools/translate.ts +66 -0
- package/src/tools/validate-entities.ts +125 -0
- package/src/tools/validate-schema.ts +128 -0
- package/src/tools/validate-template.ts +72 -0
- package/src/tools/validate.ts +459 -0
- package/src/utils/format-detector.ts +356 -0
- package/test/fixtures/docmgmt.ts +121 -0
- package/test/fixtures/multitenant.ts +163 -0
- package/test/index.test.ts +96 -0
- package/test/integration/e2e/behavior.test.ts +359 -0
- package/test/integration/e2e/edge-cases.test.ts +365 -0
- package/test/integration/e2e/failure-modes.test.ts +266 -0
- package/test/integration/e2e/protocol.test.ts +252 -0
- package/test/integration/http-smoke.test.ts +588 -0
- package/test/integration/smoke.test.ts +475 -0
- package/test/prompts/prompts.test.ts +173 -0
- package/test/property/properties.test.ts +234 -0
- package/test/resources/ref-resolver.test.ts +186 -0
- package/test/resources/store-manager.test.ts +344 -0
- package/test/setup.test.ts +7 -0
- package/test/tools/advise/avp-rules.test.ts +76 -0
- package/test/tools/advise.test.ts +339 -0
- package/test/tools/authorize-batch.test.ts +459 -0
- package/test/tools/authorize.test.ts +682 -0
- package/test/tools/check-change.test.ts +104 -0
- package/test/tools/cross-fixture.test.ts +170 -0
- package/test/tools/diff-schema.test.ts +355 -0
- package/test/tools/diff-stores.test.ts +291 -0
- package/test/tools/explain.test.ts +221 -0
- package/test/tools/format.test.ts +33 -0
- package/test/tools/generate-sample.test.ts +480 -0
- package/test/tools/link-template.test.ts +90 -0
- package/test/tools/list-templates.test.ts +151 -0
- package/test/tools/translate.test.ts +89 -0
- package/test/tools/validate-entities.test.ts +178 -0
- package/test/tools/validate-schema.test.ts +86 -0
- package/test/tools/validate-template.test.ts +89 -0
- package/test/tools/validate.test.ts +331 -0
- package/test/utils/format-detector.test.ts +518 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E layer 4: edge cases.
|
|
3
|
+
*
|
|
4
|
+
* Boundary inputs that exercise unusual but VALID parts of the Cedar surface.
|
|
5
|
+
* Failure-mode coverage (inputs the server should reject cleanly) lives in
|
|
6
|
+
* failure-modes.test.ts.
|
|
7
|
+
*
|
|
8
|
+
* Each test states the boundary it probes and the failure case it would catch.
|
|
9
|
+
*
|
|
10
|
+
* Run: npx vitest run test/integration/e2e/edge-cases
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
15
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
16
|
+
|
|
17
|
+
const repoRoot = join(import.meta.dirname, "../../..");
|
|
18
|
+
|
|
19
|
+
function makeStdioClient(): { client: Client; transport: StdioClientTransport } {
|
|
20
|
+
const transport = new StdioClientTransport({
|
|
21
|
+
command: "npx",
|
|
22
|
+
args: ["tsx", "src/index.ts"],
|
|
23
|
+
cwd: repoRoot,
|
|
24
|
+
stderr: "pipe",
|
|
25
|
+
});
|
|
26
|
+
const client = new Client(
|
|
27
|
+
{ name: "e2e-edge", version: "1.0.0" },
|
|
28
|
+
{ capabilities: {} }
|
|
29
|
+
);
|
|
30
|
+
return { client, transport };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseToolResult(result: unknown): unknown {
|
|
34
|
+
const r = result as { content?: Array<{ type: string; text?: string }> };
|
|
35
|
+
const textBlock = r.content?.find((b) => b.type === "text");
|
|
36
|
+
if (!textBlock?.text) throw new Error("No text content in tool result");
|
|
37
|
+
return JSON.parse(textBlock.text);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const MINI_SCHEMA = JSON.stringify({
|
|
41
|
+
App: {
|
|
42
|
+
entityTypes: {
|
|
43
|
+
User: { memberOfTypes: [], shape: { type: "Record", attributes: { name: { type: "String", required: true } } } },
|
|
44
|
+
Doc: { memberOfTypes: [], shape: { type: "Record", attributes: {} } },
|
|
45
|
+
},
|
|
46
|
+
actions: { read: { appliesTo: { principalTypes: ["User"], resourceTypes: ["Doc"], context: { type: "Record", attributes: {} } } } },
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("e2e edge cases", () => {
|
|
51
|
+
let client: Client | undefined;
|
|
52
|
+
let transport: StdioClientTransport | undefined;
|
|
53
|
+
|
|
54
|
+
beforeEach(async () => {
|
|
55
|
+
const conn = makeStdioClient();
|
|
56
|
+
client = conn.client;
|
|
57
|
+
transport = conn.transport;
|
|
58
|
+
await client.connect(transport);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterEach(async () => {
|
|
62
|
+
try { await client?.close(); } catch { /* ignore */ }
|
|
63
|
+
try { await transport?.close(); } catch { /* ignore */ }
|
|
64
|
+
client = undefined;
|
|
65
|
+
transport = undefined;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("EC1 — empty policy set: cedar_validate on whitespace-only text", async () => {
|
|
69
|
+
// Boundary: zero policies, only whitespace. Cedar's parser must accept this
|
|
70
|
+
// as a valid empty policy set (not an error). Failure case: a parser that
|
|
71
|
+
// rejects whitespace-only input would break tools that diff an empty store
|
|
72
|
+
// against a populated one.
|
|
73
|
+
const result = parseToolResult(
|
|
74
|
+
await client!.callTool({ name: "cedar_validate", arguments: { policies: " \n\t ", schema: MINI_SCHEMA } })
|
|
75
|
+
) as { valid: boolean; policy_count: number };
|
|
76
|
+
expect(result.valid).toBe(true);
|
|
77
|
+
expect(result.policy_count).toBe(0);
|
|
78
|
+
}, 20_000);
|
|
79
|
+
|
|
80
|
+
it("EC2 — single-policy set with no when/unless clauses", async () => {
|
|
81
|
+
// Boundary: a permit with bare scope, no conditions. The simplest possible
|
|
82
|
+
// non-empty policy. Failure case: a parser that requires at least one
|
|
83
|
+
// condition clause would reject this even though Cedar accepts it.
|
|
84
|
+
const policy = `permit (principal, action, resource);`;
|
|
85
|
+
const result = parseToolResult(
|
|
86
|
+
await client!.callTool({ name: "cedar_validate", arguments: { policies: policy, schema: MINI_SCHEMA } })
|
|
87
|
+
) as { valid: boolean; policy_count: number };
|
|
88
|
+
expect(result.valid).toBe(true);
|
|
89
|
+
expect(result.policy_count).toBe(1);
|
|
90
|
+
}, 20_000);
|
|
91
|
+
|
|
92
|
+
it("EC3 — forbid with unless guard (the inverse-permission pattern)", async () => {
|
|
93
|
+
// Boundary: the classic 'top_secret unless admin' pattern. Tests that
|
|
94
|
+
// forbid + unless composes correctly. Failure case: a generator/validator
|
|
95
|
+
// that drops the unless clause would change the policy semantics.
|
|
96
|
+
const policy = `forbid (principal, action, resource)
|
|
97
|
+
when { resource has classification && resource.classification == "top_secret" }
|
|
98
|
+
unless { principal in App::Role::"admin" };`;
|
|
99
|
+
const schemaWithClassification = JSON.stringify({
|
|
100
|
+
App: {
|
|
101
|
+
entityTypes: {
|
|
102
|
+
User: { memberOfTypes: ["Role"], shape: { type: "Record", attributes: {} } },
|
|
103
|
+
Role: { memberOfTypes: [], shape: { type: "Record", attributes: {} } },
|
|
104
|
+
Doc: { memberOfTypes: [], shape: { type: "Record", attributes: { classification: { type: "String", required: false } } } },
|
|
105
|
+
},
|
|
106
|
+
actions: { read: { appliesTo: { principalTypes: ["User"], resourceTypes: ["Doc"], context: { type: "Record", attributes: {} } } } },
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
const result = parseToolResult(
|
|
110
|
+
await client!.callTool({ name: "cedar_validate", arguments: { policies: policy, schema: schemaWithClassification } })
|
|
111
|
+
) as { valid: boolean };
|
|
112
|
+
expect(result.valid).toBe(true);
|
|
113
|
+
}, 20_000);
|
|
114
|
+
|
|
115
|
+
it("EC4 — very long entity id (1000 characters) is accepted", async () => {
|
|
116
|
+
// Boundary: entity IDs near the upper end of practical use. Cedar's grammar
|
|
117
|
+
// allows arbitrary string content in entity IDs; large IDs should not
|
|
118
|
+
// crash the validator or the formatter.
|
|
119
|
+
const longId = "x".repeat(1000);
|
|
120
|
+
const policy = `permit (principal == App::User::"${longId}", action, resource);`;
|
|
121
|
+
const result = parseToolResult(
|
|
122
|
+
await client!.callTool({ name: "cedar_validate", arguments: { policies: policy, schema: MINI_SCHEMA } })
|
|
123
|
+
) as { valid: boolean };
|
|
124
|
+
expect(result.valid).toBe(true);
|
|
125
|
+
}, 20_000);
|
|
126
|
+
|
|
127
|
+
it("EC5 — Unicode in attribute string values (validate + authorize)", async () => {
|
|
128
|
+
// Boundary: non-ASCII string values in entity attributes. Cedar strings are
|
|
129
|
+
// Unicode; the WASM boundary must handle UTF-8 without mangling.
|
|
130
|
+
// Failure case: a transport layer treating the body as Latin-1 would corrupt
|
|
131
|
+
// multi-byte characters.
|
|
132
|
+
const entities = JSON.stringify([
|
|
133
|
+
{ uid: { type: "App::User", id: "u1" }, attrs: { name: "Ælfred Ø'Hára 日本語 🦀" }, parents: [] },
|
|
134
|
+
{ uid: { type: "App::Doc", id: "d1" }, attrs: {}, parents: [] },
|
|
135
|
+
]);
|
|
136
|
+
const result = parseToolResult(
|
|
137
|
+
await client!.callTool({
|
|
138
|
+
name: "cedar_authorize",
|
|
139
|
+
arguments: {
|
|
140
|
+
policies: `permit (principal, action, resource) when { principal.name == "Ælfred Ø'Hára 日本語 🦀" };`,
|
|
141
|
+
principal: 'App::User::"u1"',
|
|
142
|
+
action: 'App::Action::"read"',
|
|
143
|
+
resource: 'App::Doc::"d1"',
|
|
144
|
+
entities,
|
|
145
|
+
schema: MINI_SCHEMA,
|
|
146
|
+
},
|
|
147
|
+
})
|
|
148
|
+
) as { decision: string };
|
|
149
|
+
expect(result.decision).toBe("Allow");
|
|
150
|
+
}, 20_000);
|
|
151
|
+
|
|
152
|
+
it("EC6 — cedar_authorize_batch on an empty requests array", async () => {
|
|
153
|
+
// Boundary: zero requests. Should return total: 0, allowed: 0, denied: 0,
|
|
154
|
+
// errored: 0 with a non-empty summary. Failure case: a divide-by-zero or
|
|
155
|
+
// off-by-one in the summary computation.
|
|
156
|
+
const result = parseToolResult(
|
|
157
|
+
await client!.callTool({
|
|
158
|
+
name: "cedar_authorize_batch",
|
|
159
|
+
arguments: {
|
|
160
|
+
policies: `permit (principal, action, resource);`,
|
|
161
|
+
schema: MINI_SCHEMA,
|
|
162
|
+
requests: "[]",
|
|
163
|
+
},
|
|
164
|
+
})
|
|
165
|
+
) as { total: number; allowed: number; denied: number; errored: number; summary: string };
|
|
166
|
+
expect(result.total).toBe(0);
|
|
167
|
+
expect(result.allowed).toBe(0);
|
|
168
|
+
expect(result.denied).toBe(0);
|
|
169
|
+
expect(result.errored).toBe(0);
|
|
170
|
+
expect(result.summary.length).toBeGreaterThan(0);
|
|
171
|
+
}, 20_000);
|
|
172
|
+
|
|
173
|
+
it("EC7 — cedar_diff_schema across entirely different namespaces", async () => {
|
|
174
|
+
// Boundary: blue and green share no namespaces. Diff should report blue's
|
|
175
|
+
// namespace as removed and green's as added, with all entity_types and
|
|
176
|
+
// actions classified accordingly. Failure case: a diff that only iterates
|
|
177
|
+
// shared namespaces would miss this entirely.
|
|
178
|
+
const blueSchema = JSON.stringify({ Foo: { entityTypes: { A: { memberOfTypes: [], shape: { type: "Record", attributes: {} } } }, actions: {} } });
|
|
179
|
+
const greenSchema = JSON.stringify({ Bar: { entityTypes: { B: { memberOfTypes: [], shape: { type: "Record", attributes: {} } } }, actions: {} } });
|
|
180
|
+
const result = parseToolResult(
|
|
181
|
+
await client!.callTool({ name: "cedar_diff_schema", arguments: { blue: blueSchema, green: greenSchema } })
|
|
182
|
+
) as {
|
|
183
|
+
namespaces_added: string[];
|
|
184
|
+
namespaces_removed: string[];
|
|
185
|
+
entity_types: { added: Array<{ namespace: string; name: string }>; removed: Array<{ namespace: string; name: string }> };
|
|
186
|
+
};
|
|
187
|
+
expect(result.namespaces_added).toContain("Bar");
|
|
188
|
+
expect(result.namespaces_removed).toContain("Foo");
|
|
189
|
+
expect(result.entity_types.added.find((e) => e.name === "B")).toBeDefined();
|
|
190
|
+
expect(result.entity_types.removed.find((e) => e.name === "A")).toBeDefined();
|
|
191
|
+
}, 20_000);
|
|
192
|
+
|
|
193
|
+
it("EC8 — template with only ?principal slot (no ?resource slot)", async () => {
|
|
194
|
+
// Boundary: a one-slot template. cedar_validate_template should detect
|
|
195
|
+
// exactly one slot; cedar_link_template should accept just the principal arg.
|
|
196
|
+
// Failure case: a template handler that requires both slots would reject
|
|
197
|
+
// the link or produce malformed output.
|
|
198
|
+
const template = `permit (principal == ?principal, action, resource);`;
|
|
199
|
+
const validateResult = parseToolResult(
|
|
200
|
+
await client!.callTool({ name: "cedar_validate_template", arguments: { template, schema: MINI_SCHEMA } })
|
|
201
|
+
) as { valid: boolean; slots?: string[]; detected_slots?: string[] };
|
|
202
|
+
expect(validateResult.valid).toBe(true);
|
|
203
|
+
|
|
204
|
+
const linkResult = parseToolResult(
|
|
205
|
+
await client!.callTool({
|
|
206
|
+
name: "cedar_link_template",
|
|
207
|
+
arguments: { template, principal: 'App::User::"alice"', schema: MINI_SCHEMA },
|
|
208
|
+
})
|
|
209
|
+
) as { linked_policy?: string; policy?: string };
|
|
210
|
+
const linked = linkResult.linked_policy ?? linkResult.policy;
|
|
211
|
+
expect(linked).toBeTruthy();
|
|
212
|
+
expect(linked).toContain('App::User::"alice"');
|
|
213
|
+
// The ?principal slot should be substituted; no remaining placeholder.
|
|
214
|
+
expect(linked).not.toContain("?principal");
|
|
215
|
+
}, 20_000);
|
|
216
|
+
|
|
217
|
+
it("EC9 — cedar_validate_entities accepts a deeply-nested record attribute", async () => {
|
|
218
|
+
// Boundary: a Record attribute containing another Record. Cedar supports
|
|
219
|
+
// arbitrary nesting; the entities validator must walk recursively.
|
|
220
|
+
// Failure case: a non-recursive validator that drops at depth 2.
|
|
221
|
+
const schema = JSON.stringify({
|
|
222
|
+
App: {
|
|
223
|
+
entityTypes: {
|
|
224
|
+
User: {
|
|
225
|
+
memberOfTypes: [],
|
|
226
|
+
shape: {
|
|
227
|
+
type: "Record",
|
|
228
|
+
attributes: {
|
|
229
|
+
profile: {
|
|
230
|
+
type: "Record",
|
|
231
|
+
required: true,
|
|
232
|
+
attributes: {
|
|
233
|
+
name: { type: "String", required: true },
|
|
234
|
+
address: {
|
|
235
|
+
type: "Record",
|
|
236
|
+
required: true,
|
|
237
|
+
attributes: { city: { type: "String", required: true } },
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
actions: {},
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
const entities = JSON.stringify([
|
|
249
|
+
{
|
|
250
|
+
uid: { type: "App::User", id: "u1" },
|
|
251
|
+
attrs: {
|
|
252
|
+
profile: {
|
|
253
|
+
name: "Alice",
|
|
254
|
+
address: { city: "Wroclaw" },
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
parents: [],
|
|
258
|
+
},
|
|
259
|
+
]);
|
|
260
|
+
const result = parseToolResult(
|
|
261
|
+
await client!.callTool({ name: "cedar_validate_entities", arguments: { entities, schema } })
|
|
262
|
+
) as { valid: boolean; errors: unknown[] };
|
|
263
|
+
expect(result.valid).toBe(true);
|
|
264
|
+
expect(result.errors).toHaveLength(0);
|
|
265
|
+
}, 20_000);
|
|
266
|
+
|
|
267
|
+
it("EC10 — cedar_authorize_batch with mixed Allow/Deny/Error outcomes in one batch", async () => {
|
|
268
|
+
// Boundary: a batch where SOME requests succeed, SOME deny, and SOME error
|
|
269
|
+
// out due to malformed entities. The batch must process all of them and
|
|
270
|
+
// surface each in its respective category. Failure case: a batch that
|
|
271
|
+
// aborts on the first error, losing visibility of later requests.
|
|
272
|
+
const policy = `permit (principal in App::Role::"admin", action, resource);`;
|
|
273
|
+
const schema = JSON.stringify({
|
|
274
|
+
App: {
|
|
275
|
+
entityTypes: {
|
|
276
|
+
User: { memberOfTypes: ["Role"], shape: { type: "Record", attributes: { name: { type: "String", required: true } } } },
|
|
277
|
+
Role: { memberOfTypes: [], shape: { type: "Record", attributes: {} } },
|
|
278
|
+
Doc: { memberOfTypes: [], shape: { type: "Record", attributes: {} } },
|
|
279
|
+
},
|
|
280
|
+
actions: { read: { appliesTo: { principalTypes: ["User"], resourceTypes: ["Doc"], context: { type: "Record", attributes: {} } } } },
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
const requests = JSON.stringify([
|
|
284
|
+
{
|
|
285
|
+
principal: 'App::User::"alice"',
|
|
286
|
+
action: 'App::Action::"read"',
|
|
287
|
+
resource: 'App::Doc::"d1"',
|
|
288
|
+
entities: JSON.stringify([
|
|
289
|
+
{ uid: { type: "App::User", id: "alice" }, attrs: { name: "Alice" }, parents: [{ type: "App::Role", id: "admin" }] },
|
|
290
|
+
{ uid: { type: "App::Role", id: "admin" }, attrs: {}, parents: [] },
|
|
291
|
+
{ uid: { type: "App::Doc", id: "d1" }, attrs: {}, parents: [] },
|
|
292
|
+
]),
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
principal: 'App::User::"bob"',
|
|
296
|
+
action: 'App::Action::"read"',
|
|
297
|
+
resource: 'App::Doc::"d1"',
|
|
298
|
+
entities: JSON.stringify([
|
|
299
|
+
{ uid: { type: "App::User", id: "bob" }, attrs: { name: "Bob" }, parents: [] },
|
|
300
|
+
{ uid: { type: "App::Doc", id: "d1" }, attrs: {}, parents: [] },
|
|
301
|
+
]),
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
principal: 'App::User::"carol"',
|
|
305
|
+
action: 'App::Action::"read"',
|
|
306
|
+
resource: 'App::Doc::"d1"',
|
|
307
|
+
entities: "{not valid json", // malformed entities → Error
|
|
308
|
+
},
|
|
309
|
+
]);
|
|
310
|
+
const result = parseToolResult(
|
|
311
|
+
await client!.callTool({ name: "cedar_authorize_batch", arguments: { policies: policy, schema, requests } })
|
|
312
|
+
) as {
|
|
313
|
+
total: number;
|
|
314
|
+
allowed: number;
|
|
315
|
+
denied: number;
|
|
316
|
+
errored: number;
|
|
317
|
+
decisions: Array<{ index: number; principal: string; action: string; resource: string; decision: string }>;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
expect(result.total).toBe(3);
|
|
321
|
+
expect(result.allowed).toBe(1);
|
|
322
|
+
expect(result.denied + result.errored).toBe(2); // bob denies; carol errors
|
|
323
|
+
|
|
324
|
+
// Order invariant: decisions[i] MUST correspond to request[i]. A batch that
|
|
325
|
+
// parallelized and returned results out-of-order would still pass a loose
|
|
326
|
+
// "outcomes match the multiset" check, hiding the bug. Assert position +
|
|
327
|
+
// principal + action + resource per index.
|
|
328
|
+
expect(result.decisions).toHaveLength(3);
|
|
329
|
+
expect(result.decisions[0]!.index).toBe(0);
|
|
330
|
+
expect(result.decisions[0]!.principal).toBe('App::User::"alice"');
|
|
331
|
+
expect(result.decisions[0]!.decision).toBe("Allow");
|
|
332
|
+
expect(result.decisions[1]!.index).toBe(1);
|
|
333
|
+
expect(result.decisions[1]!.principal).toBe('App::User::"bob"');
|
|
334
|
+
expect(result.decisions[1]!.decision).toBe("Deny");
|
|
335
|
+
expect(result.decisions[2]!.index).toBe(2);
|
|
336
|
+
expect(result.decisions[2]!.principal).toBe('App::User::"carol"');
|
|
337
|
+
expect(result.decisions[2]!.decision).toBe("Error");
|
|
338
|
+
}, 30_000);
|
|
339
|
+
|
|
340
|
+
it("EC11 — policy_count handles 100 policies in a single text block", async () => {
|
|
341
|
+
// Boundary: a large policy set passed as one text blob. Cedar's policy set
|
|
342
|
+
// parser must scale linearly with input size, not blow up.
|
|
343
|
+
// Failure case: O(n²) parser that hangs on 100 policies.
|
|
344
|
+
const policies = Array.from({ length: 100 }, (_, i) =>
|
|
345
|
+
`permit (principal in App::Role::"role-${i}", action, resource);`
|
|
346
|
+
).join("\n\n");
|
|
347
|
+
const result = parseToolResult(
|
|
348
|
+
await client!.callTool({
|
|
349
|
+
name: "cedar_validate",
|
|
350
|
+
arguments: { policies, schema: JSON.stringify({
|
|
351
|
+
App: {
|
|
352
|
+
entityTypes: {
|
|
353
|
+
User: { memberOfTypes: ["Role"], shape: { type: "Record", attributes: {} } },
|
|
354
|
+
Role: { memberOfTypes: [], shape: { type: "Record", attributes: {} } },
|
|
355
|
+
Doc: { memberOfTypes: [], shape: { type: "Record", attributes: {} } },
|
|
356
|
+
},
|
|
357
|
+
actions: { read: { appliesTo: { principalTypes: ["User"], resourceTypes: ["Doc"], context: { type: "Record", attributes: {} } } } },
|
|
358
|
+
},
|
|
359
|
+
})},
|
|
360
|
+
})
|
|
361
|
+
) as { valid: boolean; policy_count: number };
|
|
362
|
+
expect(result.valid).toBe(true);
|
|
363
|
+
expect(result.policy_count).toBe(100);
|
|
364
|
+
}, 20_000);
|
|
365
|
+
});
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E layer 5: failure modes.
|
|
3
|
+
*
|
|
4
|
+
* Inputs the server MUST reject cleanly — with a structured error, never with
|
|
5
|
+
* a crash, hang, or silent success. Edge cases (boundary-but-valid inputs)
|
|
6
|
+
* live in edge-cases.test.ts.
|
|
7
|
+
*
|
|
8
|
+
* Each test states the bad input + the expected clean-error shape.
|
|
9
|
+
*
|
|
10
|
+
* Run: npx vitest run test/integration/e2e/failure-modes
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
15
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
16
|
+
|
|
17
|
+
const repoRoot = join(import.meta.dirname, "../../..");
|
|
18
|
+
|
|
19
|
+
function makeStdioClient(): { client: Client; transport: StdioClientTransport } {
|
|
20
|
+
const transport = new StdioClientTransport({
|
|
21
|
+
command: "npx",
|
|
22
|
+
args: ["tsx", "src/index.ts"],
|
|
23
|
+
cwd: repoRoot,
|
|
24
|
+
stderr: "pipe",
|
|
25
|
+
});
|
|
26
|
+
const client = new Client(
|
|
27
|
+
{ name: "e2e-failure", version: "1.0.0" },
|
|
28
|
+
{ capabilities: {} }
|
|
29
|
+
);
|
|
30
|
+
return { client, transport };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseToolResult(result: unknown): unknown {
|
|
34
|
+
const r = result as { content?: Array<{ type: string; text?: string }> };
|
|
35
|
+
const textBlock = r.content?.find((b) => b.type === "text");
|
|
36
|
+
if (!textBlock?.text) throw new Error("No text content in tool result");
|
|
37
|
+
return JSON.parse(textBlock.text);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const MINI_SCHEMA = JSON.stringify({
|
|
41
|
+
App: {
|
|
42
|
+
entityTypes: {
|
|
43
|
+
User: { memberOfTypes: [], shape: { type: "Record", attributes: { name: { type: "String", required: true } } } },
|
|
44
|
+
Doc: { memberOfTypes: [], shape: { type: "Record", attributes: {} } },
|
|
45
|
+
},
|
|
46
|
+
actions: { read: { appliesTo: { principalTypes: ["User"], resourceTypes: ["Doc"], context: { type: "Record", attributes: {} } } } },
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("e2e failure modes", () => {
|
|
51
|
+
let client: Client | undefined;
|
|
52
|
+
let transport: StdioClientTransport | undefined;
|
|
53
|
+
|
|
54
|
+
beforeEach(async () => {
|
|
55
|
+
const conn = makeStdioClient();
|
|
56
|
+
client = conn.client;
|
|
57
|
+
transport = conn.transport;
|
|
58
|
+
await client.connect(transport);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterEach(async () => {
|
|
62
|
+
try { await client?.close(); } catch { /* ignore */ }
|
|
63
|
+
try { await transport?.close(); } catch { /* ignore */ }
|
|
64
|
+
client = undefined;
|
|
65
|
+
transport = undefined;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("F1 — malformed entities JSON returns parse_error, not a crash", async () => {
|
|
69
|
+
// Bad input: 'not valid {{ json' as the entities string. cedar_validate_entities
|
|
70
|
+
// must catch the JSON.parse failure and return error_kind: 'parse_error'.
|
|
71
|
+
const result = parseToolResult(
|
|
72
|
+
await client!.callTool({
|
|
73
|
+
name: "cedar_validate_entities",
|
|
74
|
+
arguments: { entities: "not valid {{ json", schema: MINI_SCHEMA },
|
|
75
|
+
})
|
|
76
|
+
) as { valid: boolean; errors: Array<{ error_kind: string; message: string }> };
|
|
77
|
+
expect(result.valid).toBe(false);
|
|
78
|
+
expect(result.errors[0]?.error_kind).toBe("parse_error");
|
|
79
|
+
expect(result.errors[0]?.message.length).toBeGreaterThan(0);
|
|
80
|
+
}, 20_000);
|
|
81
|
+
|
|
82
|
+
it("F2 — invalid Cedar syntax: source-location-tagged error from cedar_validate", async () => {
|
|
83
|
+
// Bad input: 'permit (broken oh no'. The parser must return valid:false with
|
|
84
|
+
// a structured error message (NOT throw). Source location is a bonus but not
|
|
85
|
+
// strictly asserted because exact offsets are SDK-specific.
|
|
86
|
+
const result = parseToolResult(
|
|
87
|
+
await client!.callTool({
|
|
88
|
+
name: "cedar_validate",
|
|
89
|
+
arguments: { policies: "permit (broken oh no", schema: MINI_SCHEMA },
|
|
90
|
+
})
|
|
91
|
+
) as { valid: boolean; errors: Array<{ message: string }> };
|
|
92
|
+
expect(result.valid).toBe(false);
|
|
93
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
94
|
+
expect(result.errors[0].message.length).toBeGreaterThan(0);
|
|
95
|
+
}, 20_000);
|
|
96
|
+
|
|
97
|
+
it("F3 — malformed schema in cedar_validate_schema returns valid:false with source location", async () => {
|
|
98
|
+
// Bad input: a cedarschema text with a missing colon. Validator must return
|
|
99
|
+
// valid:false with at least one error.
|
|
100
|
+
const result = parseToolResult(
|
|
101
|
+
await client!.callTool({
|
|
102
|
+
name: "cedar_validate_schema",
|
|
103
|
+
arguments: { schema: "namespace App { entity User { name String } }" },
|
|
104
|
+
})
|
|
105
|
+
) as { valid: boolean; errors: Array<{ message: string; source_location?: unknown }> };
|
|
106
|
+
expect(result.valid).toBe(false);
|
|
107
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
108
|
+
}, 20_000);
|
|
109
|
+
|
|
110
|
+
it("F4 — path traversal in policy id (cedar://policies/x/../escape) is rejected", async () => {
|
|
111
|
+
// Bad input: a cedar:// URI containing '..'. The resource handler must
|
|
112
|
+
// reject (no filesystem escape). The error body must surface the rejection
|
|
113
|
+
// structurally, not via a 500 or a successful read of an unintended file.
|
|
114
|
+
const result = await client!.readResource({ uri: "cedar://policies/staging/..%2Fescape" });
|
|
115
|
+
expect(result.contents.length).toBeGreaterThan(0);
|
|
116
|
+
const body = result.contents[0]!;
|
|
117
|
+
const text = typeof body.text === "string" ? body.text : "";
|
|
118
|
+
const parsed = JSON.parse(text);
|
|
119
|
+
expect(parsed.error).toBeDefined();
|
|
120
|
+
}, 20_000);
|
|
121
|
+
|
|
122
|
+
it("F5 — cedar:// URI to a non-existent store returns a clean structured error", async () => {
|
|
123
|
+
// Bad input: a syntactically-valid cedar:// URI pointing at a store that's
|
|
124
|
+
// not configured (no MCP roots). resource read must return a JSON error
|
|
125
|
+
// body, not a transport-level fault.
|
|
126
|
+
const result = await client!.readResource({ uri: "cedar://policies/this-store-does-not-exist/admin" });
|
|
127
|
+
const text = typeof result.contents[0]?.text === "string" ? result.contents[0]!.text! : "";
|
|
128
|
+
const parsed = JSON.parse(text);
|
|
129
|
+
expect(parsed.error).toBeDefined();
|
|
130
|
+
expect(typeof parsed.error).toBe("string");
|
|
131
|
+
expect(parsed.error.length).toBeGreaterThan(0);
|
|
132
|
+
}, 20_000);
|
|
133
|
+
|
|
134
|
+
it("F6 — cedar_authorize with neither policies nor policy_ref returns 'one is required' error", async () => {
|
|
135
|
+
// Bad input: omit both inline policies AND policy_ref. The server wrapper
|
|
136
|
+
// must catch this before passing nothing to the handler.
|
|
137
|
+
const result = await client!.callTool({
|
|
138
|
+
name: "cedar_authorize",
|
|
139
|
+
arguments: {
|
|
140
|
+
// intentionally omitting policies AND policy_ref
|
|
141
|
+
principal: 'App::User::"alice"',
|
|
142
|
+
action: 'App::Action::"read"',
|
|
143
|
+
resource: 'App::Doc::"d1"',
|
|
144
|
+
entities: "[]",
|
|
145
|
+
},
|
|
146
|
+
}) as { content: Array<{ type: string; text?: string }>; isError?: boolean };
|
|
147
|
+
const textBlock = result.content.find((b) => b.type === "text");
|
|
148
|
+
const parsed = JSON.parse(textBlock!.text!);
|
|
149
|
+
expect(parsed.error).toBeDefined();
|
|
150
|
+
expect(parsed.error).toMatch(/polic.*required|required.*polic/i);
|
|
151
|
+
}, 20_000);
|
|
152
|
+
|
|
153
|
+
it("F7 — cedar_diff_schema with a malformed blue schema sets schema_diff.error", async () => {
|
|
154
|
+
// Bad input: blue is unparseable Cedar text. The diff must surface this via
|
|
155
|
+
// the top-level error field on SchemaDiff — not abort the call or return
|
|
156
|
+
// misleading "no changes" for two unparseable inputs.
|
|
157
|
+
const result = parseToolResult(
|
|
158
|
+
await client!.callTool({
|
|
159
|
+
name: "cedar_diff_schema",
|
|
160
|
+
arguments: { blue: "this is not a schema", green: MINI_SCHEMA },
|
|
161
|
+
})
|
|
162
|
+
) as { error?: string; risk_level: string };
|
|
163
|
+
expect(result.error).toBeDefined();
|
|
164
|
+
expect(result.error!.length).toBeGreaterThan(0);
|
|
165
|
+
}, 20_000);
|
|
166
|
+
|
|
167
|
+
it("F8 — cedar_authorize_batch with non-array requests JSON returns clear error", async () => {
|
|
168
|
+
// Bad input: requests is a JSON OBJECT, not an array. The handler must catch
|
|
169
|
+
// the type mismatch at parse time and return a structured error (not
|
|
170
|
+
// attempt to iterate undefined or throw at the WASM boundary).
|
|
171
|
+
const result = parseToolResult(
|
|
172
|
+
await client!.callTool({
|
|
173
|
+
name: "cedar_authorize_batch",
|
|
174
|
+
arguments: {
|
|
175
|
+
policies: `permit (principal, action, resource);`,
|
|
176
|
+
schema: MINI_SCHEMA,
|
|
177
|
+
requests: JSON.stringify({ not: "an array" }),
|
|
178
|
+
},
|
|
179
|
+
})
|
|
180
|
+
) as { total?: number; error?: string; summary?: string };
|
|
181
|
+
// The handler may report this as either total:0+errored:0 with a clear
|
|
182
|
+
// summary, or as a top-level error field. Accept either shape; the key
|
|
183
|
+
// invariant is "no crash, structured response, easy to debug".
|
|
184
|
+
const surfaced =
|
|
185
|
+
(typeof result.error === "string" && result.error.length > 0) ||
|
|
186
|
+
(typeof result.summary === "string" && /array|object|not.*valid/i.test(result.summary));
|
|
187
|
+
expect(surfaced).toBe(true);
|
|
188
|
+
}, 20_000);
|
|
189
|
+
|
|
190
|
+
it("F9 — entity with type-incompatible attribute is flagged by cedar_validate_entities", async () => {
|
|
191
|
+
// Bad input: User.name is required String, entity has it as a number.
|
|
192
|
+
// cedar_validate_entities must classify this as type_mismatch with the
|
|
193
|
+
// attribute name captured.
|
|
194
|
+
const result = parseToolResult(
|
|
195
|
+
await client!.callTool({
|
|
196
|
+
name: "cedar_validate_entities",
|
|
197
|
+
arguments: {
|
|
198
|
+
entities: JSON.stringify([
|
|
199
|
+
{ uid: { type: "App::User", id: "alice" }, attrs: { name: 42 }, parents: [] },
|
|
200
|
+
]),
|
|
201
|
+
schema: MINI_SCHEMA,
|
|
202
|
+
},
|
|
203
|
+
})
|
|
204
|
+
) as { valid: boolean; errors: Array<{ error_kind: string; attribute?: string }> };
|
|
205
|
+
expect(result.valid).toBe(false);
|
|
206
|
+
expect(result.errors[0].error_kind).toBe("type_mismatch");
|
|
207
|
+
expect(result.errors[0].attribute).toBe("name");
|
|
208
|
+
}, 20_000);
|
|
209
|
+
|
|
210
|
+
it("F10 — sequential connect/disconnect/reconnect doesn't accumulate state", async () => {
|
|
211
|
+
// Close the auto-connected client, open a brand-new one, verify it works.
|
|
212
|
+
// Failure case: a stdio process that doesn't clean up between sessions
|
|
213
|
+
// would either hang the second connect or surface stale state.
|
|
214
|
+
await client!.close();
|
|
215
|
+
await transport!.close();
|
|
216
|
+
|
|
217
|
+
const conn2 = makeStdioClient();
|
|
218
|
+
client = conn2.client;
|
|
219
|
+
transport = conn2.transport;
|
|
220
|
+
await client.connect(transport);
|
|
221
|
+
|
|
222
|
+
const result = parseToolResult(
|
|
223
|
+
await client.callTool({
|
|
224
|
+
name: "cedar_validate",
|
|
225
|
+
arguments: { policies: `permit (principal, action, resource);`, schema: MINI_SCHEMA },
|
|
226
|
+
})
|
|
227
|
+
) as { valid: boolean };
|
|
228
|
+
expect(result.valid).toBe(true);
|
|
229
|
+
}, 30_000);
|
|
230
|
+
|
|
231
|
+
it("F11 — in-flight requests get their own correlated responses (stdio ID routing)", async () => {
|
|
232
|
+
// What this actually tests: stdio MCP is wire-serial (JSON-RPC over stdin/stdout
|
|
233
|
+
// is fundamentally one message at a time), but the server can have multiple
|
|
234
|
+
// in-flight Promises waiting on WASM calls. The MCP transport correlates each
|
|
235
|
+
// response to its request ID. Failure case: a server that mismanages its
|
|
236
|
+
// pending-response queue could resolve one caller's promise with another's
|
|
237
|
+
// result. We launch 8 requests via Promise.all and assert each is identified
|
|
238
|
+
// by its own role-${i} policy text in the result.
|
|
239
|
+
//
|
|
240
|
+
// True transport-level concurrency (multiple TCP connections / Mcp-Session-Id
|
|
241
|
+
// routing) lives in http-smoke.test.ts H6 — see that test for the parallel-
|
|
242
|
+
// sessions case.
|
|
243
|
+
const calls = Array.from({ length: 8 }, (_, i) =>
|
|
244
|
+
client!.callTool({
|
|
245
|
+
name: "cedar_explain",
|
|
246
|
+
arguments: {
|
|
247
|
+
policy: `permit (principal in App::Role::"role-${i}", action, resource);`,
|
|
248
|
+
},
|
|
249
|
+
})
|
|
250
|
+
);
|
|
251
|
+
const results = await Promise.all(calls);
|
|
252
|
+
for (let i = 0; i < results.length; i++) {
|
|
253
|
+
const parsed = parseToolResult(results[i]!) as {
|
|
254
|
+
policies?: Array<{ summary?: string; cedar_text?: string }>;
|
|
255
|
+
cedar_text?: string;
|
|
256
|
+
summary?: string;
|
|
257
|
+
};
|
|
258
|
+
// cedar_explain returns either a single result or { policies: [...] }.
|
|
259
|
+
// The role-${i} string from the source policy must round-trip into the
|
|
260
|
+
// result body — if response routing swapped two callers, the i-th result
|
|
261
|
+
// would contain role-${j} (j ≠ i).
|
|
262
|
+
const body = JSON.stringify(parsed);
|
|
263
|
+
expect(body, `call ${i} response`).toContain(`role-${i}`);
|
|
264
|
+
}
|
|
265
|
+
}, 60_000);
|
|
266
|
+
});
|