@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/src/index.ts CHANGED
@@ -110,7 +110,7 @@ function parseSSEEvents(
110
110
  sseText: string
111
111
  ): Array<{ type: string; data: string }> {
112
112
  const events: Array<{ type: string; data: string }> = []
113
- const normalized = sseText.replace(/\r\n/g, `\n`)
113
+ const normalized = sseText.replace(/\r\n/g, `\n`).replace(/\r/g, `\n`)
114
114
 
115
115
  // Split by double newlines (event boundaries)
116
116
  const eventBlocks = normalized.split(`\n\n`).filter((block) => block.trim())
@@ -479,7 +479,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
479
479
  method: `PUT`,
480
480
  headers: { "Content-Type": `text/plain` },
481
481
  })
482
- expect([200, 204]).toContain(secondResponse.status)
482
+ expect(secondResponse.status).toBe(200)
483
483
  })
484
484
 
485
485
  test(`should return 409 on PUT with different config`, async () => {
@@ -516,7 +516,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
516
516
  body: `hello world`,
517
517
  })
518
518
 
519
- expect([200, 204]).toContain(response.status)
519
+ expect(response.status).toBe(204)
520
520
  expect(response.headers.get(STREAM_OFFSET_HEADER)).toBeDefined()
521
521
  })
522
522
 
@@ -793,7 +793,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
793
793
  body: `second`,
794
794
  })
795
795
 
796
- expect([200, 204]).toContain(response.status)
796
+ expect(response.status).toBe(204)
797
797
  })
798
798
 
799
799
  test(`should reject duplicate seq values`, async () => {
@@ -829,6 +829,218 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
829
829
  })
830
830
  })
831
831
 
