@openwop/openwop-conformance 1.0.0 → 1.1.1
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/CHANGELOG.md +17 -0
- package/README.md +31 -6
- package/api/grpc/openwop.proto +251 -0
- package/api/openapi.yaml +109 -3
- package/coverage.md +48 -9
- package/fixtures/conformance-configurable-schema.json +39 -0
- package/fixtures/conformance-subworkflow-parent.json +1 -1
- package/fixtures/conformance-wasm-pack-memory-cap-breach.json +23 -0
- package/fixtures/openwop-smoke-byok-roundtrip.json +25 -0
- package/fixtures.md +21 -0
- package/package.json +3 -1
- package/schemas/README.md +4 -0
- package/schemas/audit-verify-result.schema.json +90 -0
- package/schemas/capabilities.schema.json +342 -1
- package/schemas/node-pack-manifest.schema.json +4 -4
- package/schemas/pack-lockfile.schema.json +92 -0
- package/schemas/registry-version-manifest.schema.json +145 -0
- package/schemas/run-event-payloads.schema.json +20 -4
- package/schemas/run-event.schema.json +2 -1
- package/schemas/security-advisory.schema.json +109 -0
- package/src/lib/a2a-fake-peer.ts +143 -56
- package/src/lib/behavior-gate.ts +107 -0
- package/src/lib/env.ts +37 -0
- package/src/lib/grpc-framing.test.ts +96 -0
- package/src/lib/grpc-framing.ts +76 -0
- package/src/lib/oidc-issuer.test.ts +328 -0
- package/src/lib/oidc-issuer.ts +241 -0
- package/src/lib/otel-collector-grpc.test.ts +191 -0
- package/src/lib/otel-collector.test.ts +303 -0
- package/src/lib/otel-collector.ts +318 -14
- package/src/lib/otlp-protobuf.test.ts +461 -0
- package/src/lib/otlp-protobuf.ts +529 -0
- package/src/scenarios/a2a-task-roundtrip.test.ts +147 -28
- package/src/scenarios/agentConfidenceEscalation.test.ts +1 -0
- package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +1 -0
- package/src/scenarios/agentMemoryRedactionContract.test.ts +1 -0
- package/src/scenarios/agentMemoryRoundTrip.test.ts +1 -0
- package/src/scenarios/agentMemoryTtlExpiry.test.ts +1 -0
- package/src/scenarios/agentMessageReducer.test.ts +1 -0
- package/src/scenarios/agentMetadata.test.ts +1 -0
- package/src/scenarios/agentPackExport.test.ts +1 -0
- package/src/scenarios/agentPackInstall.test.ts +1 -0
- package/src/scenarios/agentPackProvenance.test.ts +1 -0
- package/src/scenarios/audit-log-integrity.test.ts +3 -6
- package/src/scenarios/auth-api-key-rotation.test.ts +182 -0
- package/src/scenarios/auth-mtls.test.ts +274 -0
- package/src/scenarios/auth-oauth2-client-credentials.test.ts +259 -0
- package/src/scenarios/auth-oidc-user-bearer.test.ts +361 -0
- package/src/scenarios/bulk-cancel.test.ts +111 -0
- package/src/scenarios/configurable-schema.test.ts +48 -0
- package/src/scenarios/conversationCapabilityNegotiation.test.ts +1 -0
- package/src/scenarios/conversationLifecycle.test.ts +1 -0
- package/src/scenarios/conversationReplayDeterminism.test.ts +1 -0
- package/src/scenarios/conversationVsLegacySuspend.test.ts +1 -0
- package/src/scenarios/debug-bundle-truncation.test.ts +95 -0
- package/src/scenarios/discovery.test.ts +183 -0
- package/src/scenarios/http-client-ssrf.test.ts +71 -0
- package/src/scenarios/idempotency.test.ts +6 -0
- package/src/scenarios/idempotencyRetry.test.ts +3 -0
- package/src/scenarios/mcp-tool-roundtrip.test.ts +205 -34
- package/src/scenarios/mcp-toolcall-redaction.test.ts +66 -0
- package/src/scenarios/memory-compaction-event-emitted.test.ts +121 -0
- package/src/scenarios/memory-compaction-provenance-tag.test.ts +116 -0
- package/src/scenarios/memory-compaction-sr1-carry-forward.test.ts +127 -0
- package/src/scenarios/metric-emission.test.ts +113 -0
- package/src/scenarios/multi-region-idempotency.test.ts +39 -4
- package/src/scenarios/orchestratorConservativePath.test.ts +1 -0
- package/src/scenarios/orchestratorDispatch.test.ts +1 -0
- package/src/scenarios/orchestratorTermination.test.ts +1 -0
- package/src/scenarios/otel-emission-grpc.test.ts +98 -0
- package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +139 -0
- package/src/scenarios/pause-resume.test.ts +119 -0
- package/src/scenarios/production-backpressure.test.ts +342 -0
- package/src/scenarios/production-retention-expiry.test.ts +164 -0
- package/src/scenarios/registry-public.test.ts +222 -0
- package/src/scenarios/replay-llm-cache-key.test.ts +35 -0
- package/src/scenarios/replay-retention-expiry.test.ts +178 -0
- package/src/scenarios/restart-during-run.test.ts +177 -0
- package/src/scenarios/spec-corpus-validity.test.ts +59 -26
- package/src/scenarios/staleClaim.test.ts +3 -0
- package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +67 -10
- package/src/scenarios/wasm-pack-memory-cap.test.ts +64 -9
- package/src/scenarios/webhook-negative.test.ts +90 -0
- package/src/scenarios/webhook-signed-delivery.test.ts +178 -0
- package/src/setup.ts +25 -1
- package/vitest.config.ts +5 -1
|
@@ -11,19 +11,28 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Two layers:
|
|
13
13
|
*
|
|
14
|
-
* - **Direct
|
|
15
|
-
* fake peer through SUBMITTED → WORKING →
|
|
16
|
-
*
|
|
14
|
+
* - **Direct peer probe** (always when an A2A endpoint is configured):
|
|
15
|
+
* walks the fake peer through SUBMITTED → WORKING → COMPLETED and
|
|
16
|
+
* asserts the AgentCard + task lifecycle wire shape. With
|
|
17
|
+
* `OPENWOP_A2A_REAL_PEER_URL` set, points at a real reference A2A
|
|
18
|
+
* peer with relaxed shape-only assertions.
|
|
17
19
|
* - **Host-mediated reverse-projection** (gated on fixture
|
|
18
20
|
* advertisement): when the host advertises
|
|
19
21
|
* `conformance-a2a-task-roundtrip`, run it against the fake peer
|
|
20
22
|
* forced into AUTH_REQUIRED / REJECTED to verify the host applies
|
|
21
|
-
* the documented projections.
|
|
23
|
+
* the documented projections. **Real-peer mode does NOT exercise
|
|
24
|
+
* drift points** — real peers don't expose a state-forcing API,
|
|
25
|
+
* so these subtests stay fake-only.
|
|
22
26
|
*
|
|
23
|
-
* Operator contract:
|
|
24
|
-
*
|
|
27
|
+
* Operator contract:
|
|
28
|
+
* - `OPENWOP_A2A_FAKE_PEER=true` — boots the in-process synthetic
|
|
29
|
+
* peer. Asserts the deterministic echo skill + drift-point states.
|
|
30
|
+
* - `OPENWOP_A2A_REAL_PEER_URL=<base-url>` — points the direct probe
|
|
31
|
+
* at a real A2A reference implementation. Drift-point subtests
|
|
32
|
+
* soft-skip in this mode. Phase 3 T3.4 interop-evidence path.
|
|
25
33
|
*
|
|
26
34
|
* @see spec/v1/a2a-integration.md §"State projection"
|
|
35
|
+
* @see docs/PROTOCOL-GAP-CLOSURE-PLAN.md Phase 3 T3.4
|
|
27
36
|
*/
|
|
28
37
|
|
|
29
38
|
import { describe, it, expect } from 'vitest';
|
|
@@ -34,39 +43,149 @@ import { pollUntilTerminal, pollUntilStatus } from '../lib/polling.js';
|
|
|
34
43
|
|
|
35
44
|
const ROUNDTRIP_FIXTURE = 'conformance-a2a-task-roundtrip';
|
|
36
45
|
|
|
46
|
+
/** Resolve the A2A endpoint to probe: real-peer env wins; otherwise the in-process fake. */
|
|
47
|
+
function probePeer(): { url: string; isReal: boolean } | null {
|
|
48
|
+
const real = process.env.OPENWOP_A2A_REAL_PEER_URL;
|
|
49
|
+
if (real && real.length > 0) return { url: real.replace(/\/$/, ''), isReal: true };
|
|
50
|
+
const fake = getA2AFakePeer();
|
|
51
|
+
if (fake) return { url: fake.endpoint(), isReal: false };
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* POST a JSON-RPC 2.0 envelope at `endpoint` and return the parsed
|
|
57
|
+
* response. Throws if the envelope is malformed; surfaces JSON-RPC
|
|
58
|
+
* error responses as a `{error}` field per spec so callers can assert
|
|
59
|
+
* on them.
|
|
60
|
+
*/
|
|
61
|
+
async function rpc(
|
|
62
|
+
endpoint: string,
|
|
63
|
+
method: string,
|
|
64
|
+
params: unknown,
|
|
65
|
+
id: number,
|
|
66
|
+
): Promise<{
|
|
67
|
+
status: number;
|
|
68
|
+
result?: Record<string, unknown>;
|
|
69
|
+
error?: { code: number; message: string };
|
|
70
|
+
}> {
|
|
71
|
+
const res = await fetch(endpoint, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
74
|
+
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
|
|
75
|
+
});
|
|
76
|
+
const body = (await res.json()) as {
|
|
77
|
+
result?: Record<string, unknown>;
|
|
78
|
+
error?: { code: number; message: string };
|
|
79
|
+
};
|
|
80
|
+
const out: {
|
|
81
|
+
status: number;
|
|
82
|
+
result?: Record<string, unknown>;
|
|
83
|
+
error?: { code: number; message: string };
|
|
84
|
+
} = { status: res.status };
|
|
85
|
+
if (body.result !== undefined) out.result = body.result;
|
|
86
|
+
if (body.error !== undefined) out.error = body.error;
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
37
90
|
describe('a2a-task-roundtrip: AgentCard + task lifecycle', () => {
|
|
38
|
-
it('AgentCard exposes protocolVersion + skills;
|
|
39
|
-
const
|
|
40
|
-
if (!
|
|
91
|
+
it('AgentCard exposes protocolVersion + skills; message/send + tasks/get round-trip per A2A v0.3 JSON-RPC', async () => {
|
|
92
|
+
const probe = probePeer();
|
|
93
|
+
if (!probe) {
|
|
41
94
|
// eslint-disable-next-line no-console
|
|
42
|
-
console.warn(
|
|
95
|
+
console.warn(
|
|
96
|
+
'[a2a-task-roundtrip] no A2A endpoint configured; set OPENWOP_A2A_FAKE_PEER=true ' +
|
|
97
|
+
'or OPENWOP_A2A_REAL_PEER_URL=<base-url>',
|
|
98
|
+
);
|
|
43
99
|
return;
|
|
44
100
|
}
|
|
45
|
-
|
|
101
|
+
if (!probe.isReal) getA2AFakePeer()!.reset();
|
|
46
102
|
|
|
47
|
-
// AgentCard
|
|
48
|
-
|
|
103
|
+
// AgentCard at the A2A v0.3 well-known path
|
|
104
|
+
// (`AGENT_CARD_PATH` from @a2a-js/sdk: `.well-known/agent-card.json`).
|
|
105
|
+
const card = await fetch(`${probe.url}/.well-known/agent-card.json`);
|
|
49
106
|
expect(card.status).toBe(200);
|
|
50
|
-
const cardJson = (await card.json()) as {
|
|
107
|
+
const cardJson = (await card.json()) as {
|
|
108
|
+
protocolVersion?: string;
|
|
109
|
+
skills?: ReadonlyArray<{ id?: string; name?: string }>;
|
|
110
|
+
url?: string;
|
|
111
|
+
additionalInterfaces?: ReadonlyArray<{ url?: string; transport?: string }>;
|
|
112
|
+
};
|
|
51
113
|
expect(typeof cardJson.protocolVersion).toBe('string');
|
|
52
114
|
expect(Array.isArray(cardJson.skills)).toBe(true);
|
|
115
|
+
expect((cardJson.skills ?? []).length).toBeGreaterThan(0);
|
|
53
116
|
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
117
|
+
// Find the JSON-RPC transport endpoint. A2A v0.3 hosts MAY advertise
|
|
118
|
+
// multiple transports via `additionalInterfaces`; we pick the first
|
|
119
|
+
// JSONRPC entry, falling back to `card.url`.
|
|
120
|
+
const jsonrpcIface = (cardJson.additionalInterfaces ?? []).find(
|
|
121
|
+
(i) => i.transport === 'JSONRPC',
|
|
122
|
+
);
|
|
123
|
+
const rpcUrl = jsonrpcIface?.url ?? cardJson.url ?? `${probe.url}/a2a/jsonrpc`;
|
|
124
|
+
expect(typeof rpcUrl).toBe('string');
|
|
125
|
+
|
|
126
|
+
if (probe.isReal) {
|
|
127
|
+
// Real-peer interop evidence (Phase 3 T3.4). A2A v0.3 returns
|
|
128
|
+
// EITHER a Task (long-running) OR a Message (direct response)
|
|
129
|
+
// for `message/send` — both are spec-conformant; we only assert
|
|
130
|
+
// the envelope shape.
|
|
131
|
+
const firstSkill = cardJson.skills?.[0];
|
|
132
|
+
const sendRes = await rpc(
|
|
133
|
+
rpcUrl,
|
|
134
|
+
'message/send',
|
|
135
|
+
{
|
|
136
|
+
message: {
|
|
137
|
+
kind: 'message',
|
|
138
|
+
messageId: `probe-${Date.now()}`,
|
|
139
|
+
role: 'user',
|
|
140
|
+
parts: [{ kind: 'text', text: 'interop ping' }],
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
1,
|
|
144
|
+
);
|
|
145
|
+
expect(sendRes.status).toBe(200);
|
|
146
|
+
// Spec-conformant: result is either a Task or a Message envelope.
|
|
147
|
+
const sendResult = sendRes.result ?? {};
|
|
148
|
+
const kind = (sendResult.kind ?? '') as string;
|
|
149
|
+
expect(['task', 'message']).toContain(kind);
|
|
150
|
+
// eslint-disable-next-line no-console
|
|
151
|
+
console.warn(
|
|
152
|
+
`[a2a-task-roundtrip] real-peer interop OK against ${probe.url} ` +
|
|
153
|
+
`(skill=${firstSkill?.id ?? firstSkill?.name}, kind=${kind})`,
|
|
154
|
+
);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Fake-peer path: deterministic state forcing, assert verbatim.
|
|
159
|
+
const fake = getA2AFakePeer()!;
|
|
160
|
+
const sendRes = await rpc(
|
|
161
|
+
rpcUrl,
|
|
162
|
+
'message/send',
|
|
163
|
+
{
|
|
164
|
+
message: {
|
|
165
|
+
kind: 'message',
|
|
166
|
+
messageId: 'probe-fake-1',
|
|
167
|
+
role: 'user',
|
|
168
|
+
parts: [{ kind: 'text', text: 'hello' }],
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
1,
|
|
172
|
+
);
|
|
173
|
+
expect(sendRes.status).toBe(200);
|
|
174
|
+
expect(sendRes.error).toBeUndefined();
|
|
175
|
+
const task = sendRes.result as { id?: string; kind?: string; status?: { state?: string } };
|
|
176
|
+
expect(task.kind).toBe('task');
|
|
177
|
+
expect(typeof task.id).toBe('string');
|
|
62
178
|
|
|
63
|
-
// Advance through
|
|
64
|
-
|
|
65
|
-
|
|
179
|
+
// Advance through WORKING → COMPLETED via the fake's internal API.
|
|
180
|
+
fake.advanceTask(task.id!, 'WORKING');
|
|
181
|
+
fake.advanceTask(task.id!, 'COMPLETED');
|
|
66
182
|
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
expect(
|
|
183
|
+
const getRes = await rpc(rpcUrl, 'tasks/get', { id: task.id }, 2);
|
|
184
|
+
expect(getRes.status).toBe(200);
|
|
185
|
+
expect(getRes.error).toBeUndefined();
|
|
186
|
+
const finalTask = getRes.result as { status?: { state?: string } };
|
|
187
|
+
// A2A v0.3 wire form uses lowercase-hyphen state names.
|
|
188
|
+
expect(finalTask.status?.state).toBe('completed');
|
|
70
189
|
});
|
|
71
190
|
});
|
|
72
191
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Multi-Agent Shift Phase 1 — confidence-escalation contract (CP-1).
|
|
3
|
+
* Normative reference: RFCS/0002-agent-identity-and-reasoning-events.md
|
|
3
4
|
*
|
|
4
5
|
* Verifies: when an `agent.decided` event carries `confidence < threshold`,
|
|
5
6
|
* the host MUST emit `node.suspended { reason: 'low-confidence' }` and
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Multi-Agent Shift Phase 3 — CTI-1 cross-tenant isolation invariant.
|
|
3
|
+
* Normative reference: RFCS/0004-memory-layer.md
|
|
3
4
|
*
|
|
4
5
|
* Verifies the CTI-1 normative invariant: a `memoryRef` resolved by a
|
|
5
6
|
* MemoryAdapter MUST return entries scoped to a single tenant. If
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Multi-Agent Shift Phase 3 — MemoryAdapter list/get round-trip.
|
|
3
|
+
* Normative reference: RFCS/0004-memory-layer.md
|
|
3
4
|
*
|
|
4
5
|
* Verifies that a host advertising `capabilities.agents.memoryBackends:
|
|
5
6
|
* ['long-term']` resolves `AgentRef.memoryRef` to MemoryEntry results
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Multi-Agent Shift Phase 3 — TTL expiry semantics for MemoryEntry.
|
|
3
|
+
* Normative reference: RFCS/0004-memory-layer.md
|
|
3
4
|
*
|
|
4
5
|
* Verifies that memory entries carrying `expiresAt` in the past are
|
|
5
6
|
* NOT surfaced by `MemoryAdapter.list()` / `get()`. The fixture writes
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Multi-Agent Shift Phase 2 — agent-pack export round-trips workspace agents → AgentManifest.
|
|
3
|
+
* Normative reference: RFCS/0003-agent-packs.md
|
|
3
4
|
*
|
|
4
5
|
* Verifies that a host's workspace-scoped agent registry can project
|
|
5
6
|
* agents into the canonical AgentManifest shape for export/distribution.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Multi-Agent Shift Phase 2 — agent-pack install registers AgentManifest entries.
|
|
3
|
+
* Normative reference: RFCS/0003-agent-packs.md
|
|
3
4
|
*
|
|
4
5
|
* Verifies that a pack containing an `agents[]` array surfaces those
|
|
5
6
|
* agent manifests via the host's pack registry. The wire-shape contract
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Multi-Agent Shift Phase 2 — sourceManifestId provenance round-trip.
|
|
3
|
+
* Normative reference: RFCS/0003-agent-packs.md
|
|
3
4
|
*
|
|
4
5
|
* Verifies that when a workspace agent originates from a pack install,
|
|
5
6
|
* the agent's runtime AgentRef AND the exported AgentManifest both
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { describe, it, expect } from 'vitest';
|
|
19
19
|
import { driver } from '../lib/driver.js';
|
|
20
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
20
21
|
|
|
21
22
|
interface AuditIntegrityCaps {
|
|
22
23
|
hashChain?: boolean;
|
|
@@ -39,9 +40,7 @@ async function isProfileAdvertised(): Promise<boolean> {
|
|
|
39
40
|
|
|
40
41
|
describe('audit-log-integrity: profile shape', () => {
|
|
41
42
|
it('host that claims the profile advertises required capability fields', async () => {
|
|
42
|
-
if (!(await isProfileAdvertised())) {
|
|
43
|
-
// eslint-disable-next-line no-console
|
|
44
|
-
console.warn('[audit-log-integrity] profile not advertised; skipping');
|
|
43
|
+
if (!behaviorGate('openwop-audit-log-integrity', await isProfileAdvertised())) {
|
|
45
44
|
return;
|
|
46
45
|
}
|
|
47
46
|
|
|
@@ -64,9 +63,7 @@ describe('audit-log-integrity: profile shape', () => {
|
|
|
64
63
|
|
|
65
64
|
describe('audit-log-integrity: verify endpoint returns chainValid', () => {
|
|
66
65
|
it('GET /v1/audit/verify on a recent range reports chainValid: true', async () => {
|
|
67
|
-
if (!(await isProfileAdvertised())) {
|
|
68
|
-
// eslint-disable-next-line no-console
|
|
69
|
-
console.warn('[audit-log-integrity] profile not advertised; skipping');
|
|
66
|
+
if (!behaviorGate('openwop-audit-log-integrity', await isProfileAdvertised())) {
|
|
70
67
|
return;
|
|
71
68
|
}
|
|
72
69
|
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0010 §B: openwop-auth-api-key-rotation profile.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that hosts claiming the rotation profile satisfy
|
|
5
|
+
* `spec/v1/auth-profiles.md` §`openwop-auth-api-key-rotation`:
|
|
6
|
+
*
|
|
7
|
+
* 1. `capabilities.auth.profiles[]` includes `openwop-auth-api-key-rotation`
|
|
8
|
+
* and `capabilities.auth.rotation.supported === true`.
|
|
9
|
+
* 2. When `minGraceSeconds` is advertised, it's an integer ≥ 0;
|
|
10
|
+
* production-profile hosts SHOULD advertise ≥ 86400 (24h) per spec.
|
|
11
|
+
* 3. When the operator supplies a secondary key via
|
|
12
|
+
* `OPENWOP_TEST_SECONDARY_API_KEY`, both keys MUST authenticate the
|
|
13
|
+
* same operation. The rotation invariant: both map to the same
|
|
14
|
+
* principal/tenant within the grace window.
|
|
15
|
+
* 4. An invalid bearer (a synthetic canary, not a real key) returns
|
|
16
|
+
* 401 `invalid_token`; the response body MUST NOT echo the canary
|
|
17
|
+
* in any field (`auth.md` §3 credential-redaction MUST).
|
|
18
|
+
*
|
|
19
|
+
* The two-key overlap soft-skips when `OPENWOP_TEST_SECONDARY_API_KEY`
|
|
20
|
+
* is unset. The capability-shape and canary-redaction assertions run
|
|
21
|
+
* unconditionally when the profile is advertised.
|
|
22
|
+
*
|
|
23
|
+
* @see RFCS/0010-auth-profile-conformance.md §B
|
|
24
|
+
* @see spec/v1/auth-profiles.md §`openwop-auth-api-key-rotation`
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, it, expect } from 'vitest';
|
|
28
|
+
import { driver } from '../lib/driver.js';
|
|
29
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
30
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
31
|
+
|
|
32
|
+
interface RotationCaps {
|
|
33
|
+
supported?: boolean;
|
|
34
|
+
minGraceSeconds?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface AuthCaps {
|
|
38
|
+
profiles?: string[];
|
|
39
|
+
rotation?: RotationCaps;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const PROFILE = 'openwop-auth-api-key-rotation';
|
|
43
|
+
const FIXTURE = 'conformance-noop';
|
|
44
|
+
const CANARY = 'hk_openwop_canary_d1d2d3d4_NOT_A_REAL_KEY';
|
|
45
|
+
|
|
46
|
+
async function readAuthCaps(): Promise<AuthCaps | undefined> {
|
|
47
|
+
const disco = await driver.get('/.well-known/openwop');
|
|
48
|
+
return (disco.json as { capabilities?: { auth?: AuthCaps } }).capabilities?.auth;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isProfileAdvertised(auth: AuthCaps | undefined): boolean {
|
|
52
|
+
return (
|
|
53
|
+
Array.isArray(auth?.profiles) &&
|
|
54
|
+
auth.profiles.includes(PROFILE) &&
|
|
55
|
+
auth.rotation?.supported === true
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('auth-api-key-rotation: capability shape', () => {
|
|
60
|
+
it('host claiming rotation profile advertises required fields', async () => {
|
|
61
|
+
const auth = await readAuthCaps();
|
|
62
|
+
|
|
63
|
+
if (!behaviorGate(PROFILE, isProfileAdvertised(auth))) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
expect(auth?.profiles?.includes(PROFILE), driver.describe(
|
|
68
|
+
'auth-profiles.md §`openwop-auth-api-key-rotation`',
|
|
69
|
+
'capabilities.auth.profiles MUST include openwop-auth-api-key-rotation when the profile is claimed',
|
|
70
|
+
)).toBe(true);
|
|
71
|
+
|
|
72
|
+
expect(auth?.rotation?.supported, driver.describe(
|
|
73
|
+
'auth-profiles.md §`openwop-auth-api-key-rotation`',
|
|
74
|
+
'capabilities.auth.rotation.supported MUST be true when the profile is claimed',
|
|
75
|
+
)).toBe(true);
|
|
76
|
+
|
|
77
|
+
if (auth?.rotation?.minGraceSeconds !== undefined) {
|
|
78
|
+
expect(
|
|
79
|
+
Number.isInteger(auth.rotation.minGraceSeconds) &&
|
|
80
|
+
auth.rotation.minGraceSeconds >= 0,
|
|
81
|
+
driver.describe(
|
|
82
|
+
'capabilities.schema.json auth.rotation.minGraceSeconds',
|
|
83
|
+
'minGraceSeconds MUST be a non-negative integer when advertised',
|
|
84
|
+
),
|
|
85
|
+
).toBe(true);
|
|
86
|
+
|
|
87
|
+
if (auth.rotation.minGraceSeconds < 86400) {
|
|
88
|
+
// eslint-disable-next-line no-console
|
|
89
|
+
console.warn(
|
|
90
|
+
`[auth-api-key-rotation] minGraceSeconds=${auth.rotation.minGraceSeconds} is below the 24h floor auth-profiles.md SHOULDs for production-profile hosts`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('auth-api-key-rotation: two-key overlap', () => {
|
|
98
|
+
it('primary + secondary keys both authenticate the same operation', async () => {
|
|
99
|
+
const auth = await readAuthCaps();
|
|
100
|
+
|
|
101
|
+
if (!behaviorGate(PROFILE, isProfileAdvertised(auth))) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const secondaryKey = process.env.OPENWOP_TEST_SECONDARY_API_KEY;
|
|
106
|
+
if (!secondaryKey) {
|
|
107
|
+
// eslint-disable-next-line no-console
|
|
108
|
+
console.warn(
|
|
109
|
+
'[auth-api-key-rotation] OPENWOP_TEST_SECONDARY_API_KEY not supplied; skipping two-key overlap assertion',
|
|
110
|
+
);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!isFixtureAdvertised(FIXTURE)) {
|
|
115
|
+
// eslint-disable-next-line no-console
|
|
116
|
+
console.warn(
|
|
117
|
+
`[auth-api-key-rotation] ${FIXTURE} not advertised; skipping overlap assertion`,
|
|
118
|
+
);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Primary key — uses driver's default Authorization header (env-loaded).
|
|
123
|
+
const primary = await driver.post('/v1/runs', { workflowId: FIXTURE });
|
|
124
|
+
expect(primary.status, driver.describe(
|
|
125
|
+
'auth-profiles.md §`openwop-auth-api-key-rotation`',
|
|
126
|
+
'primary key MUST authenticate POST /v1/runs during rotation grace',
|
|
127
|
+
)).toBe(201);
|
|
128
|
+
const primaryRunId = (primary.json as { runId: string }).runId;
|
|
129
|
+
|
|
130
|
+
// Secondary key — supplied via env, sent verbatim.
|
|
131
|
+
const secondary = await driver.post(
|
|
132
|
+
'/v1/runs',
|
|
133
|
+
{ workflowId: FIXTURE },
|
|
134
|
+
{
|
|
135
|
+
authenticated: false,
|
|
136
|
+
headers: { Authorization: `Bearer ${secondaryKey}` },
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
expect(secondary.status, driver.describe(
|
|
140
|
+
'auth-profiles.md §`openwop-auth-api-key-rotation`',
|
|
141
|
+
'secondary key MUST authenticate POST /v1/runs during rotation grace',
|
|
142
|
+
)).toBe(201);
|
|
143
|
+
const secondaryRunId = (secondary.json as { runId: string }).runId;
|
|
144
|
+
|
|
145
|
+
// Both runs MUST be distinct (different keys are still independent
|
|
146
|
+
// authentications, not idempotent retries) but MUST have been
|
|
147
|
+
// accepted, proving overlap is honored.
|
|
148
|
+
expect(primaryRunId).not.toBe(secondaryRunId);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('auth-api-key-rotation: canary redaction', () => {
|
|
153
|
+
it('invalid bearer returns 401 without echoing the canary credential', async () => {
|
|
154
|
+
const auth = await readAuthCaps();
|
|
155
|
+
|
|
156
|
+
if (!behaviorGate(PROFILE, isProfileAdvertised(auth))) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const res = await driver.post(
|
|
161
|
+
'/v1/runs',
|
|
162
|
+
{ workflowId: FIXTURE },
|
|
163
|
+
{
|
|
164
|
+
authenticated: false,
|
|
165
|
+
headers: { Authorization: `Bearer ${CANARY}` },
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
expect(res.status, driver.describe(
|
|
170
|
+
'auth.md §3',
|
|
171
|
+
'invalid bearer MUST return 401, not 200 or 403',
|
|
172
|
+
)).toBe(401);
|
|
173
|
+
|
|
174
|
+
// The response body MUST NOT echo the canary in any field. We
|
|
175
|
+
// check the serialized JSON to catch echoes even in nested fields.
|
|
176
|
+
const serialized = JSON.stringify(res.json ?? {});
|
|
177
|
+
expect(serialized.includes(CANARY), driver.describe(
|
|
178
|
+
'auth.md §"No credential echo" + threat-model-auth-profiles.md A1',
|
|
179
|
+
'rotation-profile hosts MUST NOT echo the rejected credential in error responses',
|
|
180
|
+
)).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
});
|