@reproapp/node-sdk 0.0.46 → 0.0.48
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/dist/index.d.ts +16 -3
- package/dist/index.js +327 -159
- package/dist/ingest/client.js +11 -0
- package/dist/privacy.d.ts +1 -0
- package/dist/privacy.js +306 -7
- package/dist/semantic-serialization.d.ts +9 -0
- package/dist/semantic-serialization.js +171 -0
- package/package.json +3 -3
- package/src/index.ts +513 -144
- package/src/ingest/client.ts +13 -0
- package/src/privacy.ts +409 -7
- package/src/semantic-serialization.ts +182 -0
- package/test/content-addressed-mongodb.testcontainers.test.js +348 -0
- package/test/content-addressed-trace-values.test.js +264 -0
- package/test/fixtures/semantic-serialization-trace-controller.js +85 -0
- package/test/fixtures/semantic-serialization-trace-server.js +19 -0
- package/test/ingest-high-priority-fairness.test.js +76 -0
- package/test/policy-capture-attestation.test.js +234 -0
- package/test/policy-session-completion.testcontainers.test.js +271 -0
- package/test/privacy-policy-index.test.js +194 -0
- package/test/sdk-background-timeout-isolation.test.js +39 -0
- package/test/semantic-serialization-trace.test.js +152 -0
- package/test/semantic-serialization.test.js +229 -0
- package/test/session-storage-budget.test.js +298 -0
- package/test/trace-batch-size.test.js +37 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const { reproMiddleware } = require('../../dist');
|
|
3
|
+
const { handleSemanticTrace } = require('./semantic-serialization-trace-controller');
|
|
4
|
+
|
|
5
|
+
async function startServer(config) {
|
|
6
|
+
const app = express();
|
|
7
|
+
app.use(reproMiddleware(config));
|
|
8
|
+
app.get('/semantic-trace', handleSemanticTrace);
|
|
9
|
+
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const server = app.listen(0, '127.0.0.1');
|
|
12
|
+
server.once('listening', () => resolve(server));
|
|
13
|
+
server.once('error', reject);
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
module.exports = {
|
|
18
|
+
startServer,
|
|
19
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
|
|
3
|
+
const originalFetch = global.fetch;
|
|
4
|
+
const attempts = [];
|
|
5
|
+
|
|
6
|
+
global.fetch = async (_url, init = {}) => {
|
|
7
|
+
const body = JSON.parse(String(init.body || '{}'));
|
|
8
|
+
const requestIds = (body.events || []).map((event) => event?.payload?.requestRid);
|
|
9
|
+
attempts.push(requestIds);
|
|
10
|
+
const hasPoisonedItem = requestIds.includes('R_poisoned');
|
|
11
|
+
return {
|
|
12
|
+
ok: !hasPoisonedItem,
|
|
13
|
+
status: hasPoisonedItem ? 503 : 202,
|
|
14
|
+
json: async () => ({ ok: !hasPoisonedItem }),
|
|
15
|
+
text: async () => JSON.stringify({ ok: !hasPoisonedItem }),
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const { enqueueIngestEntries, flushIngestQueue } = require('../dist/ingest/client');
|
|
20
|
+
|
|
21
|
+
function manifest(requestRid) {
|
|
22
|
+
return {
|
|
23
|
+
actionId: 'A_fairness',
|
|
24
|
+
requestRid,
|
|
25
|
+
requestCaptureManifest: {
|
|
26
|
+
scopeId: `req:${requestRid}`,
|
|
27
|
+
requestRid,
|
|
28
|
+
transport: 'http',
|
|
29
|
+
expected: {
|
|
30
|
+
backendRequest: 1,
|
|
31
|
+
traceBatch: 0,
|
|
32
|
+
dbChange: 0,
|
|
33
|
+
emailEvent: 0,
|
|
34
|
+
},
|
|
35
|
+
closedAt: Date.now(),
|
|
36
|
+
},
|
|
37
|
+
t: Date.now(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function main() {
|
|
42
|
+
const cfg = {
|
|
43
|
+
tenantId: 'TENANT_test',
|
|
44
|
+
appId: 'APP_test',
|
|
45
|
+
appSecret: 'secret',
|
|
46
|
+
serviceName: 'ingest-fairness-test',
|
|
47
|
+
ingestBase: 'http://127.0.0.1:65535',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
enqueueIngestEntries(cfg, 'S_fairness', [manifest('R_poisoned')], { priority: 'high' });
|
|
51
|
+
enqueueIngestEntries(cfg, 'S_fairness', [manifest('R_valid')], { priority: 'high' });
|
|
52
|
+
|
|
53
|
+
await flushIngestQueue();
|
|
54
|
+
await flushIngestQueue();
|
|
55
|
+
await flushIngestQueue();
|
|
56
|
+
|
|
57
|
+
assert(
|
|
58
|
+
attempts.some((requestIds) => requestIds.length === 1 && requestIds[0] === 'R_valid'),
|
|
59
|
+
`valid completion evidence remained blocked: ${JSON.stringify(attempts)}`,
|
|
60
|
+
);
|
|
61
|
+
assert(
|
|
62
|
+
attempts.filter((requestIds) => requestIds.includes('R_poisoned')).length >= 2,
|
|
63
|
+
`failed high-priority evidence was dropped instead of retained: ${JSON.stringify(attempts)}`,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
console.log('ingest high priority fairness OK');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
main()
|
|
70
|
+
.catch((error) => {
|
|
71
|
+
console.error(error);
|
|
72
|
+
process.exitCode = 1;
|
|
73
|
+
})
|
|
74
|
+
.finally(() => {
|
|
75
|
+
global.fetch = originalFetch;
|
|
76
|
+
});
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
process.env.REPRO_SDK_BACKGROUND_IDLE_DELAY_MS = '0';
|
|
2
|
+
process.env.REPRO_SDK_BACKGROUND_BUSY_IDLE_DELAY_MS = '0';
|
|
3
|
+
process.env.REPRO_SDK_BACKGROUND_MAX_DEFER_MS = '0';
|
|
4
|
+
process.env.TRACE_IDLE_FLUSH_MS = '10';
|
|
5
|
+
process.env.TRACE_LINGER_AFTER_FINISH_MS = '0';
|
|
6
|
+
|
|
7
|
+
const assert = require('node:assert/strict');
|
|
8
|
+
const http = require('node:http');
|
|
9
|
+
const express = require('express');
|
|
10
|
+
|
|
11
|
+
const {
|
|
12
|
+
__reproTestHooks,
|
|
13
|
+
initReproTracing,
|
|
14
|
+
reproMiddleware,
|
|
15
|
+
} = require('../dist');
|
|
16
|
+
const {
|
|
17
|
+
normalizeRuntimePrivacyPolicy,
|
|
18
|
+
} = require('../dist/privacy');
|
|
19
|
+
const { flushIngestQueue } = require('../dist/ingest/client');
|
|
20
|
+
|
|
21
|
+
function flattenEvents(posts) {
|
|
22
|
+
return posts.flatMap((body) => Array.isArray(body?.events) ? body.events : []);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function waitForCapturedEvent(capturedBodies, sessionId, eventType, timeoutMs = 2000) {
|
|
26
|
+
const deadline = Date.now() + timeoutMs;
|
|
27
|
+
while (Date.now() < deadline) {
|
|
28
|
+
const found = flattenEvents(capturedBodies).some(
|
|
29
|
+
(event) => event.session_id === sessionId && event.event_type === eventType,
|
|
30
|
+
);
|
|
31
|
+
if (found) return;
|
|
32
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
33
|
+
}
|
|
34
|
+
assert.fail(`timed out waiting for ${eventType} in ${sessionId}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function requestJson(port, path) {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const request = http.get({
|
|
40
|
+
host: '127.0.0.1',
|
|
41
|
+
port,
|
|
42
|
+
path,
|
|
43
|
+
headers: {
|
|
44
|
+
'x-bug-session-id': `S_attestation_${path.slice(1)}`,
|
|
45
|
+
'x-bug-action-id': `A_attestation_${path.slice(1)}`,
|
|
46
|
+
'x-bug-request-start': String(Date.now()),
|
|
47
|
+
},
|
|
48
|
+
}, (response) => {
|
|
49
|
+
const chunks = [];
|
|
50
|
+
response.on('data', (chunk) => chunks.push(chunk));
|
|
51
|
+
response.on('end', () => {
|
|
52
|
+
try {
|
|
53
|
+
resolve({
|
|
54
|
+
status: response.statusCode,
|
|
55
|
+
body: JSON.parse(Buffer.concat(chunks).toString('utf8')),
|
|
56
|
+
});
|
|
57
|
+
} catch (error) {
|
|
58
|
+
reject(error);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
request.on('error', reject);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function drainCapture() {
|
|
67
|
+
await __reproTestHooks.drainSdkBackgroundQueueForTest();
|
|
68
|
+
await flushIngestQueue();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function policyWithProtectedSecret() {
|
|
72
|
+
return normalizeRuntimePrivacyPolicy({
|
|
73
|
+
environment: 'dev',
|
|
74
|
+
statements: [{
|
|
75
|
+
sid: 'drop-internal-secret',
|
|
76
|
+
when: {
|
|
77
|
+
scope: 'FIELD',
|
|
78
|
+
selector: 'field:internalSecret',
|
|
79
|
+
target: 'response.body.internalSecret|trace.args.internalSecret',
|
|
80
|
+
},
|
|
81
|
+
apply: { action: 'DROP' },
|
|
82
|
+
meta: { priority: 1 },
|
|
83
|
+
}],
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function main() {
|
|
88
|
+
const capturedBodies = [];
|
|
89
|
+
const originalFetch = global.fetch;
|
|
90
|
+
global.fetch = async (_url, init = {}) => {
|
|
91
|
+
capturedBodies.push(JSON.parse(String(init.body || '{}')));
|
|
92
|
+
return {
|
|
93
|
+
ok: true,
|
|
94
|
+
status: 202,
|
|
95
|
+
json: async () => ({ accepted: 1 }),
|
|
96
|
+
text: async () => '{"accepted":1}',
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
let server;
|
|
101
|
+
try {
|
|
102
|
+
initReproTracing({ instrument: false, logFunctionCalls: false });
|
|
103
|
+
|
|
104
|
+
const failingCfg = {
|
|
105
|
+
tenantId: 'TENANT_test',
|
|
106
|
+
appId: 'APP_policy_failure',
|
|
107
|
+
appSecret: 'secret',
|
|
108
|
+
serviceName: 'policy-attestation',
|
|
109
|
+
privacy: { environment: 'dev' },
|
|
110
|
+
ingestBase: 'http://127.0.0.1:65535',
|
|
111
|
+
};
|
|
112
|
+
const failingPolicy = policyWithProtectedSecret();
|
|
113
|
+
assert(failingPolicy);
|
|
114
|
+
// Fault injection after normalization exercises the real SDK failure path
|
|
115
|
+
// without adding a production-only bypass to the policy engine.
|
|
116
|
+
failingPolicy.statements[0].selectorLeaves = null;
|
|
117
|
+
__reproTestHooks.setRuntimePrivacyPolicyForTest(failingCfg, failingPolicy);
|
|
118
|
+
|
|
119
|
+
const rawCfg = {
|
|
120
|
+
...failingCfg,
|
|
121
|
+
appId: 'APP_policy_raw',
|
|
122
|
+
privacy: {
|
|
123
|
+
environment: 'dev',
|
|
124
|
+
captureRaw: true,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
__reproTestHooks.setRuntimePrivacyPolicyForTest(rawCfg, failingPolicy);
|
|
128
|
+
|
|
129
|
+
const app = express();
|
|
130
|
+
app.use('/failed', reproMiddleware(failingCfg));
|
|
131
|
+
app.get('/failed', (_request, response) => {
|
|
132
|
+
const value = global.__repro_call(
|
|
133
|
+
(input) => input,
|
|
134
|
+
null,
|
|
135
|
+
[{ internalSecret: 'super-secret-policy-value' }],
|
|
136
|
+
__filename,
|
|
137
|
+
1,
|
|
138
|
+
'PolicyService.capture',
|
|
139
|
+
true,
|
|
140
|
+
);
|
|
141
|
+
response.json({
|
|
142
|
+
ok: true,
|
|
143
|
+
internalSecret: value.internalSecret,
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
app.use('/raw', reproMiddleware(rawCfg));
|
|
147
|
+
app.get('/raw', (_request, response) => {
|
|
148
|
+
response.json({
|
|
149
|
+
ok: true,
|
|
150
|
+
internalSecret: 'raw-mode-secret',
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
server = await new Promise((resolve) => {
|
|
155
|
+
const listening = app.listen(0, '127.0.0.1', () => resolve(listening));
|
|
156
|
+
});
|
|
157
|
+
const port = server.address().port;
|
|
158
|
+
|
|
159
|
+
const failedResponse = await requestJson(port, '/failed');
|
|
160
|
+
assert.equal(failedResponse.status, 200);
|
|
161
|
+
assert.equal(failedResponse.body.internalSecret, 'super-secret-policy-value');
|
|
162
|
+
await waitForCapturedEvent(
|
|
163
|
+
capturedBodies,
|
|
164
|
+
'S_attestation_failed',
|
|
165
|
+
'request_capture_manifest',
|
|
166
|
+
);
|
|
167
|
+
await drainCapture();
|
|
168
|
+
|
|
169
|
+
const failedEvents = flattenEvents(capturedBodies)
|
|
170
|
+
.filter((event) => event.session_id === 'S_attestation_failed');
|
|
171
|
+
const failedRequest = failedEvents.find((event) => event.event_type === 'backend_request');
|
|
172
|
+
const failedManifest = failedEvents.find((event) => event.event_type === 'request_capture_manifest');
|
|
173
|
+
const failedTrace = failedEvents.find((event) => event.event_type === 'trace_batch');
|
|
174
|
+
assert(failedRequest, 'policy failure must retain backend request metadata');
|
|
175
|
+
assert(failedManifest, 'policy failure must emit a terminal request manifest');
|
|
176
|
+
assert(failedTrace, 'policy failure must retain a metadata-only trace');
|
|
177
|
+
assert.equal(
|
|
178
|
+
failedManifest.payload.requestCaptureManifest.expected.backendRequest,
|
|
179
|
+
1,
|
|
180
|
+
'observed request count must not become zero after policy failure',
|
|
181
|
+
);
|
|
182
|
+
assert.equal(failedManifest.payload.requestCaptureManifest.privacy.mode, 'policy');
|
|
183
|
+
assert.equal(failedManifest.payload.requestCaptureManifest.privacy.status, 'failed');
|
|
184
|
+
assert.match(
|
|
185
|
+
failedManifest.payload.requestCaptureManifest.privacy.policyFingerprint,
|
|
186
|
+
/^[a-f0-9]{64}$/,
|
|
187
|
+
);
|
|
188
|
+
assert(
|
|
189
|
+
failedManifest.payload.requestCaptureManifest.captureFailures.length > 0,
|
|
190
|
+
'policy failure must be declared for hydration',
|
|
191
|
+
);
|
|
192
|
+
assert.equal(
|
|
193
|
+
JSON.stringify(failedEvents).includes('super-secret-policy-value'),
|
|
194
|
+
false,
|
|
195
|
+
'failed policy materialization must fail closed without raw-value leakage',
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
capturedBodies.length = 0;
|
|
199
|
+
const rawResponse = await requestJson(port, '/raw');
|
|
200
|
+
assert.equal(rawResponse.status, 200);
|
|
201
|
+
await waitForCapturedEvent(
|
|
202
|
+
capturedBodies,
|
|
203
|
+
'S_attestation_raw',
|
|
204
|
+
'request_capture_manifest',
|
|
205
|
+
);
|
|
206
|
+
await drainCapture();
|
|
207
|
+
|
|
208
|
+
const rawEvents = flattenEvents(capturedBodies)
|
|
209
|
+
.filter((event) => event.session_id === 'S_attestation_raw');
|
|
210
|
+
const rawManifest = rawEvents.find((event) => event.event_type === 'request_capture_manifest');
|
|
211
|
+
assert(rawManifest);
|
|
212
|
+
assert.equal(rawManifest.payload.requestCaptureManifest.privacy.mode, 'raw');
|
|
213
|
+
assert.equal(rawManifest.payload.requestCaptureManifest.privacy.status, 'bypassed');
|
|
214
|
+
assert.equal(rawManifest.payload.requestCaptureManifest.captureFailures, undefined);
|
|
215
|
+
assert(
|
|
216
|
+
JSON.stringify(rawEvents).includes('raw-mode-secret'),
|
|
217
|
+
'captureRaw must continue to retain raw values explicitly',
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
console.log('policy capture attestation OK');
|
|
221
|
+
} finally {
|
|
222
|
+
await __reproTestHooks.drainSdkBackgroundQueueForTest().catch(() => {});
|
|
223
|
+
await flushIngestQueue().catch(() => {});
|
|
224
|
+
global.fetch = originalFetch;
|
|
225
|
+
if (server) {
|
|
226
|
+
await new Promise((resolve) => server.close(resolve));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
main().catch((error) => {
|
|
232
|
+
console.error(error);
|
|
233
|
+
process.exit(1);
|
|
234
|
+
});
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
process.env.REPRO_SDK_BACKGROUND_IDLE_DELAY_MS = '0';
|
|
2
|
+
process.env.REPRO_SDK_BACKGROUND_BUSY_IDLE_DELAY_MS = '0';
|
|
3
|
+
process.env.REPRO_SDK_BACKGROUND_MAX_DEFER_MS = '0';
|
|
4
|
+
process.env.TRACE_IDLE_FLUSH_MS = '10';
|
|
5
|
+
process.env.TRACE_LINGER_AFTER_FINISH_MS = '0';
|
|
6
|
+
|
|
7
|
+
const assert = require('node:assert/strict');
|
|
8
|
+
const http = require('node:http');
|
|
9
|
+
const express = require('express');
|
|
10
|
+
const mongoose = require('mongoose');
|
|
11
|
+
const { GenericContainer, Wait } = require('testcontainers');
|
|
12
|
+
|
|
13
|
+
const {
|
|
14
|
+
__reproTestHooks,
|
|
15
|
+
initReproTracing,
|
|
16
|
+
reproMiddleware,
|
|
17
|
+
reproMongoosePlugin,
|
|
18
|
+
} = require('../dist');
|
|
19
|
+
const { flushIngestQueue } = require('../dist/ingest/client');
|
|
20
|
+
|
|
21
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
22
|
+
|
|
23
|
+
function flattenEvents(posts) {
|
|
24
|
+
return posts.flatMap((body) => Array.isArray(body?.events) ? body.events : []);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function eventsOf(posts, eventType) {
|
|
28
|
+
return flattenEvents(posts).filter((event) => event?.event_type === eventType);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function largePolicy() {
|
|
32
|
+
return {
|
|
33
|
+
environment: 'dev',
|
|
34
|
+
statements: [
|
|
35
|
+
...Array.from({ length: 5000 }, (_, index) => ({
|
|
36
|
+
sid: `function-${index}`,
|
|
37
|
+
when: {
|
|
38
|
+
scope: 'FUNCTION',
|
|
39
|
+
selector: `GeneratedService${index}.method${index}`,
|
|
40
|
+
target: 'trace.args|trace.returnValue',
|
|
41
|
+
},
|
|
42
|
+
apply: { action: 'KEEP_EXACT' },
|
|
43
|
+
meta: { priority: index + 10 },
|
|
44
|
+
})),
|
|
45
|
+
{
|
|
46
|
+
sid: 'drop-internal-secret',
|
|
47
|
+
when: {
|
|
48
|
+
scope: 'FIELD',
|
|
49
|
+
selector: 'field:internalSecret',
|
|
50
|
+
target: 'response.body.data[].internalSecret|db.resultMeta[].internalSecret',
|
|
51
|
+
},
|
|
52
|
+
apply: { action: 'DROP' },
|
|
53
|
+
meta: { priority: 1 },
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function startMongo() {
|
|
60
|
+
const container = await new GenericContainer('mongo:7')
|
|
61
|
+
.withExposedPorts(27017)
|
|
62
|
+
.withTmpFs({ '/data/db': 'rw,noexec,nosuid,size=1g' })
|
|
63
|
+
.withCommand(['mongod', '--bind_ip_all', '--setParameter', 'diagnosticDataCollectionEnabled=false'])
|
|
64
|
+
.withWaitStrategy(Wait.forListeningPorts())
|
|
65
|
+
.start();
|
|
66
|
+
return {
|
|
67
|
+
container,
|
|
68
|
+
uri: `mongodb://${container.getHost()}:${container.getMappedPort(27017)}/repro_policy_completion`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function requestNames(port, index) {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const request = http.get({
|
|
75
|
+
host: '127.0.0.1',
|
|
76
|
+
port,
|
|
77
|
+
path: '/names',
|
|
78
|
+
headers: {
|
|
79
|
+
'x-bug-session-id': 'S_policy_completion',
|
|
80
|
+
'x-bug-action-id': `A_policy_completion_${index}`,
|
|
81
|
+
'x-bug-request-start': String(Date.now()),
|
|
82
|
+
},
|
|
83
|
+
}, (response) => {
|
|
84
|
+
const chunks = [];
|
|
85
|
+
response.on('data', (chunk) => chunks.push(chunk));
|
|
86
|
+
response.on('end', () => {
|
|
87
|
+
try {
|
|
88
|
+
assert.equal(response.statusCode, 200);
|
|
89
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
90
|
+
assert.equal(parsed.data.length, 8);
|
|
91
|
+
resolve();
|
|
92
|
+
} catch (error) {
|
|
93
|
+
reject(error);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
request.on('error', reject);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function waitForCompleteCapture(capturedBodies, expectedRequests, timeoutMs = 30000) {
|
|
102
|
+
const deadline = Date.now() + timeoutMs;
|
|
103
|
+
while (Date.now() < deadline) {
|
|
104
|
+
await __reproTestHooks.drainSdkBackgroundQueueForTest();
|
|
105
|
+
await flushIngestQueue();
|
|
106
|
+
const backendRequests = eventsOf(capturedBodies, 'backend_request');
|
|
107
|
+
const manifests = eventsOf(capturedBodies, 'request_capture_manifest');
|
|
108
|
+
const traceBatches = eventsOf(capturedBodies, 'trace_batch');
|
|
109
|
+
if (
|
|
110
|
+
backendRequests.length === expectedRequests &&
|
|
111
|
+
manifests.length === expectedRequests &&
|
|
112
|
+
traceBatches.length >= expectedRequests
|
|
113
|
+
) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
await sleep(25);
|
|
117
|
+
}
|
|
118
|
+
assert.fail(JSON.stringify({
|
|
119
|
+
backendRequests: eventsOf(capturedBodies, 'backend_request').length,
|
|
120
|
+
manifests: eventsOf(capturedBodies, 'request_capture_manifest').length,
|
|
121
|
+
traceBatches: eventsOf(capturedBodies, 'trace_batch').length,
|
|
122
|
+
dbChanges: eventsOf(capturedBodies, 'db_change').length,
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function main() {
|
|
127
|
+
const capturedBodies = [];
|
|
128
|
+
const originalFetch = global.fetch;
|
|
129
|
+
global.fetch = async (_url, init = {}) => {
|
|
130
|
+
capturedBodies.push(JSON.parse(String(init.body || '{}')));
|
|
131
|
+
return {
|
|
132
|
+
ok: true,
|
|
133
|
+
status: 202,
|
|
134
|
+
json: async () => ({ accepted: 1 }),
|
|
135
|
+
text: async () => '{"accepted":1}',
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
let mongo;
|
|
140
|
+
let connection;
|
|
141
|
+
let server;
|
|
142
|
+
try {
|
|
143
|
+
mongo = await startMongo();
|
|
144
|
+
connection = await mongoose.createConnection(mongo.uri).asPromise();
|
|
145
|
+
initReproTracing({ instrument: false, logFunctionCalls: false });
|
|
146
|
+
|
|
147
|
+
const cfg = {
|
|
148
|
+
tenantId: 'TENANT_test',
|
|
149
|
+
appId: 'APP_test',
|
|
150
|
+
appSecret: 'secret',
|
|
151
|
+
serviceName: 'policy-session-completion',
|
|
152
|
+
captureHeaders: false,
|
|
153
|
+
privacy: {
|
|
154
|
+
environment: 'dev',
|
|
155
|
+
policy: largePolicy(),
|
|
156
|
+
},
|
|
157
|
+
ingestBase: 'http://127.0.0.1:65535',
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const depotSchema = new mongoose.Schema({
|
|
161
|
+
location: String,
|
|
162
|
+
description: String,
|
|
163
|
+
internalSecret: String,
|
|
164
|
+
});
|
|
165
|
+
reproMongoosePlugin(cfg)(depotSchema);
|
|
166
|
+
const Depot = connection.model('Depot', depotSchema, 'depots');
|
|
167
|
+
await Depot.insertMany(Array.from({ length: 8 }, (_, index) => ({
|
|
168
|
+
location: `Depot ${index}`,
|
|
169
|
+
description: `Large depot description ${index} `.repeat(500),
|
|
170
|
+
internalSecret: `secret-${index}`,
|
|
171
|
+
})));
|
|
172
|
+
|
|
173
|
+
async function loadNames() {
|
|
174
|
+
return Depot.find({}).lean();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const app = express();
|
|
178
|
+
app.use(reproMiddleware(cfg));
|
|
179
|
+
app.get('/names', async (_request, response, next) => {
|
|
180
|
+
try {
|
|
181
|
+
const data = await global.__repro_call(
|
|
182
|
+
loadNames,
|
|
183
|
+
null,
|
|
184
|
+
[],
|
|
185
|
+
__filename,
|
|
186
|
+
1,
|
|
187
|
+
'loadNames',
|
|
188
|
+
true,
|
|
189
|
+
);
|
|
190
|
+
response.json({ data, message: 'depot names', statusCode: 200 });
|
|
191
|
+
} catch (error) {
|
|
192
|
+
next(error);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
server = await new Promise((resolve) => {
|
|
197
|
+
const listening = app.listen(0, '127.0.0.1', () => resolve(listening));
|
|
198
|
+
});
|
|
199
|
+
const port = server.address().port;
|
|
200
|
+
|
|
201
|
+
const startedAt = Date.now();
|
|
202
|
+
await Promise.all(Array.from({ length: 14 }, (_, index) => requestNames(port, index)));
|
|
203
|
+
await waitForCompleteCapture(capturedBodies, 14);
|
|
204
|
+
const elapsedMs = Date.now() - startedAt;
|
|
205
|
+
|
|
206
|
+
const backendRequests = eventsOf(capturedBodies, 'backend_request');
|
|
207
|
+
const manifests = eventsOf(capturedBodies, 'request_capture_manifest');
|
|
208
|
+
const dbChanges = eventsOf(capturedBodies, 'db_change');
|
|
209
|
+
assert.equal(backendRequests.length, 14);
|
|
210
|
+
assert.equal(manifests.length, 14);
|
|
211
|
+
assert(dbChanges.length >= 14, `expected Mongo evidence for every request, got ${dbChanges.length}`);
|
|
212
|
+
assert(
|
|
213
|
+
manifests.every((event) => event.payload?.requestCaptureManifest?.expected?.backendRequest === 1),
|
|
214
|
+
'every request manifest must account for its backend request',
|
|
215
|
+
);
|
|
216
|
+
assert(
|
|
217
|
+
manifests.every((event) => event.payload?.requestCaptureManifest?.expected?.traceBatch >= 1),
|
|
218
|
+
'every request manifest must account for its trace batches',
|
|
219
|
+
);
|
|
220
|
+
assert(
|
|
221
|
+
manifests.every((event) => event.payload?.requestCaptureManifest?.expected?.dbChange >= 1),
|
|
222
|
+
'every request manifest must declare each observed Mongo operation before materialization',
|
|
223
|
+
);
|
|
224
|
+
assert(
|
|
225
|
+
manifests.every((event) => event.payload?.requestCaptureManifest?.privacy?.status === 'applied'),
|
|
226
|
+
'every request manifest must attest successful policy application',
|
|
227
|
+
);
|
|
228
|
+
assert(
|
|
229
|
+
manifests.every((event) =>
|
|
230
|
+
/^[a-f0-9]{64}$/.test(
|
|
231
|
+
event.payload?.requestCaptureManifest?.privacy?.policyFingerprint ?? '',
|
|
232
|
+
),
|
|
233
|
+
),
|
|
234
|
+
'every policy attestation must identify the exact normalized policy',
|
|
235
|
+
);
|
|
236
|
+
assert(
|
|
237
|
+
manifests.every((event) => !event.payload?.requestCaptureManifest?.captureFailures),
|
|
238
|
+
'successful policy materialization must not report capture failures',
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
const responseValues = backendRequests.flatMap((event) => event.payload?.requestValues || [])
|
|
242
|
+
.filter((entry) => entry.target === 'response.body');
|
|
243
|
+
assert.equal(responseValues.length, 14);
|
|
244
|
+
assert.equal(responseValues[0].value.data.length, 8);
|
|
245
|
+
assert.equal(responseValues[0].value.data[0].internalSecret, '[dropped]');
|
|
246
|
+
assert(
|
|
247
|
+
elapsedMs < 30000,
|
|
248
|
+
`large-policy session completion exceeded 30 seconds: ${elapsedMs}ms`,
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
console.log(`policy session completion testcontainers OK (${elapsedMs}ms)`);
|
|
252
|
+
} finally {
|
|
253
|
+
await __reproTestHooks.drainSdkBackgroundQueueForTest().catch(() => {});
|
|
254
|
+
await flushIngestQueue().catch(() => {});
|
|
255
|
+
global.fetch = originalFetch;
|
|
256
|
+
if (server) {
|
|
257
|
+
await new Promise((resolve) => server.close(resolve));
|
|
258
|
+
}
|
|
259
|
+
if (connection) {
|
|
260
|
+
await connection.close().catch(() => {});
|
|
261
|
+
}
|
|
262
|
+
if (mongo?.container) {
|
|
263
|
+
await mongo.container.stop().catch(() => {});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
main().catch((error) => {
|
|
269
|
+
console.error(error);
|
|
270
|
+
process.exit(1);
|
|
271
|
+
});
|