832
+ // ============================================================================
833
+ // Browser Security Headers (Protocol Section 10.7)
834
+ // ============================================================================
835
+
836
+ describe(`Browser Security Headers`, () => {
837
+ test(`should include X-Content-Type-Options: nosniff on GET responses`, async () => {
838
+ const streamPath = `/v1/stream/security-get-nosniff-${Date.now()}`
839
+
840
+ // Create stream with data
841
+ await fetch(`${getBaseUrl()}${streamPath}`, {
842
+ method: `PUT`,
843
+ headers: { "Content-Type": `text/plain` },
844
+ body: `test data`,
845
+ })
846
+
847
+ // Read data
848
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
849
+ method: `GET`,
850
+ })
851
+
852
+ expect(response.status).toBe(200)
853
+ expect(response.headers.get(`x-content-type-options`)).toBe(`nosniff`)
854
+ })
855
+
856
+ test(`should include X-Content-Type-Options: nosniff on PUT responses`, async () => {
857
+ const streamPath = `/v1/stream/security-put-nosniff-${Date.now()}`
858
+
859
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
860
+ method: `PUT`,
861
+ headers: { "Content-Type": `text/plain` },
862
+ })
863
+
864
+ expect(response.status).toBe(201)
865
+ expect(response.headers.get(`x-content-type-options`)).toBe(`nosniff`)
866
+ })
867
+
868
+ test(`should include X-Content-Type-Options: nosniff on POST responses`, async () => {
869
+ const streamPath = `/v1/stream/security-post-nosniff-${Date.now()}`
870
+
871
+ // Create stream
872
+ await fetch(`${getBaseUrl()}${streamPath}`, {
873
+ method: `PUT`,
874
+ headers: { "Content-Type": `text/plain` },
875
+ })
876
+
877
+ // Append data
878
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
879
+ method: `POST`,
880
+ headers: { "Content-Type": `text/plain` },
881
+ body: `data`,
882
+ })
883
+
884
+ expect([200, 204]).toContain(response.status)
885
+ expect(response.headers.get(`x-content-type-options`)).toBe(`nosniff`)
886
+ })
887
+
888
+ test(`should include X-Content-Type-Options: nosniff on HEAD responses`, async () => {
889
+ const streamPath = `/v1/stream/security-head-nosniff-${Date.now()}`
890
+
891
+ // Create stream
892
+ await fetch(`${getBaseUrl()}${streamPath}`, {
893
+ method: `PUT`,
894
+ headers: { "Content-Type": `text/plain` },
895
+ })
896
+
897
+ // HEAD request
898
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
899
+ method: `HEAD`,
900
+ })
901
+
902
+ expect(response.status).toBe(200)
903
+ expect(response.headers.get(`x-content-type-options`)).toBe(`nosniff`)
904
+ })
905
+
906
+ test(`should include Cross-Origin-Resource-Policy header on GET responses`, async () => {
907
+ const streamPath = `/v1/stream/security-corp-get-${Date.now()}`
908
+
909
+ // Create stream with data
910
+ await fetch(`${getBaseUrl()}${streamPath}`, {
911
+ method: `PUT`,
912
+ headers: { "Content-Type": `application/octet-stream` },
913
+ body: new Uint8Array([1, 2, 3, 4]),
914
+ })
915
+
916
+ // Read data
917
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
918
+ method: `GET`,
919
+ })
920
+
921
+ expect(response.status).toBe(200)
922
+ const corp = response.headers.get(`cross-origin-resource-policy`)
923
+ expect(corp).toBeDefined()
924
+ expect([`cross-origin`, `same-origin`, `same-site`]).toContain(corp)
925
+ })
926
+
927
+ test(`should include Cache-Control: no-store on HEAD responses`, async () => {
928
+ const streamPath = `/v1/stream/security-head-cache-${Date.now()}`
929
+
930
+ // Create stream
931
+ await fetch(`${getBaseUrl()}${streamPath}`, {
932
+ method: `PUT`,
933
+ headers: { "Content-Type": `text/plain` },
934
+ })
935
+
936
+ // HEAD request
937
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
938
+ method: `HEAD`,
939
+ })
940
+
941
+ expect(response.status).toBe(200)
942
+ const cacheControl = response.headers.get(`cache-control`)
943
+ expect(cacheControl).toBeDefined()
944
+ expect(cacheControl).toContain(`no-store`)
945
+ })
946
+
947
+ test(`should include X-Content-Type-Options: nosniff on SSE responses`, async () => {
948
+ const streamPath = `/v1/stream/security-sse-nosniff-${Date.now()}`
949
+
950
+ // Create stream with data
951
+ await fetch(`${getBaseUrl()}${streamPath}`, {
952
+ method: `PUT`,
953
+ headers: { "Content-Type": `application/json` },
954
+ body: JSON.stringify({ test: `data` }),
955
+ })
956
+
957
+ // Get offset
958
+ const headResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
959
+ method: `HEAD`,
960
+ })
961
+ const offset = headResponse.headers.get(STREAM_OFFSET_HEADER) ?? `-1`
962
+
963
+ // SSE request with abort controller
964
+ const controller = new AbortController()
965
+ const timeoutId = setTimeout(() => controller.abort(), 500)
966
+
967
+ try {
968
+ const response = await fetch(
969
+ `${getBaseUrl()}${streamPath}?offset=${offset}&live=sse`,
970
+ {
971
+ method: `GET`,
972
+ signal: controller.signal,
973
+ }
974
+ )
975
+
976
+ expect(response.status).toBe(200)
977
+ expect(response.headers.get(`x-content-type-options`)).toBe(`nosniff`)
978
+ } catch (e) {
979
+ // AbortError is expected
980
+ if (!(e instanceof Error && e.name === `AbortError`)) {
981
+ throw e
982
+ }
983
+ } finally {
984
+ clearTimeout(timeoutId)
985
+ }
986
+ })
987
+
988
+ test(`should include X-Content-Type-Options: nosniff on long-poll responses`, async () => {
989
+ const streamPath = `/v1/stream/security-longpoll-nosniff-${Date.now()}`
990
+
991
+ // Create stream with data
992
+ await fetch(`${getBaseUrl()}${streamPath}`, {
993
+ method: `PUT`,
994
+ headers: { "Content-Type": `text/plain` },
995
+ body: `initial data`,
996
+ })
997
+
998
+ // Get offset
999
+ const headResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
1000
+ method: `HEAD`,
1001
+ })
1002
+ const offset = headResponse.headers.get(STREAM_OFFSET_HEADER) ?? `-1`
1003
+
1004
+ // Long-poll request (will likely return 204 if no new data)
1005
+ const controller = new AbortController()
1006
+ const timeoutId = setTimeout(() => controller.abort(), 500)
1007
+
1008
+ try {
1009
+ const response = await fetch(
1010
+ `${getBaseUrl()}${streamPath}?offset=${offset}&live=long-poll`,
1011
+ {
1012
+ method: `GET`,
1013
+ signal: controller.signal,
1014
+ }
1015
+ )
1016
+
1017
+ // Either 200 (data) or 204 (timeout) - both should have nosniff
1018
+ expect([200, 204]).toContain(response.status)
1019
+ expect(response.headers.get(`x-content-type-options`)).toBe(`nosniff`)
1020
+ } catch (e) {
1021
+ // AbortError is acceptable if request times out
1022
+ if (!(e instanceof Error && e.name === `AbortError`)) {
1023
+ throw e
1024
+ }
1025
+ } finally {
1026
+ clearTimeout(timeoutId)
1027
+ }
1028
+ })
1029
+
1030
+ test(`should include security headers on error responses`, async () => {
1031
+ const streamPath = `/v1/stream/security-error-headers-${Date.now()}`
1032
+
1033
+ // Try to read non-existent stream (404)
1034
+ const response = await fetch(`${getBaseUrl()}${streamPath}`, {
1035
+ method: `GET`,
1036
+ })
1037
+
1038
+ expect(response.status).toBe(404)
1039
+ // Security headers should be present even on error responses
1040
+ expect(response.headers.get(`x-content-type-options`)).toBe(`nosniff`)
1041
+ })
1042
+ })
1043
+
832
1044
  // ============================================================================
