@durable-streams/server-conformance-tests 0.1.5 → 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.js +1 -1
- package/dist/{src-BJQjRfnf.cjs → src-ChUwq33M.cjs} +480 -17
- package/dist/{src-BtF2jQ-Q.js → src-DWkKYD4d.js} +480 -17
- package/dist/test-runner.cjs +1 -1
- package/dist/test-runner.js +1 -1
- package/package.json +2 -2
- package/src/index.ts +757 -17
|
@@ -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`);
|
|
@@ -301,7 +301,7 @@ function runConformanceTests(options) {
|
|
|
301
301
|
method: `PUT`,
|
|
302
302
|
headers: { "Content-Type": `text/plain` }
|
|
303
303
|
});
|
|
304
|
-
expect(
|
|
304
|
+
expect(secondResponse.status).toBe(200);
|
|
305
305
|
});
|
|
306
306
|
test(`should return 409 on PUT with different config`, async () => {
|
|
307
307
|
const streamPath = `/v1/stream/config-conflict-test-${Date.now()}`;
|
|
@@ -326,7 +326,7 @@ function runConformanceTests(options) {
|
|
|
326
326
|
headers: { "Content-Type": `text/plain` },
|
|
327
327
|
body: `hello world`
|
|
328
328
|
});
|
|
329
|
-
expect(
|
|
329
|
+
expect(response.status).toBe(204);
|
|
330
330
|
expect(response.headers.get(STREAM_OFFSET_HEADER)).toBeDefined();
|
|
331
331
|
});
|
|
332
332
|
test(`should return 404 on POST to non-existent stream`, async () => {
|
|
@@ -509,7 +509,7 @@ function runConformanceTests(options) {
|
|
|
509
509
|
},
|
|
510
510
|
body: `second`
|
|
511
511
|
});
|
|
512
|
-
expect(
|
|
512
|
+
expect(response.status).toBe(204);
|
|
513
513
|
});
|
|
514
514
|
test(`should reject duplicate seq values`, async () => {
|
|
515
515
|
const streamPath = `/v1/stream/seq-duplicate-test-${Date.now()}`;
|
|
@@ -536,6 +536,140 @@ function runConformanceTests(options) {
|
|
|
536
536
|
expect(response.status).toBe(409);
|
|
537
537
|
});
|
|
538
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
|
+
});
|
|
539
673
|
describe(`TTL and Expiry Validation`, () => {
|
|
540
674
|
test(`should reject both TTL and Expires-At (400)`, async () => {
|
|
541
675
|
const streamPath = `/v1/stream/ttl-expires-conflict-test-${Date.now()}`;
|
|
@@ -606,7 +740,7 @@ function runConformanceTests(options) {
|
|
|
606
740
|
headers: { "Content-Type": `TEXT/PLAIN` },
|
|
607
741
|
body: `test`
|
|
608
742
|
});
|
|
609
|
-
expect(
|
|
743
|
+
expect(response.status).toBe(204);
|
|
610
744
|
});
|
|
611
745
|
test(`should allow idempotent create with different case content-type`, async () => {
|
|
612
746
|
const streamPath = `/v1/stream/case-idempotent-test-${Date.now()}`;
|
|
@@ -619,7 +753,7 @@ function runConformanceTests(options) {
|
|
|
619
753
|
method: `PUT`,
|
|
620
754
|
headers: { "Content-Type": `APPLICATION/JSON` }
|
|
621
755
|
});
|
|
622
|
-
expect(
|
|
756
|
+
expect(response2.status).toBe(200);
|
|
623
757
|
});
|
|
624
758
|
test(`should accept headers with different casing`, async () => {
|
|
625
759
|
const streamPath = `/v1/stream/case-header-test-${Date.now()}`;
|
|
@@ -635,7 +769,7 @@ function runConformanceTests(options) {
|
|
|
635
769
|
},
|
|
636
770
|
body: `test`
|
|
637
771
|
});
|
|
638
|
-
expect(
|
|
772
|
+
expect(response.status).toBe(204);
|
|
639
773
|
});
|
|
640
774
|
});
|
|
641
775
|
describe(`Content-Type Validation`, () => {
|
|
@@ -663,7 +797,7 @@ function runConformanceTests(options) {
|
|
|
663
797
|
headers: { "Content-Type": `application/json` },
|
|
664
798
|
body: `{"test": true}`
|
|
665
799
|
});
|
|
666
|
-
expect(
|
|
800
|
+
expect(response.status).toBe(204);
|
|
667
801
|
});
|
|
668
802
|
test(`should return stream content-type on GET`, async () => {
|
|
669
803
|
const streamPath = `/v1/stream/content-type-get-test-${Date.now()}`;
|
|
@@ -955,7 +1089,8 @@ function runConformanceTests(options) {
|
|
|
955
1089
|
expect(response.status).toBe(201);
|
|
956
1090
|
const location = response.headers.get(`location`);
|
|
957
1091
|
expect(location).toBeDefined();
|
|
958
|
-
expect(location).toBe(
|
|
1092
|
+
expect(location.endsWith(streamPath)).toBe(true);
|
|
1093
|
+
expect(() => new URL(location)).not.toThrow();
|
|
959
1094
|
});
|
|
960
1095
|
test(`should reject missing Content-Type on POST`, async () => {
|
|
961
1096
|
const streamPath = `/v1/stream/missing-ct-post-test-${Date.now()}`;
|
|
@@ -1159,7 +1294,7 @@ function runConformanceTests(options) {
|
|
|
1159
1294
|
"Stream-TTL": `3600`
|
|
1160
1295
|
}
|
|
1161
1296
|
});
|
|
1162
|
-
expect(
|
|
1297
|
+
expect(response2.status).toBe(200);
|
|
1163
1298
|
});
|
|
1164
1299
|
test(`should reject idempotent PUT with different TTL`, async () => {
|
|
1165
1300
|
const streamPath = `/v1/stream/ttl-conflict-test-${Date.now()}`;
|
|
@@ -1263,7 +1398,7 @@ function runConformanceTests(options) {
|
|
|
1263
1398
|
headers: { "Content-Type": `text/plain` },
|
|
1264
1399
|
body: `appended data`
|
|
1265
1400
|
});
|
|
1266
|
-
expect(
|
|
1401
|
+
expect(postBefore.status).toBe(204);
|
|
1267
1402
|
await sleep(1500);
|
|
1268
1403
|
const postAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1269
1404
|
method: `POST`,
|
|
@@ -1323,7 +1458,7 @@ function runConformanceTests(options) {
|
|
|
1323
1458
|
headers: { "Content-Type": `text/plain` },
|
|
1324
1459
|
body: `appended data`
|
|
1325
1460
|
});
|
|
1326
|
-
expect(
|
|
1461
|
+
expect(postBefore.status).toBe(204);
|
|
1327
1462
|
await sleep(1500);
|
|
1328
1463
|
const postAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1329
1464
|
method: `POST`,
|
|
@@ -1746,6 +1881,88 @@ function runConformanceTests(options) {
|
|
|
1746
1881
|
expect(received).toContain(`data: line2`);
|
|
1747
1882
|
expect(received).toContain(`data: line3`);
|
|
1748
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
|
+
});
|
|
1749
1966
|
test(`should generate unique, monotonically increasing offsets in SSE mode`, async () => {
|
|
1750
1967
|
const streamPath = `/v1/stream/sse-monotonic-offset-test-${Date.now()}`;
|
|
1751
1968
|
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
@@ -2131,7 +2348,7 @@ function runConformanceTests(options) {
|
|
|
2131
2348
|
headers: { "Content-Type": `application/octet-stream` },
|
|
2132
2349
|
body: chunk
|
|
2133
2350
|
});
|
|
2134
|
-
expect(
|
|
2351
|
+
expect(response.status).toBe(204);
|
|
2135
2352
|
}
|
|
2136
2353
|
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
2137
2354
|
const expected = new Uint8Array(totalLength);
|
|
@@ -2234,7 +2451,7 @@ function runConformanceTests(options) {
|
|
|
2234
2451
|
headers: { "Content-Type": `application/octet-stream` },
|
|
2235
2452
|
body: op.data
|
|
2236
2453
|
});
|
|
2237
|
-
expect(
|
|
2454
|
+
expect(response.status).toBe(204);
|
|
2238
2455
|
appendedData.push(...Array.from(op.data));
|
|
2239
2456
|
const offset = response.headers.get(STREAM_OFFSET_HEADER);
|
|
2240
2457
|
if (offset) savedOffsets.push(offset);
|
|
@@ -2317,7 +2534,7 @@ function runConformanceTests(options) {
|
|
|
2317
2534
|
headers: { "Content-Type": `application/octet-stream` },
|
|
2318
2535
|
body: data
|
|
2319
2536
|
});
|
|
2320
|
-
expect(
|
|
2537
|
+
expect(appendResponse.status).toBe(204);
|
|
2321
2538
|
const readResponse = await fetch(`${getBaseUrl()}${streamPath}`);
|
|
2322
2539
|
expect(readResponse.status).toBe(200);
|
|
2323
2540
|
const buffer = await readResponse.arrayBuffer();
|
|
@@ -2429,7 +2646,7 @@ function runConformanceTests(options) {
|
|
|
2429
2646
|
},
|
|
2430
2647
|
body: `data-${seq}`
|
|
2431
2648
|
});
|
|
2432
|
-
expect(
|
|
2649
|
+
expect(response.status).toBe(204);
|
|
2433
2650
|
}
|
|
2434
2651
|
return true;
|
|
2435
2652
|
}
|
|
@@ -2453,7 +2670,7 @@ function runConformanceTests(options) {
|
|
|
2453
2670
|
},
|
|
2454
2671
|
body: `first`
|
|
2455
2672
|
});
|
|
2456
|
-
expect(
|
|
2673
|
+
expect(response1.status).toBe(204);
|
|
2457
2674
|
const response2 = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2458
2675
|
method: `POST`,
|
|
2459
2676
|
headers: {
|
|
@@ -2468,6 +2685,252 @@ function runConformanceTests(options) {
|
|
|
2468
2685
|
), { numRuns: 25 });
|
|
2469
2686
|
});
|
|
2470
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
|
+
});
|
|
2471
2934
|
});
|
|
2472
2935
|
}
|
|
2473
2936
|
|
package/dist/test-runner.cjs
CHANGED
package/dist/test-runner.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@durable-streams/server-conformance-tests",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Conformance test suite for Durable Streams server implementations",
|
|
5
5
|
"author": "Durable Stream contributors",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"fast-check": "^4.4.0",
|
|
51
51
|
"vitest": "^4.0.0",
|
|
52
|
-
"@durable-streams/client": "0.1.
|
|
52
|
+
"@durable-streams/client": "0.1.3"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"tsdown": "^0.9.0",
|