@durable-streams/server-conformance-tests 0.1.4 → 0.1.6
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.cjs +1 -1
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -1
- package/dist/{src-DK3GDgwo.cjs → src-ChUwq33M.cjs} +485 -20
- package/dist/{src-DcbQ_SIQ.js → src-DWkKYD4d.js} +485 -20
- package/dist/test-runner.cjs +1 -1
- package/dist/test-runner.js +1 -1
- package/package.json +2 -2
- package/src/index.ts +845 -92
|
@@ -61,7 +61,7 @@ async function fetchSSE(url, opts = {}) {
|
|
|
61
61
|
*/
|
|
62
62
|
function parseSSEEvents(sseText) {
|
|
63
63
|
const events = [];
|
|
64
|
-
const normalized = sseText.replace(/\r\n/g, `\n`);
|
|
64
|
+
const normalized = sseText.replace(/\r\n/g, `\n`).replace(/\r/g, `\n`);
|
|
65
65
|
const eventBlocks = normalized.split(`\n\n`).filter((block) => block.trim());
|
|
66
66
|
for (const block of eventBlocks) {
|
|
67
67
|
const lines = block.split(`\n`);
|
|
@@ -84,6 +84,7 @@ function parseSSEEvents(sseText) {
|
|
|
84
84
|
*/
|
|
85
85
|
function runConformanceTests(options) {
|
|
86
86
|
const getBaseUrl = () => options.baseUrl;
|
|
87
|
+
const getLongPollTestTimeoutMs = () => (options.longPollTimeoutMs ?? 2e4) + 1e3;
|
|
87
88
|
(0, vitest.describe)(`Basic Stream Operations`, () => {
|
|
88
89
|
(0, vitest.test)(`should create a stream`, async () => {
|
|
89
90
|
const streamPath = `/v1/stream/create-test-${Date.now()}`;
|
|
@@ -252,13 +253,14 @@ function runConformanceTests(options) {
|
|
|
252
253
|
const readPromise = (async () => {
|
|
253
254
|
const res = await stream.stream({ live: `long-poll` });
|
|
254
255
|
await new Promise((resolve) => {
|
|
255
|
-
const unsubscribe = res.subscribeBytes(
|
|
256
|
+
const unsubscribe = res.subscribeBytes((chunk) => {
|
|
256
257
|
if (chunk.data.length > 0) receivedData.push(new TextDecoder().decode(chunk.data));
|
|
257
258
|
if (receivedData.length >= 1) {
|
|
258
259
|
unsubscribe();
|
|
259
260
|
res.cancel();
|
|
260
261
|
resolve();
|
|
261
262
|
}
|
|
263
|
+
return Promise.resolve();
|
|
262
264
|
});
|
|
263
265
|
});
|
|
264
266
|
})();
|
|
@@ -266,7 +268,7 @@ function runConformanceTests(options) {
|
|
|
266
268
|
await stream.append(`new data`);
|
|
267
269
|
await readPromise;
|
|
268
270
|
(0, vitest.expect)(receivedData).toContain(`new data`);
|
|
269
|
-
},
|
|
271
|
+
}, getLongPollTestTimeoutMs());
|
|
270
272
|
(0, vitest.test)(`should return immediately if data already exists`, async () => {
|
|
271
273
|
const streamPath = `/v1/stream/longpoll-immediate-test-${Date.now()}`;
|
|
272
274
|
const stream = await __durable_streams_client.DurableStream.create({
|
|
@@ -301,7 +303,7 @@ function runConformanceTests(options) {
|
|
|
301
303
|
method: `PUT`,
|
|
302
304
|
headers: { "Content-Type": `text/plain` }
|
|
303
305
|
});
|
|
304
|
-
(0, vitest.expect)(
|
|
306
|
+
(0, vitest.expect)(secondResponse.status).toBe(200);
|
|
305
307
|
});
|
|
306
308
|
(0, vitest.test)(`should return 409 on PUT with different config`, async () => {
|
|
307
309
|
const streamPath = `/v1/stream/config-conflict-test-${Date.now()}`;
|
|
@@ -326,7 +328,7 @@ function runConformanceTests(options) {
|
|
|
326
328
|
headers: { "Content-Type": `text/plain` },
|
|
327
329
|
body: `hello world`
|
|
328
330
|
});
|
|
329
|
-
(0, vitest.expect)(
|
|
331
|
+
(0, vitest.expect)(response.status).toBe(204);
|
|
330
332
|
(0, vitest.expect)(response.headers.get(__durable_streams_client.STREAM_OFFSET_HEADER)).toBeDefined();
|
|
331
333
|
});
|
|
332
334
|
(0, vitest.test)(`should return 404 on POST to non-existent stream`, async () => {
|
|
@@ -509,7 +511,7 @@ function runConformanceTests(options) {
|
|
|
509
511
|
},
|
|
510
512
|
body: `second`
|
|
511
513
|
});
|
|
512
|
-
(0, vitest.expect)(
|
|
514
|
+
(0, vitest.expect)(response.status).toBe(204);
|
|
513
515
|
});
|
|
514
516
|
(0, vitest.test)(`should reject duplicate seq values`, async () => {
|
|
515
517
|
const streamPath = `/v1/stream/seq-duplicate-test-${Date.now()}`;
|
|
@@ -536,6 +538,140 @@ function runConformanceTests(options) {
|
|
|
536
538
|
(0, vitest.expect)(response.status).toBe(409);
|
|
537
539
|
});
|
|
538
540
|
});
|
|
541
|
+
(0, vitest.describe)(`Browser Security Headers`, () => {
|
|
542
|
+
(0, vitest.test)(`should include X-Content-Type-Options: nosniff on GET responses`, async () => {
|
|
543
|
+
const streamPath = `/v1/stream/security-get-nosniff-${Date.now()}`;
|
|
544
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
545
|
+
method: `PUT`,
|
|
546
|
+
headers: { "Content-Type": `text/plain` },
|
|
547
|
+
body: `test data`
|
|
548
|
+
});
|
|
549
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
|
|
550
|
+
(0, vitest.expect)(response.status).toBe(200);
|
|
551
|
+
(0, vitest.expect)(response.headers.get(`x-content-type-options`)).toBe(`nosniff`);
|
|
552
|
+
});
|
|
553
|
+
(0, vitest.test)(`should include X-Content-Type-Options: nosniff on PUT responses`, async () => {
|
|
554
|
+
const streamPath = `/v1/stream/security-put-nosniff-${Date.now()}`;
|
|
555
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
556
|
+
method: `PUT`,
|
|
557
|
+
headers: { "Content-Type": `text/plain` }
|
|
558
|
+
});
|
|
559
|
+
(0, vitest.expect)(response.status).toBe(201);
|
|
560
|
+
(0, vitest.expect)(response.headers.get(`x-content-type-options`)).toBe(`nosniff`);
|
|
561
|
+
});
|
|
562
|
+
(0, vitest.test)(`should include X-Content-Type-Options: nosniff on POST responses`, async () => {
|
|
563
|
+
const streamPath = `/v1/stream/security-post-nosniff-${Date.now()}`;
|
|
564
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
565
|
+
method: `PUT`,
|
|
566
|
+
headers: { "Content-Type": `text/plain` }
|
|
567
|
+
});
|
|
568
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
569
|
+
method: `POST`,
|
|
570
|
+
headers: { "Content-Type": `text/plain` },
|
|
571
|
+
body: `data`
|
|
572
|
+
});
|
|
573
|
+
(0, vitest.expect)([200, 204]).toContain(response.status);
|
|
574
|
+
(0, vitest.expect)(response.headers.get(`x-content-type-options`)).toBe(`nosniff`);
|
|
575
|
+
});
|
|
576
|
+
(0, vitest.test)(`should include X-Content-Type-Options: nosniff on HEAD responses`, async () => {
|
|
577
|
+
const streamPath = `/v1/stream/security-head-nosniff-${Date.now()}`;
|
|
578
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
579
|
+
method: `PUT`,
|
|
580
|
+
headers: { "Content-Type": `text/plain` }
|
|
581
|
+
});
|
|
582
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, { method: `HEAD` });
|
|
583
|
+
(0, vitest.expect)(response.status).toBe(200);
|
|
584
|
+
(0, vitest.expect)(response.headers.get(`x-content-type-options`)).toBe(`nosniff`);
|
|
585
|
+
});
|
|
586
|
+
(0, vitest.test)(`should include Cross-Origin-Resource-Policy header on GET responses`, async () => {
|
|
587
|
+
const streamPath = `/v1/stream/security-corp-get-${Date.now()}`;
|
|
588
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
589
|
+
method: `PUT`,
|
|
590
|
+
headers: { "Content-Type": `application/octet-stream` },
|
|
591
|
+
body: new Uint8Array([
|
|
592
|
+
1,
|
|
593
|
+
2,
|
|
594
|
+
3,
|
|
595
|
+
4
|
|
596
|
+
])
|
|
597
|
+
});
|
|
598
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
|
|
599
|
+
(0, vitest.expect)(response.status).toBe(200);
|
|
600
|
+
const corp = response.headers.get(`cross-origin-resource-policy`);
|
|
601
|
+
(0, vitest.expect)(corp).toBeDefined();
|
|
602
|
+
(0, vitest.expect)([
|
|
603
|
+
`cross-origin`,
|
|
604
|
+
`same-origin`,
|
|
605
|
+
`same-site`
|
|
606
|
+
]).toContain(corp);
|
|
607
|
+
});
|
|
608
|
+
(0, vitest.test)(`should include Cache-Control: no-store on HEAD responses`, async () => {
|
|
609
|
+
const streamPath = `/v1/stream/security-head-cache-${Date.now()}`;
|
|
610
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
611
|
+
method: `PUT`,
|
|
612
|
+
headers: { "Content-Type": `text/plain` }
|
|
613
|
+
});
|
|
614
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, { method: `HEAD` });
|
|
615
|
+
(0, vitest.expect)(response.status).toBe(200);
|
|
616
|
+
const cacheControl = response.headers.get(`cache-control`);
|
|
617
|
+
(0, vitest.expect)(cacheControl).toBeDefined();
|
|
618
|
+
(0, vitest.expect)(cacheControl).toContain(`no-store`);
|
|
619
|
+
});
|
|
620
|
+
(0, vitest.test)(`should include X-Content-Type-Options: nosniff on SSE responses`, async () => {
|
|
621
|
+
const streamPath = `/v1/stream/security-sse-nosniff-${Date.now()}`;
|
|
622
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
623
|
+
method: `PUT`,
|
|
624
|
+
headers: { "Content-Type": `application/json` },
|
|
625
|
+
body: JSON.stringify({ test: `data` })
|
|
626
|
+
});
|
|
627
|
+
const headResponse = await fetch(`${getBaseUrl()}${streamPath}`, { method: `HEAD` });
|
|
628
|
+
const offset = headResponse.headers.get(__durable_streams_client.STREAM_OFFSET_HEADER) ?? `-1`;
|
|
629
|
+
const controller = new AbortController();
|
|
630
|
+
const timeoutId = setTimeout(() => controller.abort(), 500);
|
|
631
|
+
try {
|
|
632
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}?offset=${offset}&live=sse`, {
|
|
633
|
+
method: `GET`,
|
|
634
|
+
signal: controller.signal
|
|
635
|
+
});
|
|
636
|
+
(0, vitest.expect)(response.status).toBe(200);
|
|
637
|
+
(0, vitest.expect)(response.headers.get(`x-content-type-options`)).toBe(`nosniff`);
|
|
638
|
+
} catch (e) {
|
|
639
|
+
if (!(e instanceof Error && e.name === `AbortError`)) throw e;
|
|
640
|
+
} finally {
|
|
641
|
+
clearTimeout(timeoutId);
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
(0, vitest.test)(`should include X-Content-Type-Options: nosniff on long-poll responses`, async () => {
|
|
645
|
+
const streamPath = `/v1/stream/security-longpoll-nosniff-${Date.now()}`;
|
|
646
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
647
|
+
method: `PUT`,
|
|
648
|
+
headers: { "Content-Type": `text/plain` },
|
|
649
|
+
body: `initial data`
|
|
650
|
+
});
|
|
651
|
+
const headResponse = await fetch(`${getBaseUrl()}${streamPath}`, { method: `HEAD` });
|
|
652
|
+
const offset = headResponse.headers.get(__durable_streams_client.STREAM_OFFSET_HEADER) ?? `-1`;
|
|
653
|
+
const controller = new AbortController();
|
|
654
|
+
const timeoutId = setTimeout(() => controller.abort(), 500);
|
|
655
|
+
try {
|
|
656
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}?offset=${offset}&live=long-poll`, {
|
|
657
|
+
method: `GET`,
|
|
658
|
+
signal: controller.signal
|
|
659
|
+
});
|
|
660
|
+
(0, vitest.expect)([200, 204]).toContain(response.status);
|
|
661
|
+
(0, vitest.expect)(response.headers.get(`x-content-type-options`)).toBe(`nosniff`);
|
|
662
|
+
} catch (e) {
|
|
663
|
+
if (!(e instanceof Error && e.name === `AbortError`)) throw e;
|
|
664
|
+
} finally {
|
|
665
|
+
clearTimeout(timeoutId);
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
(0, vitest.test)(`should include security headers on error responses`, async () => {
|
|
669
|
+
const streamPath = `/v1/stream/security-error-headers-${Date.now()}`;
|
|
670
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
|
|
671
|
+
(0, vitest.expect)(response.status).toBe(404);
|
|
672
|
+
(0, vitest.expect)(response.headers.get(`x-content-type-options`)).toBe(`nosniff`);
|
|
673
|
+
});
|
|
674
|
+
});
|
|
539
675
|
(0, vitest.describe)(`TTL and Expiry Validation`, () => {
|
|
540
676
|
(0, vitest.test)(`should reject both TTL and Expires-At (400)`, async () => {
|
|
541
677
|
const streamPath = `/v1/stream/ttl-expires-conflict-test-${Date.now()}`;
|
|
@@ -606,7 +742,7 @@ function runConformanceTests(options) {
|
|
|
606
742
|
headers: { "Content-Type": `TEXT/PLAIN` },
|
|
607
743
|
body: `test`
|
|
608
744
|
});
|
|
609
|
-
(0, vitest.expect)(
|
|
745
|
+
(0, vitest.expect)(response.status).toBe(204);
|
|
610
746
|
});
|
|
611
747
|
(0, vitest.test)(`should allow idempotent create with different case content-type`, async () => {
|
|
612
748
|
const streamPath = `/v1/stream/case-idempotent-test-${Date.now()}`;
|
|
@@ -619,7 +755,7 @@ function runConformanceTests(options) {
|
|
|
619
755
|
method: `PUT`,
|
|
620
756
|
headers: { "Content-Type": `APPLICATION/JSON` }
|
|
621
757
|
});
|
|
622
|
-
(0, vitest.expect)(
|
|
758
|
+
(0, vitest.expect)(response2.status).toBe(200);
|
|
623
759
|
});
|
|
624
760
|
(0, vitest.test)(`should accept headers with different casing`, async () => {
|
|
625
761
|
const streamPath = `/v1/stream/case-header-test-${Date.now()}`;
|
|
@@ -635,7 +771,7 @@ function runConformanceTests(options) {
|
|
|
635
771
|
},
|
|
636
772
|
body: `test`
|
|
637
773
|
});
|
|
638
|
-
(0, vitest.expect)(
|
|
774
|
+
(0, vitest.expect)(response.status).toBe(204);
|
|
639
775
|
});
|
|
640
776
|
});
|
|
641
777
|
(0, vitest.describe)(`Content-Type Validation`, () => {
|
|
@@ -663,7 +799,7 @@ function runConformanceTests(options) {
|
|
|
663
799
|
headers: { "Content-Type": `application/json` },
|
|
664
800
|
body: `{"test": true}`
|
|
665
801
|
});
|
|
666
|
-
(0, vitest.expect)(
|
|
802
|
+
(0, vitest.expect)(response.status).toBe(204);
|
|
667
803
|
});
|
|
668
804
|
(0, vitest.test)(`should return stream content-type on GET`, async () => {
|
|
669
805
|
const streamPath = `/v1/stream/content-type-get-test-${Date.now()}`;
|
|
@@ -955,7 +1091,8 @@ function runConformanceTests(options) {
|
|
|
955
1091
|
(0, vitest.expect)(response.status).toBe(201);
|
|
956
1092
|
const location = response.headers.get(`location`);
|
|
957
1093
|
(0, vitest.expect)(location).toBeDefined();
|
|
958
|
-
(0, vitest.expect)(location).toBe(
|
|
1094
|
+
(0, vitest.expect)(location.endsWith(streamPath)).toBe(true);
|
|
1095
|
+
(0, vitest.expect)(() => new URL(location)).not.toThrow();
|
|
959
1096
|
});
|
|
960
1097
|
(0, vitest.test)(`should reject missing Content-Type on POST`, async () => {
|
|
961
1098
|
const streamPath = `/v1/stream/missing-ct-post-test-${Date.now()}`;
|
|
@@ -1059,7 +1196,7 @@ function runConformanceTests(options) {
|
|
|
1059
1196
|
clearTimeout(timeoutId);
|
|
1060
1197
|
if (e instanceof Error && e.name !== `AbortError`) throw e;
|
|
1061
1198
|
}
|
|
1062
|
-
},
|
|
1199
|
+
}, getLongPollTestTimeoutMs());
|
|
1063
1200
|
});
|
|
1064
1201
|
(0, vitest.describe)(`TTL and Expiry Edge Cases`, () => {
|
|
1065
1202
|
(0, vitest.test)(`should reject TTL with leading zeros`, async () => {
|
|
@@ -1159,7 +1296,7 @@ function runConformanceTests(options) {
|
|
|
1159
1296
|
"Stream-TTL": `3600`
|
|
1160
1297
|
}
|
|
1161
1298
|
});
|
|
1162
|
-
(0, vitest.expect)(
|
|
1299
|
+
(0, vitest.expect)(response2.status).toBe(200);
|
|
1163
1300
|
});
|
|
1164
1301
|
(0, vitest.test)(`should reject idempotent PUT with different TTL`, async () => {
|
|
1165
1302
|
const streamPath = `/v1/stream/ttl-conflict-test-${Date.now()}`;
|
|
@@ -1263,7 +1400,7 @@ function runConformanceTests(options) {
|
|
|
1263
1400
|
headers: { "Content-Type": `text/plain` },
|
|
1264
1401
|
body: `appended data`
|
|
1265
1402
|
});
|
|
1266
|
-
(0, vitest.expect)(
|
|
1403
|
+
(0, vitest.expect)(postBefore.status).toBe(204);
|
|
1267
1404
|
await sleep(1500);
|
|
1268
1405
|
const postAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1269
1406
|
method: `POST`,
|
|
@@ -1323,7 +1460,7 @@ function runConformanceTests(options) {
|
|
|
1323
1460
|
headers: { "Content-Type": `text/plain` },
|
|
1324
1461
|
body: `appended data`
|
|
1325
1462
|
});
|
|
1326
|
-
(0, vitest.expect)(
|
|
1463
|
+
(0, vitest.expect)(postBefore.status).toBe(204);
|
|
1327
1464
|
await sleep(1500);
|
|
1328
1465
|
const postAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1329
1466
|
method: `POST`,
|
|
@@ -1746,6 +1883,88 @@ function runConformanceTests(options) {
|
|
|
1746
1883
|
(0, vitest.expect)(received).toContain(`data: line2`);
|
|
1747
1884
|
(0, vitest.expect)(received).toContain(`data: line3`);
|
|
1748
1885
|
});
|
|
1886
|
+
(0, vitest.test)(`should prevent CRLF injection in payloads - embedded event boundaries become literal data`, async () => {
|
|
1887
|
+
const streamPath = `/v1/stream/sse-crlf-injection-test-${Date.now()}`;
|
|
1888
|
+
const maliciousPayload = `safe content\r\n\r\nevent: control\r\ndata: {"injected":true}\r\n\r\nmore safe content`;
|
|
1889
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1890
|
+
method: `PUT`,
|
|
1891
|
+
headers: { "Content-Type": `text/plain` },
|
|
1892
|
+
body: maliciousPayload
|
|
1893
|
+
});
|
|
1894
|
+
const { response, received } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=-1&live=sse`, { untilContent: `event: control` });
|
|
1895
|
+
(0, vitest.expect)(response.status).toBe(200);
|
|
1896
|
+
const events = parseSSEEvents(received);
|
|
1897
|
+
const dataEvents = events.filter((e) => e.type === `data`);
|
|
1898
|
+
const controlEvents = events.filter((e) => e.type === `control`);
|
|
1899
|
+
(0, vitest.expect)(dataEvents.length).toBe(1);
|
|
1900
|
+
(0, vitest.expect)(controlEvents.length).toBe(1);
|
|
1901
|
+
const dataContent = dataEvents[0].data;
|
|
1902
|
+
(0, vitest.expect)(dataContent).toContain(`event: control`);
|
|
1903
|
+
(0, vitest.expect)(dataContent).toContain(`data: {"injected":true}`);
|
|
1904
|
+
const controlContent = JSON.parse(controlEvents[0].data);
|
|
1905
|
+
(0, vitest.expect)(controlContent.injected).toBeUndefined();
|
|
1906
|
+
(0, vitest.expect)(controlContent.streamNextOffset).toBeDefined();
|
|
1907
|
+
});
|
|
1908
|
+
(0, vitest.test)(`should prevent CRLF injection - LF-only attack vectors`, async () => {
|
|
1909
|
+
const streamPath = `/v1/stream/sse-lf-injection-test-${Date.now()}`;
|
|
1910
|
+
const maliciousPayload = `start\n\nevent: data\ndata: fake-event\n\nend`;
|
|
1911
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1912
|
+
method: `PUT`,
|
|
1913
|
+
headers: { "Content-Type": `text/plain` },
|
|
1914
|
+
body: maliciousPayload
|
|
1915
|
+
});
|
|
1916
|
+
const { response, received } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=-1&live=sse`, { untilContent: `event: control` });
|
|
1917
|
+
(0, vitest.expect)(response.status).toBe(200);
|
|
1918
|
+
const events = parseSSEEvents(received);
|
|
1919
|
+
const dataEvents = events.filter((e) => e.type === `data`);
|
|
1920
|
+
(0, vitest.expect)(dataEvents.length).toBe(1);
|
|
1921
|
+
const dataContent = dataEvents[0].data;
|
|
1922
|
+
(0, vitest.expect)(dataContent).toContain(`event: data`);
|
|
1923
|
+
(0, vitest.expect)(dataContent).toContain(`data: fake-event`);
|
|
1924
|
+
});
|
|
1925
|
+
(0, vitest.test)(`should prevent CRLF injection - carriage return only attack vectors`, async () => {
|
|
1926
|
+
const streamPath = `/v1/stream/sse-cr-injection-test-${Date.now()}`;
|
|
1927
|
+
const maliciousPayload = `start\r\revent: control\rdata: {"cr_injected":true}\r\rend`;
|
|
1928
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1929
|
+
method: `PUT`,
|
|
1930
|
+
headers: { "Content-Type": `text/plain` },
|
|
1931
|
+
body: maliciousPayload
|
|
1932
|
+
});
|
|
1933
|
+
const { response, received } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=-1&live=sse`, { untilContent: `event: control` });
|
|
1934
|
+
(0, vitest.expect)(response.status).toBe(200);
|
|
1935
|
+
const events = parseSSEEvents(received);
|
|
1936
|
+
const controlEvents = events.filter((e) => e.type === `control`);
|
|
1937
|
+
(0, vitest.expect)(controlEvents.length).toBe(1);
|
|
1938
|
+
const controlContent = JSON.parse(controlEvents[0].data);
|
|
1939
|
+
(0, vitest.expect)(controlContent.cr_injected).toBeUndefined();
|
|
1940
|
+
(0, vitest.expect)(controlContent.streamNextOffset).toBeDefined();
|
|
1941
|
+
});
|
|
1942
|
+
(0, vitest.test)(`should handle JSON payloads with embedded newlines safely`, async () => {
|
|
1943
|
+
const streamPath = `/v1/stream/sse-json-newline-test-${Date.now()}`;
|
|
1944
|
+
const jsonPayload = JSON.stringify({
|
|
1945
|
+
message: `line1\nline2\nline3`,
|
|
1946
|
+
attack: `try\r\n\r\nevent: control\r\ndata: {"bad":true}`
|
|
1947
|
+
});
|
|
1948
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1949
|
+
method: `PUT`,
|
|
1950
|
+
headers: { "Content-Type": `application/json` },
|
|
1951
|
+
body: jsonPayload
|
|
1952
|
+
});
|
|
1953
|
+
const { response, received } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=-1&live=sse`, { untilContent: `event: control` });
|
|
1954
|
+
(0, vitest.expect)(response.status).toBe(200);
|
|
1955
|
+
const events = parseSSEEvents(received);
|
|
1956
|
+
const dataEvents = events.filter((e) => e.type === `data`);
|
|
1957
|
+
const controlEvents = events.filter((e) => e.type === `control`);
|
|
1958
|
+
(0, vitest.expect)(dataEvents.length).toBe(1);
|
|
1959
|
+
(0, vitest.expect)(controlEvents.length).toBe(1);
|
|
1960
|
+
const parsedData = JSON.parse(dataEvents[0].data);
|
|
1961
|
+
(0, vitest.expect)(Array.isArray(parsedData)).toBe(true);
|
|
1962
|
+
(0, vitest.expect)(parsedData[0].message).toBe(`line1\nline2\nline3`);
|
|
1963
|
+
(0, vitest.expect)(parsedData[0].attack).toContain(`event: control`);
|
|
1964
|
+
const controlContent = JSON.parse(controlEvents[0].data);
|
|
1965
|
+
(0, vitest.expect)(controlContent.bad).toBeUndefined();
|
|
1966
|
+
(0, vitest.expect)(controlContent.streamNextOffset).toBeDefined();
|
|
1967
|
+
});
|
|
1749
1968
|
(0, vitest.test)(`should generate unique, monotonically increasing offsets in SSE mode`, async () => {
|
|
1750
1969
|
const streamPath = `/v1/stream/sse-monotonic-offset-test-${Date.now()}`;
|
|
1751
1970
|
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
@@ -2131,7 +2350,7 @@ function runConformanceTests(options) {
|
|
|
2131
2350
|
headers: { "Content-Type": `application/octet-stream` },
|
|
2132
2351
|
body: chunk
|
|
2133
2352
|
});
|
|
2134
|
-
(0, vitest.expect)(
|
|
2353
|
+
(0, vitest.expect)(response.status).toBe(204);
|
|
2135
2354
|
}
|
|
2136
2355
|
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
2137
2356
|
const expected = new Uint8Array(totalLength);
|
|
@@ -2234,7 +2453,7 @@ function runConformanceTests(options) {
|
|
|
2234
2453
|
headers: { "Content-Type": `application/octet-stream` },
|
|
2235
2454
|
body: op.data
|
|
2236
2455
|
});
|
|
2237
|
-
(0, vitest.expect)(
|
|
2456
|
+
(0, vitest.expect)(response.status).toBe(204);
|
|
2238
2457
|
appendedData.push(...Array.from(op.data));
|
|
2239
2458
|
const offset = response.headers.get(__durable_streams_client.STREAM_OFFSET_HEADER);
|
|
2240
2459
|
if (offset) savedOffsets.push(offset);
|
|
@@ -2317,7 +2536,7 @@ function runConformanceTests(options) {
|
|
|
2317
2536
|
headers: { "Content-Type": `application/octet-stream` },
|
|
2318
2537
|
body: data
|
|
2319
2538
|
});
|
|
2320
|
-
(0, vitest.expect)(
|
|
2539
|
+
(0, vitest.expect)(appendResponse.status).toBe(204);
|
|
2321
2540
|
const readResponse = await fetch(`${getBaseUrl()}${streamPath}`);
|
|
2322
2541
|
(0, vitest.expect)(readResponse.status).toBe(200);
|
|
2323
2542
|
const buffer = await readResponse.arrayBuffer();
|
|
@@ -2429,7 +2648,7 @@ function runConformanceTests(options) {
|
|
|
2429
2648
|
},
|
|
2430
2649
|
body: `data-${seq}`
|
|
2431
2650
|
});
|
|
2432
|
-
(0, vitest.expect)(
|
|
2651
|
+
(0, vitest.expect)(response.status).toBe(204);
|
|
2433
2652
|
}
|
|
2434
2653
|
return true;
|
|
2435
2654
|
}
|
|
@@ -2453,7 +2672,7 @@ function runConformanceTests(options) {
|
|
|
2453
2672
|
},
|
|
2454
2673
|
body: `first`
|
|
2455
2674
|
});
|
|
2456
|
-
(0, vitest.expect)(
|
|
2675
|
+
(0, vitest.expect)(response1.status).toBe(204);
|
|
2457
2676
|
const response2 = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2458
2677
|
method: `POST`,
|
|
2459
2678
|
headers: {
|
|
@@ -2468,6 +2687,252 @@ function runConformanceTests(options) {
|
|
|
2468
2687
|
), { numRuns: 25 });
|
|
2469
2688
|
});
|
|
2470
2689
|
});
|
|
2690
|
+
(0, vitest.describe)(`Concurrent Writer Stress Tests`, () => {
|
|
2691
|
+
(0, vitest.test)(`concurrent writers with sequence numbers - server handles gracefully`, async () => {
|
|
2692
|
+
const streamPath = `/v1/stream/concurrent-seq-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
2693
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2694
|
+
method: `PUT`,
|
|
2695
|
+
headers: { "Content-Type": `text/plain` }
|
|
2696
|
+
});
|
|
2697
|
+
const numWriters = 5;
|
|
2698
|
+
const seqValue = `seq-001`;
|
|
2699
|
+
const writePromises = Array.from({ length: numWriters }, (_, i) => fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2700
|
+
method: `POST`,
|
|
2701
|
+
headers: {
|
|
2702
|
+
"Content-Type": `text/plain`,
|
|
2703
|
+
[__durable_streams_client.STREAM_SEQ_HEADER]: seqValue
|
|
2704
|
+
},
|
|
2705
|
+
body: `writer-${i}`
|
|
2706
|
+
}));
|
|
2707
|
+
const responses = await Promise.all(writePromises);
|
|
2708
|
+
const statuses = responses.map((r) => r.status);
|
|
2709
|
+
for (const status of statuses) (0, vitest.expect)([
|
|
2710
|
+
200,
|
|
2711
|
+
204,
|
|
2712
|
+
409
|
|
2713
|
+
]).toContain(status);
|
|
2714
|
+
const successes = statuses.filter((s) => s === 200 || s === 204);
|
|
2715
|
+
(0, vitest.expect)(successes.length).toBeGreaterThanOrEqual(1);
|
|
2716
|
+
const readResponse = await fetch(`${getBaseUrl()}${streamPath}`);
|
|
2717
|
+
const content = await readResponse.text();
|
|
2718
|
+
const matchingWriters = Array.from({ length: numWriters }, (_, i) => content.includes(`writer-${i}`)).filter(Boolean);
|
|
2719
|
+
(0, vitest.expect)(matchingWriters.length).toBeGreaterThanOrEqual(1);
|
|
2720
|
+
});
|
|
2721
|
+
(0, vitest.test)(`concurrent writers racing with incrementing seq values`, async () => {
|
|
2722
|
+
await fast_check.assert(fast_check.asyncProperty(fast_check.integer({
|
|
2723
|
+
min: 3,
|
|
2724
|
+
max: 8
|
|
2725
|
+
}), async (numWriters) => {
|
|
2726
|
+
const streamPath = `/v1/stream/concurrent-race-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
2727
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2728
|
+
method: `PUT`,
|
|
2729
|
+
headers: { "Content-Type": `text/plain` }
|
|
2730
|
+
});
|
|
2731
|
+
const writePromises = Array.from({ length: numWriters }, (_, i) => fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2732
|
+
method: `POST`,
|
|
2733
|
+
headers: {
|
|
2734
|
+
"Content-Type": `text/plain`,
|
|
2735
|
+
[__durable_streams_client.STREAM_SEQ_HEADER]: String(i).padStart(4, `0`)
|
|
2736
|
+
},
|
|
2737
|
+
body: `data-${i}`
|
|
2738
|
+
}));
|
|
2739
|
+
const responses = await Promise.all(writePromises);
|
|
2740
|
+
const successIndices = [];
|
|
2741
|
+
for (let i = 0; i < responses.length; i++) {
|
|
2742
|
+
(0, vitest.expect)([
|
|
2743
|
+
200,
|
|
2744
|
+
204,
|
|
2745
|
+
409
|
|
2746
|
+
]).toContain(responses[i].status);
|
|
2747
|
+
if (responses[i].status === 200 || responses[i].status === 204) successIndices.push(i);
|
|
2748
|
+
}
|
|
2749
|
+
(0, vitest.expect)(successIndices.length).toBeGreaterThanOrEqual(1);
|
|
2750
|
+
const readResponse = await fetch(`${getBaseUrl()}${streamPath}`);
|
|
2751
|
+
const content = await readResponse.text();
|
|
2752
|
+
for (const i of successIndices) (0, vitest.expect)(content).toContain(`data-${i}`);
|
|
2753
|
+
return true;
|
|
2754
|
+
}), { numRuns: 10 });
|
|
2755
|
+
});
|
|
2756
|
+
(0, vitest.test)(`concurrent appends without seq - all data is persisted`, async () => {
|
|
2757
|
+
const streamPath = `/v1/stream/concurrent-no-seq-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
2758
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2759
|
+
method: `PUT`,
|
|
2760
|
+
headers: { "Content-Type": `text/plain` }
|
|
2761
|
+
});
|
|
2762
|
+
const numWriters = 10;
|
|
2763
|
+
const writePromises = Array.from({ length: numWriters }, (_, i) => fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2764
|
+
method: `POST`,
|
|
2765
|
+
headers: { "Content-Type": `text/plain` },
|
|
2766
|
+
body: `concurrent-${i}`
|
|
2767
|
+
}));
|
|
2768
|
+
const responses = await Promise.all(writePromises);
|
|
2769
|
+
for (const response of responses) (0, vitest.expect)([200, 204]).toContain(response.status);
|
|
2770
|
+
const offsets = responses.map((r) => r.headers.get(__durable_streams_client.STREAM_OFFSET_HEADER));
|
|
2771
|
+
for (const offset of offsets) (0, vitest.expect)(offset).not.toBeNull();
|
|
2772
|
+
const readResponse = await fetch(`${getBaseUrl()}${streamPath}`);
|
|
2773
|
+
const content = await readResponse.text();
|
|
2774
|
+
for (let i = 0; i < numWriters; i++) (0, vitest.expect)(content).toContain(`concurrent-${i}`);
|
|
2775
|
+
});
|
|
2776
|
+
(0, vitest.test)(`mixed readers and writers - readers see consistent state`, async () => {
|
|
2777
|
+
const streamPath = `/v1/stream/concurrent-rw-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
2778
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2779
|
+
method: `PUT`,
|
|
2780
|
+
headers: { "Content-Type": `text/plain` }
|
|
2781
|
+
});
|
|
2782
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2783
|
+
method: `POST`,
|
|
2784
|
+
headers: { "Content-Type": `text/plain` },
|
|
2785
|
+
body: `initial`
|
|
2786
|
+
});
|
|
2787
|
+
const numOps = 20;
|
|
2788
|
+
const operations = Array.from({ length: numOps }, (_, i) => {
|
|
2789
|
+
if (i % 2 === 0) return fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2790
|
+
method: `POST`,
|
|
2791
|
+
headers: { "Content-Type": `text/plain` },
|
|
2792
|
+
body: `write-${i}`
|
|
2793
|
+
});
|
|
2794
|
+
else return fetch(`${getBaseUrl()}${streamPath}`);
|
|
2795
|
+
});
|
|
2796
|
+
const responses = await Promise.all(operations);
|
|
2797
|
+
responses.forEach((response, i) => {
|
|
2798
|
+
const expectedStatus = i % 2 === 0 ? 204 : 200;
|
|
2799
|
+
(0, vitest.expect)(response.status).toBe(expectedStatus);
|
|
2800
|
+
});
|
|
2801
|
+
const finalRead = await fetch(`${getBaseUrl()}${streamPath}`);
|
|
2802
|
+
const content = await finalRead.text();
|
|
2803
|
+
(0, vitest.expect)(content).toContain(`initial`);
|
|
2804
|
+
for (let i = 0; i < numOps; i += 2) (0, vitest.expect)(content).toContain(`write-${i}`);
|
|
2805
|
+
});
|
|
2806
|
+
});
|
|
2807
|
+
(0, vitest.describe)(`State Hash Verification`, () => {
|
|
2808
|
+
/**
|
|
2809
|
+
* Simple hash function for content verification.
|
|
2810
|
+
* Uses FNV-1a algorithm for deterministic hashing.
|
|
2811
|
+
*/
|
|
2812
|
+
function hashContent(data) {
|
|
2813
|
+
let hash = 2166136261;
|
|
2814
|
+
for (const byte of data) {
|
|
2815
|
+
hash ^= byte;
|
|
2816
|
+
hash = Math.imul(hash, 16777619);
|
|
2817
|
+
hash = hash >>> 0;
|
|
2818
|
+
}
|
|
2819
|
+
return hash.toString(16).padStart(8, `0`);
|
|
2820
|
+
}
|
|
2821
|
+
(0, vitest.test)(`replay produces identical content hash`, async () => {
|
|
2822
|
+
await fast_check.assert(fast_check.asyncProperty(
|
|
2823
|
+
// Generate a sequence of appends
|
|
2824
|
+
fast_check.array(fast_check.uint8Array({
|
|
2825
|
+
minLength: 1,
|
|
2826
|
+
maxLength: 100
|
|
2827
|
+
}), {
|
|
2828
|
+
minLength: 1,
|
|
2829
|
+
maxLength: 10
|
|
2830
|
+
}),
|
|
2831
|
+
async (chunks) => {
|
|
2832
|
+
const streamPath1 = `/v1/stream/hash-verify-1-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
2833
|
+
await fetch(`${getBaseUrl()}${streamPath1}`, {
|
|
2834
|
+
method: `PUT`,
|
|
2835
|
+
headers: { "Content-Type": `application/octet-stream` }
|
|
2836
|
+
});
|
|
2837
|
+
for (const chunk of chunks) await fetch(`${getBaseUrl()}${streamPath1}`, {
|
|
2838
|
+
method: `POST`,
|
|
2839
|
+
headers: { "Content-Type": `application/octet-stream` },
|
|
2840
|
+
body: chunk
|
|
2841
|
+
});
|
|
2842
|
+
const response1 = await fetch(`${getBaseUrl()}${streamPath1}`);
|
|
2843
|
+
const data1 = new Uint8Array(await response1.arrayBuffer());
|
|
2844
|
+
const hash1 = hashContent(data1);
|
|
2845
|
+
const streamPath2 = `/v1/stream/hash-verify-2-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
2846
|
+
await fetch(`${getBaseUrl()}${streamPath2}`, {
|
|
2847
|
+
method: `PUT`,
|
|
2848
|
+
headers: { "Content-Type": `application/octet-stream` }
|
|
2849
|
+
});
|
|
2850
|
+
for (const chunk of chunks) await fetch(`${getBaseUrl()}${streamPath2}`, {
|
|
2851
|
+
method: `POST`,
|
|
2852
|
+
headers: { "Content-Type": `application/octet-stream` },
|
|
2853
|
+
body: chunk
|
|
2854
|
+
});
|
|
2855
|
+
const response2 = await fetch(`${getBaseUrl()}${streamPath2}`);
|
|
2856
|
+
const data2 = new Uint8Array(await response2.arrayBuffer());
|
|
2857
|
+
const hash2 = hashContent(data2);
|
|
2858
|
+
(0, vitest.expect)(hash1).toBe(hash2);
|
|
2859
|
+
(0, vitest.expect)(data1.length).toBe(data2.length);
|
|
2860
|
+
return true;
|
|
2861
|
+
}
|
|
2862
|
+
), { numRuns: 15 });
|
|
2863
|
+
});
|
|
2864
|
+
(0, vitest.test)(`content hash changes with each append`, async () => {
|
|
2865
|
+
const streamPath = `/v1/stream/hash-changes-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
2866
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2867
|
+
method: `PUT`,
|
|
2868
|
+
headers: { "Content-Type": `application/octet-stream` }
|
|
2869
|
+
});
|
|
2870
|
+
const hashes = [];
|
|
2871
|
+
for (let i = 0; i < 5; i++) {
|
|
2872
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2873
|
+
method: `POST`,
|
|
2874
|
+
headers: { "Content-Type": `application/octet-stream` },
|
|
2875
|
+
body: new Uint8Array([
|
|
2876
|
+
i,
|
|
2877
|
+
i + 1,
|
|
2878
|
+
i + 2
|
|
2879
|
+
])
|
|
2880
|
+
});
|
|
2881
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`);
|
|
2882
|
+
const data = new Uint8Array(await response.arrayBuffer());
|
|
2883
|
+
hashes.push(hashContent(data));
|
|
2884
|
+
}
|
|
2885
|
+
const uniqueHashes = new Set(hashes);
|
|
2886
|
+
(0, vitest.expect)(uniqueHashes.size).toBe(5);
|
|
2887
|
+
});
|
|
2888
|
+
(0, vitest.test)(`empty stream has consistent hash`, async () => {
|
|
2889
|
+
const streamPath1 = `/v1/stream/empty-hash-1-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
2890
|
+
const streamPath2 = `/v1/stream/empty-hash-2-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
2891
|
+
await fetch(`${getBaseUrl()}${streamPath1}`, {
|
|
2892
|
+
method: `PUT`,
|
|
2893
|
+
headers: { "Content-Type": `application/octet-stream` }
|
|
2894
|
+
});
|
|
2895
|
+
await fetch(`${getBaseUrl()}${streamPath2}`, {
|
|
2896
|
+
method: `PUT`,
|
|
2897
|
+
headers: { "Content-Type": `application/octet-stream` }
|
|
2898
|
+
});
|
|
2899
|
+
const response1 = await fetch(`${getBaseUrl()}${streamPath1}`);
|
|
2900
|
+
const response2 = await fetch(`${getBaseUrl()}${streamPath2}`);
|
|
2901
|
+
const data1 = new Uint8Array(await response1.arrayBuffer());
|
|
2902
|
+
const data2 = new Uint8Array(await response2.arrayBuffer());
|
|
2903
|
+
(0, vitest.expect)(data1.length).toBe(0);
|
|
2904
|
+
(0, vitest.expect)(data2.length).toBe(0);
|
|
2905
|
+
(0, vitest.expect)(hashContent(data1)).toBe(hashContent(data2));
|
|
2906
|
+
});
|
|
2907
|
+
(0, vitest.test)(`deterministic ordering - same data in same order produces same hash`, async () => {
|
|
2908
|
+
await fast_check.assert(fast_check.asyncProperty(fast_check.array(fast_check.uint8Array({
|
|
2909
|
+
minLength: 1,
|
|
2910
|
+
maxLength: 50
|
|
2911
|
+
}), {
|
|
2912
|
+
minLength: 2,
|
|
2913
|
+
maxLength: 5
|
|
2914
|
+
}), async (chunks) => {
|
|
2915
|
+
const hashes = [];
|
|
2916
|
+
for (let run = 0; run < 2; run++) {
|
|
2917
|
+
const streamPath = `/v1/stream/order-hash-${run}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
2918
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2919
|
+
method: `PUT`,
|
|
2920
|
+
headers: { "Content-Type": `application/octet-stream` }
|
|
2921
|
+
});
|
|
2922
|
+
for (const chunk of chunks) await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2923
|
+
method: `POST`,
|
|
2924
|
+
headers: { "Content-Type": `application/octet-stream` },
|
|
2925
|
+
body: chunk
|
|
2926
|
+
});
|
|
2927
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`);
|
|
2928
|
+
const data = new Uint8Array(await response.arrayBuffer());
|
|
2929
|
+
hashes.push(hashContent(data));
|
|
2930
|
+
}
|
|
2931
|
+
(0, vitest.expect)(hashes[0]).toBe(hashes[1]);
|
|
2932
|
+
return true;
|
|
2933
|
+
}), { numRuns: 10 });
|
|
2934
|
+
});
|
|
2935
|
+
});
|
|
2471
2936
|
});
|
|
2472
2937
|
}
|
|
2473
2938
|
|