833
1045
  // TTL and Expiry Validation
834
1046
  // ============================================================================
@@ -927,7 +1139,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
927
1139
  body: `test`,
928
1140
  })
929
1141
 
930
- expect([200, 204]).toContain(response.status)
1142
+ expect(response.status).toBe(204)
931
1143
  })
932
1144
 
933
1145
  test(`should allow idempotent create with different case content-type`, async () => {
@@ -945,7 +1157,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
945
1157
  method: `PUT`,
946
1158
  headers: { "Content-Type": `APPLICATION/JSON` },
947
1159
  })
948
- expect([200, 204]).toContain(response2.status)
1160
+ expect(response2.status).toBe(200)
949
1161
  })
950
1162
 
951
1163
  test(`should accept headers with different casing`, async () => {
@@ -967,7 +1179,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
967
1179
  body: `test`,
968
1180
  })
969
1181
 
970
- expect([200, 204]).toContain(response.status)
1182
+ expect(response.status).toBe(204)
971
1183
  })
972
1184
  })
973
1185
 
@@ -1011,7 +1223,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
1011
1223
  body: `{"test": true}`,
1012
1224
  })
1013
1225
 
1014
- expect([200, 204]).toContain(response.status)
1226
+ expect(response.status).toBe(204)
1015
1227
  })
1016
1228
 
1017
1229
  test(`should return stream content-type on GET`, async () => {
@@ -1492,7 +1704,10 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
1492
1704
  expect(response.status).toBe(201)
1493
1705
  const location = response.headers.get(`location`)
1494
1706
  expect(location).toBeDefined()
1495
- expect(location).toBe(`${getBaseUrl()}${streamPath}`)
1707
+ // Check that Location contains the correct path (host may vary by server config)
1708
+ expect(location!.endsWith(streamPath)).toBe(true)
1709
+ // Verify it's a valid absolute URL
1710
+ expect(() => new URL(location!)).not.toThrow()
1496
1711
  })
1497
1712
 
1498
1713
  test(`should reject missing Content-Type on POST`, async () => {
@@ -1828,7 +2043,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
1828
2043
  "Stream-TTL": `3600`,
1829
2044
  },
1830
2045
  })
1831
- expect([200, 204]).toContain(response2.status)
2046
+ expect(response2.status).toBe(200)
1832
2047
  })
1833
2048
 
1834
2049
  test(`should reject idempotent PUT with different TTL`, async () => {
@@ -2005,7 +2220,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
2005
2220
  headers: { "Content-Type": `text/plain` },
2006
2221
  body: `appended data`,
2007
2222
  })
2008
- expect([200, 204]).toContain(postBefore.status)
2223
+ expect(postBefore.status).toBe(204)
2009
2224
 
2010
2225
  // Wait for TTL to expire
2011
2226
  await sleep(1500)
@@ -2109,7 +2324,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
2109
2324
  headers: { "Content-Type": `text/plain` },
2110
2325
  body: `appended data`,
2111
2326
  })
2112
- expect([200, 204]).toContain(postBefore.status)
2327
+ expect(postBefore.status).toBe(204)
2113
2328
 
2114
2329
  // Wait for expiry time to pass
2115
2330
  await sleep(1500)
@@ -2842,6 +3057,154 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
2842
3057
  expect(received).toContain(`data: line3`)
2843
3058
  })
2844
3059
 
