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