3060
+ test(`should prevent CRLF injection in payloads - embedded event boundaries become literal data`, async () => {
3061
+ const streamPath = `/v1/stream/sse-crlf-injection-test-${Date.now()}`
3062
+
3063
+ // Payload attempts to inject a fake control event via CRLF sequences
3064
+ // If vulnerable, this would terminate the current event and inject a new one
3065
+ const maliciousPayload = `safe content\r\n\r\nevent: control\r\ndata: {"injected":true}\r\n\r\nmore safe content`
3066
+
3067
+ await fetch(`${getBaseUrl()}${streamPath}`, {
3068
+ method: `PUT`,
3069
+ headers: { "Content-Type": `text/plain` },
3070
+ body: maliciousPayload,
3071
+ })
3072
+
3073
+ const { response, received } = await fetchSSE(
3074
+ `${getBaseUrl()}${streamPath}?offset=-1&live=sse`,
3075
+ { untilContent: `event: control` }
3076
+ )
3077
+
3078
+ expect(response.status).toBe(200)
3079
+
3080
+ // Parse all events from the response
3081
+ const events = parseSSEEvents(received)
3082
+
3083
+ // Should have exactly 1 data event and 1 control event (the real one from server)
3084
+ const dataEvents = events.filter((e) => e.type === `data`)
3085
+ const controlEvents = events.filter((e) => e.type === `control`)
3086
+
3087
+ expect(dataEvents.length).toBe(1)
3088
+ expect(controlEvents.length).toBe(1)
3089
+
3090
+ // The "injected" control event should NOT exist as a real event
3091
+ // Instead, "event: control" should appear as literal text within the data
3092
+ const dataContent = dataEvents[0]!.data
3093
+ expect(dataContent).toContain(`event: control`)
3094
+ expect(dataContent).toContain(`data: {"injected":true}`)
3095
+
3096
+ // The real control event should have server-generated fields, not injected ones
3097
+ const controlContent = JSON.parse(controlEvents[0]!.data)
3098
+ expect(controlContent.injected).toBeUndefined()
3099
+ expect(controlContent.streamNextOffset).toBeDefined()
3100
+ })
3101
+
3102
+ test(`should prevent CRLF injection - LF-only attack vectors`, async () => {
3103
+ const streamPath = `/v1/stream/sse-lf-injection-test-${Date.now()}`
3104
+
3105
+ // Attempt injection using Unix-style line endings only
3106
+ const maliciousPayload = `start\n\nevent: data\ndata: fake-event\n\nend`
3107
+
3108
+ await fetch(`${getBaseUrl()}${streamPath}`, {
3109
+ method: `PUT`,
3110
+ headers: { "Content-Type": `text/plain` },
3111
+ body: maliciousPayload,
3112
+ })
3113
+
3114
+ const { response, received } = await fetchSSE(
3115
+ `${getBaseUrl()}${streamPath}?offset=-1&live=sse`,
3116
+ { untilContent: `event: control` }
3117
+ )
3118
+
3119
+ expect(response.status).toBe(200)
3120
+
3121
+ const events = parseSSEEvents(received)
3122
+ const dataEvents = events.filter((e) => e.type === `data`)
3123
+
3124
+ // Should be exactly 1 data event (the injected one should be escaped)
3125
+ expect(dataEvents.length).toBe(1)
3126
+
3127
+ // The payload should be preserved as literal content, including the
3128
+ // "event: data" and "data: fake-event" as text, not parsed as SSE commands
3129
+ const dataContent = dataEvents[0]!.data
3130
+ expect(dataContent).toContain(`event: data`)
3131
+ expect(dataContent).toContain(`data: fake-event`)
3132
+ })
3133
+
3134
+ test(`should prevent CRLF injection - carriage return only attack vectors`, async () => {
3135
+ const streamPath = `/v1/stream/sse-cr-injection-test-${Date.now()}`
3136
+
3137
+ // Attempt injection using CR-only line endings (per SSE spec, CR is a valid line terminator)
3138
+ const maliciousPayload = `start\r\revent: control\rdata: {"cr_injected":true}\r\rend`
3139
+
3140
+ await fetch(`${getBaseUrl()}${streamPath}`, {
3141
+ method: `PUT`,
3142
+ headers: { "Content-Type": `text/plain` },
3143
+ body: maliciousPayload,
3144
+ })
3145
+
3146
+ const { response, received } = await fetchSSE(
3147
+ `${getBaseUrl()}${streamPath}?offset=-1&live=sse`,
3148
+ { untilContent: `event: control` }
3149
+ )
3150
+
3151
+ expect(response.status).toBe(200)
3152
+
3153
+ const events = parseSSEEvents(received)
3154
+ const controlEvents = events.filter((e) => e.type === `control`)
3155
+
3156
+ // Should have exactly 1 control event (the real one from server)
3157
+ expect(controlEvents.length).toBe(1)
3158
+
3159
+ // The real control event should not contain injected fields
3160
+ const controlContent = JSON.parse(controlEvents[0]!.data)
3161
+ expect(controlContent.cr_injected).toBeUndefined()
3162
+ expect(controlContent.streamNextOffset).toBeDefined()
3163
+ })
3164
+
3165
+ test(`should handle JSON payloads with embedded newlines safely`, async () => {
3166
+ const streamPath = `/v1/stream/sse-json-newline-test-${Date.now()}`
3167
+
3168
+ // JSON content that contains literal newlines in string values
3169
+ // These should be JSON-escaped, but we test that even if they're not,
3170
+ // SSE encoding handles them safely
3171
+ const jsonPayload = JSON.stringify({
3172
+ message: `line1\nline2\nline3`,
3173
+ attack: `try\r\n\r\nevent: control\r\ndata: {"bad":true}`,
3174
+ })
3175
+
3176
+ await fetch(`${getBaseUrl()}${streamPath}`, {
3177
+ method: `PUT`,
3178
+ headers: { "Content-Type": `application/json` },
3179
+ body: jsonPayload,
3180
+ })
3181
+
3182
+ const { response, received } = await fetchSSE(
3183
+ `${getBaseUrl()}${streamPath}?offset=-1&live=sse`,
3184
+ { untilContent: `event: control` }
3185
+ )
3186
+
3187
+ expect(response.status).toBe(200)
3188
+
3189
+ const events = parseSSEEvents(received)
3190
+ const dataEvents = events.filter((e) => e.type === `data`)
3191
+ const controlEvents = events.filter((e) => e.type === `control`)
3192
+
3193
+ expect(dataEvents.length).toBe(1)
3194
+ expect(controlEvents.length).toBe(1)
3195
+
3196
+ // Parse the data event - should be valid JSON array wrapping the original object
3197
+ const parsedData = JSON.parse(dataEvents[0]!.data)
3198
+ expect(Array.isArray(parsedData)).toBe(true)
3199
+ expect(parsedData[0].message).toBe(`line1\nline2\nline3`)
3200
+ expect(parsedData[0].attack).toContain(`event: control`)
3201
+
3202
+ // Control event should be the real server-generated one
3203
+ const controlContent = JSON.parse(controlEvents[0]!.data)
3204
+ expect(controlContent.bad).toBeUndefined()
3205
+ expect(controlContent.streamNextOffset).toBeDefined()
3206
+ })
3207
+
2845
3208
  test(`should generate unique, monotonically increasing offsets in SSE mode`, async () => {
2846
3209
  const streamPath = `/v1/stream/sse-monotonic-offset-test-${Date.now()}`
2847
3210
 
@@ -3341,7 +3704,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
3341
3704
  headers: { "Content-Type": `application/octet-stream` },
3342
3705
  body: chunk,
3343
3706
  })
3344
- expect([200, 204]).toContain(response.status)
3707
+ expect(response.status).toBe(204)
3345
3708
  }
3346
3709
 
3347
3710
  // Calculate expected result
@@ -3492,7 +3855,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
3492
3855
  headers: { "Content-Type": `application/octet-stream` },
3493
3856
  body: op.data as BodyInit,
3494
3857
  })
3495
- expect([200, 204]).toContain(response.status)
3858
+ expect(response.status).toBe(204)
3496
3859
 
3497
3860
  // Track what we appended
3498
3861
  appendedData.push(...Array.from(op.data))
@@ -3643,7 +4006,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
3643
4006
  body: data,
3644
4007
  }
3645
4008
  )
3646
- expect([200, 204]).toContain(appendResponse.status)
4009
+ expect(appendResponse.status).toBe(204)
3647
4010
 
3648
4011
  // Immediately read back
3649
4012
  const readResponse = await fetch(`${getBaseUrl()}${streamPath}`)
@@ -3802,7 +4165,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
3802
4165
  },
3803
4166
  body: `data-${seq}`,
3804
4167
  })
3805
- expect([200, 204]).toContain(response.status)
4168
+ expect(response.status).toBe(204)
3806
4169
  }
3807
4170
 
3808
4171
  return true
@@ -3839,7 +4202,7 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
3839
4202
  },
3840
4203
  body: `first`,
3841
4204
  })
3842
- expect([200, 204]).toContain(response1.status)
4205
+ expect(response1.status).toBe(204)
3843
4206
 
3844
4207
  // Second append with smaller seq should be rejected
3845
4208
  const response2 = await fetch(`${getBaseUrl()}${streamPath}`, {
@@ -3859,5 +4222,382 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
3859
4222
  )
3860
4223
  })
3861
4224
  })
4225
+
4226
+ describe(`Concurrent Writer Stress Tests`, () => {
4227
+ test(`concurrent writers with sequence numbers - server handles gracefully`, async () => {
4228
+ const streamPath = `/v1/stream/concurrent-seq-${Date.now()}-${Math.random().toString(36).slice(2)}`
4229
+
4230
+ // Create stream
4231
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4232
+ method: `PUT`,
4233
+ headers: { "Content-Type": `text/plain` },
4234
+ })
4235
+
4236
+ // Try to write with same seq from multiple "writers" concurrently
4237
+ const numWriters = 5
4238
+ const seqValue = `seq-001`
4239
+
4240
+ const writePromises = Array.from({ length: numWriters }, (_, i) =>
4241
+ fetch(`${getBaseUrl()}${streamPath}`, {
4242
+ method: `POST`,
4243
+ headers: {
4244
+ "Content-Type": `text/plain`,
4245
+ [STREAM_SEQ_HEADER]: seqValue,
4246
+ },
4247
+ body: `writer-${i}`,
4248
+ })
4249
+ )
4250
+
4251
+ const responses = await Promise.all(writePromises)
4252
+ const statuses = responses.map((r) => r.status)
4253
+
4254
+ // Server should handle concurrent writes gracefully
4255
+ // All responses should be valid (success or conflict)
4256
+ for (const status of statuses) {
4257
+ expect([200, 204, 409]).toContain(status)
4258
+ }
4259
+
4260
+ // At least one should succeed
4261
+ const successes = statuses.filter((s) => s === 200 || s === 204)
4262
+ expect(successes.length).toBeGreaterThanOrEqual(1)
4263
+
4264
+ // Read back - should have exactly one write's data
4265
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`)
4266
+ const content = await readResponse.text()
4267
+
4268
+ // Content should contain data from exactly one writer
4269
+ const matchingWriters = Array.from({ length: numWriters }, (_, i) =>
4270
+ content.includes(`writer-${i}`)
4271
+ ).filter(Boolean)
4272
+ expect(matchingWriters.length).toBeGreaterThanOrEqual(1)
4273
+ })
4274
+
4275
+ test(`concurrent writers racing with incrementing seq values`, async () => {
4276
+ await fc.assert(
4277
+ fc.asyncProperty(
4278
+ fc.integer({ min: 3, max: 8 }), // Number of writers
4279
+ async (numWriters) => {
4280
+ const streamPath = `/v1/stream/concurrent-race-${Date.now()}-${Math.random().toString(36).slice(2)}`
4281
+
4282
+ // Create stream
4283
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4284
+ method: `PUT`,
4285
+ headers: { "Content-Type": `text/plain` },
4286
+ })
4287
+
4288
+ // Each writer gets a unique seq value (padded for lexicographic ordering)
4289
+ const writePromises = Array.from({ length: numWriters }, (_, i) =>
4290
+ fetch(`${getBaseUrl()}${streamPath}`, {
4291
+ method: `POST`,
4292
+ headers: {
4293
+ "Content-Type": `text/plain`,
4294
+ [STREAM_SEQ_HEADER]: String(i).padStart(4, `0`),
4295
+ },
4296
+ body: `data-${i}`,
4297
+ })
4298
+ )
4299
+
4300
+ const responses = await Promise.all(writePromises)
4301
+
4302
+ // With concurrent writes, some may succeed (200/204) and some may conflict (409)
4303
+ // due to out-of-order arrival at the server. All responses should be valid.
4304
+ const successIndices: Array<number> = []
4305
+ for (let i = 0; i < responses.length; i++) {
4306
+ expect([200, 204, 409]).toContain(responses[i]!.status)
4307
+ if (
4308
+ responses[i]!.status === 200 ||
4309
+ responses[i]!.status === 204
4310
+ ) {
4311
+ successIndices.push(i)
4312
+ }
4313
+ }
4314
+
4315
+ // At least one write should succeed
4316
+ expect(successIndices.length).toBeGreaterThanOrEqual(1)
4317
+
4318
+ // Read back and verify successful writes are present
4319
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`)
4320
+ const content = await readResponse.text()
4321
+
4322
+ // All successful writes should have their data in the stream
4323
+ for (const i of successIndices) {
4324
+ expect(content).toContain(`data-${i}`)
4325
+ }
4326
+
4327
+ return true
4328
+ }
4329
+ ),
4330
+ { numRuns: 10 }
4331
+ )
4332
+ })
4333
+
4334
+ test(`concurrent appends without seq - all data is persisted`, async () => {
4335
+ const streamPath = `/v1/stream/concurrent-no-seq-${Date.now()}-${Math.random().toString(36).slice(2)}`
4336
+
4337
+ // Create stream
4338
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4339
+ method: `PUT`,
4340
+ headers: { "Content-Type": `text/plain` },
4341
+ })
4342
+
4343
+ const numWriters = 10
4344
+ const writePromises = Array.from({ length: numWriters }, (_, i) =>
4345
+ fetch(`${getBaseUrl()}${streamPath}`, {
4346
+ method: `POST`,
4347
+ headers: { "Content-Type": `text/plain` },
4348
+ body: `concurrent-${i}`,
4349
+ })
4350
+ )
4351
+
4352
+ const responses = await Promise.all(writePromises)
4353
+
4354
+ // All should succeed
4355
+ for (const response of responses) {
4356
+ expect([200, 204]).toContain(response.status)
4357
+ }
4358
+
4359
+ // All offsets that are returned should be valid (non-null)
4360
+ const offsets = responses.map((r) =>
4361
+ r.headers.get(STREAM_OFFSET_HEADER)
4362
+ )
4363
+ for (const offset of offsets) {
4364
+ expect(offset).not.toBeNull()
4365
+ }
4366
+
4367
+ // Read back and verify all data is present (the key invariant)
4368
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`)
4369
+ const content = await readResponse.text()
4370
+
4371
+ for (let i = 0; i < numWriters; i++) {
4372
+ expect(content).toContain(`concurrent-${i}`)
4373
+ }
4374
+ })
4375
+
4376
+ test(`mixed readers and writers - readers see consistent state`, async () => {
4377
+ const streamPath = `/v1/stream/concurrent-rw-${Date.now()}-${Math.random().toString(36).slice(2)}`
4378
+
4379
+ // Create stream with initial data
4380
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4381
+ method: `PUT`,
4382
+ headers: { "Content-Type": `text/plain` },
4383
+ })
4384
+
4385
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4386
+ method: `POST`,
4387
+ headers: { "Content-Type": `text/plain` },
4388
+ body: `initial`,
4389
+ })
4390
+
4391
+ // Launch concurrent readers and writers
4392
+ const numOps = 20
4393
+ const operations = Array.from({ length: numOps }, (_, i) => {
4394
+ if (i % 2 === 0) {
4395
+ // Writer
4396
+ return fetch(`${getBaseUrl()}${streamPath}`, {
4397
+ method: `POST`,
4398
+ headers: { "Content-Type": `text/plain` },
4399
+ body: `write-${i}`,
4400
+ })
4401
+ } else {
4402
+ // Reader
4403
+ return fetch(`${getBaseUrl()}${streamPath}`)
4404
+ }
4405
+ })
4406
+
4407
+ const responses = await Promise.all(operations)
4408
+
4409
+ // All operations should succeed
4410
+ // Writers (even indices) return 204, readers (odd indices) return 200
4411
+ responses.forEach((response, i) => {
4412
+ const expectedStatus = i % 2 === 0 ? 204 : 200
4413
+ expect(response.status).toBe(expectedStatus)
4414
+ })
4415
+
4416
+ // Final read should have all writes
4417
+ const finalRead = await fetch(`${getBaseUrl()}${streamPath}`)
4418
+ const content = await finalRead.text()
4419
+
4420
+ // Initial data should be present
4421
+ expect(content).toContain(`initial`)
4422
+
4423
+ // All writes should be present
4424
+ for (let i = 0; i < numOps; i += 2) {
4425
+ expect(content).toContain(`write-${i}`)
4426
+ }
4427
+ })
4428
+ })
4429
+
4430
+ describe(`State Hash Verification`, () => {
4431
+ /**
4432
+ * Simple hash function for content verification.
4433
+ * Uses FNV-1a algorithm for deterministic hashing.
4434
+ */
4435
+ function hashContent(data: Uint8Array): string {
4436
+ let hash = 2166136261 // FNV offset basis
4437
+ for (const byte of data) {
4438
+ hash ^= byte
4439
+ hash = Math.imul(hash, 16777619) // FNV prime
4440
+ hash = hash >>> 0 // Convert to unsigned 32-bit
4441
+ }
4442
+ return hash.toString(16).padStart(8, `0`)
4443
+ }
4444
+
4445
+ test(`replay produces identical content hash`, async () => {
4446
+ await fc.assert(
4447
+ fc.asyncProperty(
4448
+ // Generate a sequence of appends
4449
+ fc.array(fc.uint8Array({ minLength: 1, maxLength: 100 }), {
4450
+ minLength: 1,
4451
+ maxLength: 10,
4452
+ }),
4453
+ async (chunks) => {
4454
+ // Create first stream and append data
4455
+ const streamPath1 = `/v1/stream/hash-verify-1-${Date.now()}-${Math.random().toString(36).slice(2)}`
4456
+ await fetch(`${getBaseUrl()}${streamPath1}`, {
4457
+ method: `PUT`,
4458
+ headers: { "Content-Type": `application/octet-stream` },
4459
+ })
4460
+
4461
+ for (const chunk of chunks) {
4462
+ await fetch(`${getBaseUrl()}${streamPath1}`, {
4463
+ method: `POST`,
4464
+ headers: { "Content-Type": `application/octet-stream` },
4465
+ body: chunk,
4466
+ })
4467
+ }
4468
+
4469
+ // Read and hash first stream
4470
+ const response1 = await fetch(`${getBaseUrl()}${streamPath1}`)
4471
+ const data1 = new Uint8Array(await response1.arrayBuffer())
4472
+ const hash1 = hashContent(data1)
4473
+
4474
+ // Create second stream and replay same operations
4475
+ const streamPath2 = `/v1/stream/hash-verify-2-${Date.now()}-${Math.random().toString(36).slice(2)}`
4476
+ await fetch(`${getBaseUrl()}${streamPath2}`, {
4477
+ method: `PUT`,
4478
+ headers: { "Content-Type": `application/octet-stream` },
4479
+ })
4480
+
4481
+ for (const chunk of chunks) {
4482
+ await fetch(`${getBaseUrl()}${streamPath2}`, {
4483
+ method: `POST`,
4484
+ headers: { "Content-Type": `application/octet-stream` },
4485
+ body: chunk,
4486
+ })
4487
+ }
4488
+
4489
+ // Read and hash second stream
4490
+ const response2 = await fetch(`${getBaseUrl()}${streamPath2}`)
4491
+ const data2 = new Uint8Array(await response2.arrayBuffer())
4492
+ const hash2 = hashContent(data2)
4493
+
4494
+ // Hashes must match
4495
+ expect(hash1).toBe(hash2)
4496
+ expect(data1.length).toBe(data2.length)
4497
+
4498
+ return true
4499
+ }
4500
+ ),
4501
+ { numRuns: 15 }
4502
+ )
4503
+ })
4504
+
4505
+ test(`content hash changes with each append`, async () => {
4506
+ const streamPath = `/v1/stream/hash-changes-${Date.now()}-${Math.random().toString(36).slice(2)}`
4507
+
4508
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4509
+ method: `PUT`,
4510
+ headers: { "Content-Type": `application/octet-stream` },
4511
+ })
4512
+
4513
+ const hashes: Array<string> = []
4514
+
4515
+ // Append 5 chunks and verify hash changes each time
4516
+ for (let i = 0; i < 5; i++) {
4517
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4518
+ method: `POST`,
4519
+ headers: { "Content-Type": `application/octet-stream` },
4520
+ body: new Uint8Array([i, i + 1, i + 2]),
4521
+ })
4522
+
4523
+ const response = await fetch(`${getBaseUrl()}${streamPath}`)
4524
+ const data = new Uint8Array(await response.arrayBuffer())
4525
+ hashes.push(hashContent(data))
4526
+ }
4527
+
4528
+ // All hashes should be unique
4529
+ const uniqueHashes = new Set(hashes)
4530
+ expect(uniqueHashes.size).toBe(5)
4531
+ })
4532
+
4533
+ test(`empty stream has consistent hash`, async () => {
4534
+ // Create two empty streams
4535
+ const streamPath1 = `/v1/stream/empty-hash-1-${Date.now()}-${Math.random().toString(36).slice(2)}`
4536
+ const streamPath2 = `/v1/stream/empty-hash-2-${Date.now()}-${Math.random().toString(36).slice(2)}`
4537
+
4538
+ await fetch(`${getBaseUrl()}${streamPath1}`, {
4539
+ method: `PUT`,
4540
+ headers: { "Content-Type": `application/octet-stream` },
4541
+ })
4542
+ await fetch(`${getBaseUrl()}${streamPath2}`, {
4543
+ method: `PUT`,
4544
+ headers: { "Content-Type": `application/octet-stream` },
4545
+ })
4546
+
4547
+ // Read both
4548
+ const response1 = await fetch(`${getBaseUrl()}${streamPath1}`)
4549
+ const response2 = await fetch(`${getBaseUrl()}${streamPath2}`)
4550
+
4551
+ const data1 = new Uint8Array(await response1.arrayBuffer())
4552
+ const data2 = new Uint8Array(await response2.arrayBuffer())
4553
+
4554
+ // Both should be empty and have same hash
4555
+ expect(data1.length).toBe(0)
4556
+ expect(data2.length).toBe(0)
4557
+ expect(hashContent(data1)).toBe(hashContent(data2))
4558
+ })
4559
+
4560
+ test(`deterministic ordering - same data in same order produces same hash`, async () => {
4561
+ await fc.assert(
4562
+ fc.asyncProperty(
4563
+ fc.array(fc.uint8Array({ minLength: 1, maxLength: 50 }), {
4564
+ minLength: 2,
4565
+ maxLength: 5,
4566
+ }),
4567
+ async (chunks) => {
4568
+ // Create two streams with same data in same order
4569
+ const hashes: Array<string> = []
4570
+
4571
+ for (let run = 0; run < 2; run++) {
4572
+ const streamPath = `/v1/stream/order-hash-${run}-${Date.now()}-${Math.random().toString(36).slice(2)}`
4573
+
4574
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4575
+ method: `PUT`,
4576
+ headers: { "Content-Type": `application/octet-stream` },
4577
+ })
4578
+
4579
+ // Append in order
4580
+ for (const chunk of chunks) {
4581
+ await fetch(`${getBaseUrl()}${streamPath}`, {
4582
+ method: `POST`,
4583
+ headers: { "Content-Type": `application/octet-stream` },
4584
+ body: chunk,
4585
+ })
4586
+ }
4587
+
4588
+ const response = await fetch(`${getBaseUrl()}${streamPath}`)
4589
+ const data = new Uint8Array(await response.arrayBuffer())
4590
+ hashes.push(hashContent(data))
4591
+ }
4592
+
4593
+ expect(hashes[0]).toBe(hashes[1])
4594
+
4595
+ return true
4596
+ }
4597
+ ),
4598
+ { numRuns: 10 }
4599
+ )
4600
+ })
4601
+ })
3862
4602
  })
3863
4603
  }