@stowage/conformance 0.0.0 → 0.2.0
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/LICENSE +21 -0
- package/README.md +187 -0
- package/dist/index.d.ts +97 -0
- package/dist/index.js +2220 -0
- package/package.json +41 -1
package/dist/index.js
ADDED
|
@@ -0,0 +1,2220 @@
|
|
|
1
|
+
import { capabilityNames, isStorageError } from "@stowage/core";
|
|
2
|
+
//#region src/result.ts
|
|
3
|
+
/**
|
|
4
|
+
* What a harness may carry out of a run (ADR 0006). Neither `cause` nor the thrown value
|
|
5
|
+
* itself survives the trip out of a worker, so a result holds neither.
|
|
6
|
+
*/
|
|
7
|
+
function serializeError(thrown) {
|
|
8
|
+
const serialized = {
|
|
9
|
+
name: stringFieldOf(thrown, "name") ?? "Error",
|
|
10
|
+
message: stringFieldOf(thrown, "message") ?? stringOf(thrown)
|
|
11
|
+
};
|
|
12
|
+
const stack = stringFieldOf(thrown, "stack");
|
|
13
|
+
const code = storageErrorCodeOf(thrown);
|
|
14
|
+
if (stack !== void 0) serialized.stack = stack;
|
|
15
|
+
if (code !== void 0) serialized.code = code;
|
|
16
|
+
return serialized;
|
|
17
|
+
}
|
|
18
|
+
function stringFieldOf(value, field) {
|
|
19
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
20
|
+
try {
|
|
21
|
+
const held = Reflect.get(value, field);
|
|
22
|
+
return typeof held === "string" ? held : void 0;
|
|
23
|
+
} catch {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function stringOf(value) {
|
|
28
|
+
try {
|
|
29
|
+
return String(value);
|
|
30
|
+
} catch {
|
|
31
|
+
return "Unknown error";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function storageErrorCodeOf(value) {
|
|
35
|
+
try {
|
|
36
|
+
return isStorageError(value) ? value.code : void 0;
|
|
37
|
+
} catch {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region src/assertions.ts
|
|
43
|
+
/**
|
|
44
|
+
* What a case asserts with. The suite carries its own rather than the `expect` of a test
|
|
45
|
+
* framework, because `runAll` runs the same cases where there is no framework (ADR 0006).
|
|
46
|
+
*/
|
|
47
|
+
function assert(condition, message) {
|
|
48
|
+
if (!condition) throw new Error(message);
|
|
49
|
+
}
|
|
50
|
+
/** The bytes a case wrote, read back, where a failure names the offset and not the body. */
|
|
51
|
+
function assertSameBytes(actual, expected, what) {
|
|
52
|
+
assert(actual.byteLength === expected.byteLength, `${what} is ${actual.byteLength} bytes, and not the ${expected.byteLength} that were written`);
|
|
53
|
+
for (let index = 0; index < expected.byteLength; index += 1) assert(actual[index] === expected[index], `${what} holds ${actual[index]} at byte ${index}, and not the ${expected[index]} that was written`);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A time the provider reported, as spec 4.4 and 4.6 have an object description and a
|
|
57
|
+
* listing entry carry it. A target may be written in JavaScript, where the declared type
|
|
58
|
+
* of a field promises nothing about what the storage hands over.
|
|
59
|
+
*/
|
|
60
|
+
function assertDate(value, what) {
|
|
61
|
+
assert(value instanceof Date && Number.isFinite(value.getTime()), `${what} is ${JSON.stringify(value)}, which is no \`Date\``);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Two descriptions of one object, on the fields the row of spec 9.5 names. Spec 4.4
|
|
65
|
+
* leaves `lastModified` free to differ between a write and a later read by the
|
|
66
|
+
* provider's rounding, so a row names its fields rather than the whole description.
|
|
67
|
+
*/
|
|
68
|
+
function assertSameDescription(one, other, fields, what) {
|
|
69
|
+
for (const field of fields) assert(one[field] === other[field], `${what} report \`${field}\` as ${JSON.stringify(one[field])} and ${JSON.stringify(other[field])}`);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* A header of the answer a case read, which is how spec 9.5 and 9.6 have the two cases
|
|
73
|
+
* that leave the API — a signed URL and the `Response` of flow 4 — read a content type.
|
|
74
|
+
*/
|
|
75
|
+
function assertHeader(response, name, expected) {
|
|
76
|
+
const held = response.headers.get(name);
|
|
77
|
+
assert(held === expected, `The answer reports \`${name}: ${JSON.stringify(held)}\` and not ${JSON.stringify(expected)}`);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The `StorageError` the call is expected to reject with, handed back so that a case can
|
|
81
|
+
* read it for what its row of spec 9.5 states beyond the fields it named here. A case
|
|
82
|
+
* running the same call over a list of keys names the one it is at in `what`.
|
|
83
|
+
*/
|
|
84
|
+
async function expectStorageError(call, expected, what) {
|
|
85
|
+
const subject = what === void 0 ? "" : ` for ${what}`;
|
|
86
|
+
const expectation = `Expected a \`StorageError\` with \`code: "${expected.code}"\`${subject}`;
|
|
87
|
+
const thrown = storageErrorOf(await rejectionOf(call, expectation), expectation);
|
|
88
|
+
for (const [field, value] of Object.entries(expected)) {
|
|
89
|
+
const held = Reflect.get(thrown, field);
|
|
90
|
+
assert(held === value, `${expectation}, and the error carries \`${field}: ${JSON.stringify(held)}\` rather than ${JSON.stringify(value)}`);
|
|
91
|
+
}
|
|
92
|
+
return thrown;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The `StorageError` a call rejected with, whatever its code, for a row that states the
|
|
96
|
+
* failure and not the code behind it, and for `errors/shape`, which reads every field
|
|
97
|
+
* spec 4.10 has an error carry off whichever error it provoked.
|
|
98
|
+
*/
|
|
99
|
+
async function expectAnyStorageError(call, what) {
|
|
100
|
+
const expectation = `Expected a \`StorageError\` for ${what}`;
|
|
101
|
+
return storageErrorOf(await rejectionOf(call, expectation), expectation);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Spec 4.3 and 4.5 leave two failures to the runtime — the `AbortError` of a signal and
|
|
105
|
+
* the `SyntaxError` of a body that is no JSON — which a caller branching on a
|
|
106
|
+
* `StorageError` has to be able to tell apart from one.
|
|
107
|
+
*/
|
|
108
|
+
async function expectRuntimeError(call, name) {
|
|
109
|
+
const expectation = `Expected the runtime's \`${name}\``;
|
|
110
|
+
const thrown = await rejectionOf(call, expectation);
|
|
111
|
+
const serialized = serializeError(thrown);
|
|
112
|
+
assert(!isStorageError(thrown), `${expectation}, and the call rejected with a \`StorageError\`: ${serialized.message}`);
|
|
113
|
+
assert(serialized.name === name, `${expectation}, and the call threw ${serialized.name}: ${serialized.message}`);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Spec 4.9 leaves a call needing a capability the storage does not declare with an
|
|
117
|
+
* `Unsupported` error naming it, and spec 9.2 has most of the `runWithout` halves assert
|
|
118
|
+
* that through this.
|
|
119
|
+
*/
|
|
120
|
+
async function expectUnsupported(call, capability) {
|
|
121
|
+
const expectation = `Expected \`Unsupported\` naming \`${capability}\``;
|
|
122
|
+
const thrown = storageErrorOf(await rejectionOf(call, expectation), expectation);
|
|
123
|
+
if (thrown.code !== "Unsupported" || thrown.capability !== capability) throw new Error(`${expectation}, and the call rejected with \`${thrown.code}\` naming \`${thrown.capability}\``);
|
|
124
|
+
}
|
|
125
|
+
function storageErrorOf(thrown, expectation) {
|
|
126
|
+
if (isStorageError(thrown)) return thrown;
|
|
127
|
+
const { name, message } = serializeError(thrown);
|
|
128
|
+
throw new Error(`${expectation}, and the call threw ${name}: ${message}`);
|
|
129
|
+
}
|
|
130
|
+
async function rejectionOf(call, expectation) {
|
|
131
|
+
try {
|
|
132
|
+
await call();
|
|
133
|
+
} catch (thrown) {
|
|
134
|
+
return thrown;
|
|
135
|
+
}
|
|
136
|
+
throw new Error(`${expectation}, and the call resolved`);
|
|
137
|
+
}
|
|
138
|
+
//#endregion
|
|
139
|
+
//#region src/cases/keys.ts
|
|
140
|
+
const utf8$6 = new TextEncoder();
|
|
141
|
+
/** Spec 9.7 measures a key in UTF-8 bytes, and `adapter-fs` bounds one segment at 255. */
|
|
142
|
+
const segmentLimit = 255;
|
|
143
|
+
/**
|
|
144
|
+
* The characters spec 4.8 refuses anywhere in a key, at both ends of the range it names
|
|
145
|
+
* and the one above it. They are built from their code points rather than written down,
|
|
146
|
+
* because a source file holding a `U+0000` is one no tool reads as text any more.
|
|
147
|
+
*/
|
|
148
|
+
const refusedControlCharacters = [
|
|
149
|
+
0,
|
|
150
|
+
31,
|
|
151
|
+
127
|
|
152
|
+
];
|
|
153
|
+
/**
|
|
154
|
+
* A lone high and a lone low surrogate, which spec 4.8 refuses under every rule: a
|
|
155
|
+
* JavaScript string holds either, and neither is a Unicode character with a UTF-8 form.
|
|
156
|
+
*/
|
|
157
|
+
const refusedLoneSurrogates = [55296, 56320];
|
|
158
|
+
/** The byte length spec 4.8 allows a writable key, which two key lists sit on either side of. */
|
|
159
|
+
const writableKeyLimit = 1024;
|
|
160
|
+
/** The prefix one case writes below, which no other case reads or writes. */
|
|
161
|
+
function prefixFor(ctx, caseName) {
|
|
162
|
+
return `${ctx.keyPrefix}${caseName}/`;
|
|
163
|
+
}
|
|
164
|
+
function keyFor(ctx, caseName, name = "object") {
|
|
165
|
+
return `${prefixFor(ctx, caseName)}${name}`;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* A key of exactly `bytes` UTF-8 bytes below `prefix`, in segments of at most 255 bytes:
|
|
169
|
+
* the boundary key of spec 9.2 and the over-long key of spec 9.7 are both built here, so
|
|
170
|
+
* that the run's prefix counts toward the length rather than being added to it.
|
|
171
|
+
*/
|
|
172
|
+
function keyOfBytes(prefix, bytes) {
|
|
173
|
+
const fill = bytes - utf8$6.encode(prefix).length;
|
|
174
|
+
if (fill < 1) throw new Error(`A key of ${bytes} bytes does not fit below ${JSON.stringify(prefix)}`);
|
|
175
|
+
const count = Math.ceil((fill + 1) / 256);
|
|
176
|
+
const characters = fill - (count - 1);
|
|
177
|
+
return `${prefix}${Array.from({ length: count }, (_, index) => "k".repeat(Math.floor(characters / count) + (index < characters % count ? 1 : 0))).join("/")}`;
|
|
178
|
+
}
|
|
179
|
+
/** The accepted list of spec 9.7, below the prefix of the case that writes it. */
|
|
180
|
+
function acceptedKeys(prefix) {
|
|
181
|
+
return [
|
|
182
|
+
...acceptedNames.map((name) => ({
|
|
183
|
+
label: name,
|
|
184
|
+
key: `${prefix}${name}`
|
|
185
|
+
})),
|
|
186
|
+
{
|
|
187
|
+
label: "a key of 1024 bytes",
|
|
188
|
+
key: keyOfBytes(prefix, writableKeyLimit)
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
label: "a segment of 255 bytes",
|
|
192
|
+
key: `${prefix}${"k".repeat(segmentLimit)}`
|
|
193
|
+
}
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
const acceptedNames = [
|
|
197
|
+
"a",
|
|
198
|
+
"hello world.txt",
|
|
199
|
+
"docs/2026/report.pdf",
|
|
200
|
+
"a#b",
|
|
201
|
+
"100%",
|
|
202
|
+
"q?x=1",
|
|
203
|
+
"a+b",
|
|
204
|
+
"it's",
|
|
205
|
+
"Grüße/日本語/ключ.txt"
|
|
206
|
+
];
|
|
207
|
+
/**
|
|
208
|
+
* The list of spec 9.7 that `put` refuses, below the prefix of the case wherever the
|
|
209
|
+
* prefix leaves the violated rule as it is: a key that is about the start of a key is
|
|
210
|
+
* given as the spec writes it, and every other one goes below the run's prefix, so that
|
|
211
|
+
* a run against a shared bucket asks `exists` about its own key space alone.
|
|
212
|
+
*/
|
|
213
|
+
function refusedWritableKeys(prefix) {
|
|
214
|
+
return [
|
|
215
|
+
{
|
|
216
|
+
label: "the empty string",
|
|
217
|
+
key: "",
|
|
218
|
+
existsAnswers: "unasked"
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
label: "/a",
|
|
222
|
+
key: "/a",
|
|
223
|
+
existsAnswers: "unasked"
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
label: "a/",
|
|
227
|
+
key: `${prefix}a/`,
|
|
228
|
+
existsAnswers: "false"
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
label: "a//b",
|
|
232
|
+
key: `${prefix}a//b`,
|
|
233
|
+
existsAnswers: "unasked"
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
label: "./a",
|
|
237
|
+
key: `${prefix}./a`,
|
|
238
|
+
existsAnswers: "unasked"
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
label: "a/../b",
|
|
242
|
+
key: `${prefix}a/../b`,
|
|
243
|
+
existsAnswers: "unasked"
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
label: "..",
|
|
247
|
+
key: `${prefix}..`,
|
|
248
|
+
existsAnswers: "unasked"
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
label: "a\\b",
|
|
252
|
+
key: `${prefix}a\\b`,
|
|
253
|
+
existsAnswers: "false"
|
|
254
|
+
},
|
|
255
|
+
...[...refusedControlCharacters, ...refusedLoneSurrogates].map((code) => {
|
|
256
|
+
const { label, key } = keyHolding(prefix, code);
|
|
257
|
+
return {
|
|
258
|
+
label,
|
|
259
|
+
key,
|
|
260
|
+
existsAnswers: "unasked"
|
|
261
|
+
};
|
|
262
|
+
}),
|
|
263
|
+
{
|
|
264
|
+
label: "a key of 1025 bytes",
|
|
265
|
+
key: keyOfBytes(prefix, 1025),
|
|
266
|
+
existsAnswers: "false-or-refusal"
|
|
267
|
+
}
|
|
268
|
+
];
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* The list of spec 9.7 that `get`, `stat` and `exists` refuse, placed as the refused
|
|
272
|
+
* writable list places its keys.
|
|
273
|
+
*/
|
|
274
|
+
function refusedAddressableKeys(prefix) {
|
|
275
|
+
return [
|
|
276
|
+
{
|
|
277
|
+
label: "the empty string",
|
|
278
|
+
key: ""
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
label: "/a",
|
|
282
|
+
key: "/a"
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
label: "a//b",
|
|
286
|
+
key: `${prefix}a//b`
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
label: "./a",
|
|
290
|
+
key: `${prefix}./a`
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
label: "a/../b",
|
|
294
|
+
key: `${prefix}a/../b`
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
label: ".",
|
|
298
|
+
key: `${prefix}.`
|
|
299
|
+
},
|
|
300
|
+
keyHolding(prefix, 0),
|
|
301
|
+
...refusedLoneSurrogates.map((code) => keyHolding(prefix, code))
|
|
302
|
+
];
|
|
303
|
+
}
|
|
304
|
+
function keyHolding(prefix, code) {
|
|
305
|
+
return {
|
|
306
|
+
label: `a key holding U+${code.toString(16).toUpperCase().padStart(4, "0")}`,
|
|
307
|
+
key: `${prefix}a${String.fromCharCode(code)}b`
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
//#endregion
|
|
311
|
+
//#region src/cases/objects.ts
|
|
312
|
+
/**
|
|
313
|
+
* What a case writes where it reads the content type back, under a key ending in `.txt`:
|
|
314
|
+
* spec 6 has `adapter-fs` derive the type from the key rather than store the one it was
|
|
315
|
+
* given, so the two have to agree.
|
|
316
|
+
*/
|
|
317
|
+
const textContentType = "text/plain";
|
|
318
|
+
/**
|
|
319
|
+
* How many writes a case keeps in flight. The cases past one thousand objects write more
|
|
320
|
+
* than a provider answers at once: a thousand requests in flight come back as throttling
|
|
321
|
+
* rather than as the objects the case is about.
|
|
322
|
+
*/
|
|
323
|
+
const inFlight = 32;
|
|
324
|
+
/**
|
|
325
|
+
* What the `slow` cases of spec 9.5 write: one past the thousand objects a provider
|
|
326
|
+
* answers a listing with and takes in a delete at the most, so that the case reaches
|
|
327
|
+
* past the one page and the one batch rather than filling them.
|
|
328
|
+
*/
|
|
329
|
+
const pastOneThousand = 1001;
|
|
330
|
+
/** The keys of `count` objects below `prefix`, numbered so that a failure names one. */
|
|
331
|
+
function keysBelow(prefix, count) {
|
|
332
|
+
const width = String(count - 1).length;
|
|
333
|
+
return Array.from({ length: count }, (_, index) => `${prefix}${String(index).padStart(width, "0")}`);
|
|
334
|
+
}
|
|
335
|
+
async function putEach(ctx, keys, body) {
|
|
336
|
+
for (let start = 0; start < keys.length; start += inFlight) {
|
|
337
|
+
const batch = keys.slice(start, start + inFlight);
|
|
338
|
+
await Promise.all(batch.map(async (key) => void await ctx.storage.put(key, body)));
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
async function collectEntries(listing) {
|
|
342
|
+
const entries = [];
|
|
343
|
+
for await (const entry of listing) entries.push(entry);
|
|
344
|
+
return entries;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* The keys a listing named, against the keys the case wrote. Spec 4.6 promises every
|
|
348
|
+
* object below the prefix once and no order, so the two sides meet as sets.
|
|
349
|
+
*/
|
|
350
|
+
function assertNamesEachOnce(held, expected, what) {
|
|
351
|
+
const once = new Set(held);
|
|
352
|
+
assert(once.size === held.length, `${what} names ${held.length} keys, ${once.size} of them distinct`);
|
|
353
|
+
for (const key of expected) assert(once.has(key), `${what} names no ${JSON.stringify(key)}`);
|
|
354
|
+
const beyond = [...once].filter((key) => !expected.includes(key));
|
|
355
|
+
assert(beyond.length === 0, `${what} names ${beyond.map((key) => JSON.stringify(key)).join(", ")} beyond what the case wrote`);
|
|
356
|
+
}
|
|
357
|
+
/** That nothing is left below the prefix, which is how a deletion of many keys is read. */
|
|
358
|
+
async function assertNothingBelow(ctx, prefix) {
|
|
359
|
+
const left = await collectEntries(ctx.storage.list({ prefix }));
|
|
360
|
+
assert(left.length === 0, `${left.length} objects are left below ${JSON.stringify(prefix)}, the first of them ${JSON.stringify(left[0]?.key)}`);
|
|
361
|
+
}
|
|
362
|
+
/** A report of spec 4.7 over keys the provider took: the count it covered, and no failure. */
|
|
363
|
+
function assertAccepted(report, requested, what) {
|
|
364
|
+
assert(report.requested === requested, `${what} reports \`requested: ${report.requested}\` and not ${requested}`);
|
|
365
|
+
assert(report.failed.length === 0, `${what} reports the failure ${JSON.stringify(report.failed[0]?.message)}`);
|
|
366
|
+
}
|
|
367
|
+
//#endregion
|
|
368
|
+
//#region src/cases/copy.ts
|
|
369
|
+
const utf8$5 = new TextEncoder();
|
|
370
|
+
const copyAndMoveCases = [
|
|
371
|
+
{
|
|
372
|
+
name: "copy/round-trip",
|
|
373
|
+
requires: [],
|
|
374
|
+
cost: "fast",
|
|
375
|
+
async run(ctx) {
|
|
376
|
+
const prefix = prefixFor(ctx, "copy/round-trip");
|
|
377
|
+
const source = await write(ctx, `${prefix}source.txt`, "the body to copy");
|
|
378
|
+
const to = `${prefix}destination.txt`;
|
|
379
|
+
assertNames(await ctx.storage.copy(source.key, to), to, "`copy`");
|
|
380
|
+
await assertHolds(ctx, to, source.body, "The destination");
|
|
381
|
+
await assertHolds(ctx, source.key, source.body, "The source after the copy");
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
{
|
|
385
|
+
name: "copy/overwrites",
|
|
386
|
+
requires: [],
|
|
387
|
+
cost: "fast",
|
|
388
|
+
async run(ctx) {
|
|
389
|
+
const prefix = prefixFor(ctx, "copy/overwrites");
|
|
390
|
+
const source = await write(ctx, `${prefix}source.txt`, "the body that replaces");
|
|
391
|
+
const destination = await write(ctx, `${prefix}destination.txt`, "the body that is replaced");
|
|
392
|
+
await ctx.storage.copy(source.key, destination.key);
|
|
393
|
+
await assertHolds(ctx, destination.key, source.body, "The destination that was there");
|
|
394
|
+
await assertHolds(ctx, source.key, source.body, "The source after the copy");
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
name: "copy/missing-source",
|
|
399
|
+
requires: [],
|
|
400
|
+
cost: "fast",
|
|
401
|
+
async run(ctx) {
|
|
402
|
+
const prefix = prefixFor(ctx, "copy/missing-source");
|
|
403
|
+
const to = `${prefix}destination.txt`;
|
|
404
|
+
await expectStorageError(() => ctx.storage.copy(`${prefix}absent.txt`, to), { code: "NotFound" });
|
|
405
|
+
await assertAbsent(ctx, to, "a copy whose source is not there");
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
name: "copy/onto-itself",
|
|
410
|
+
requires: [],
|
|
411
|
+
cost: "fast",
|
|
412
|
+
async run(ctx) {
|
|
413
|
+
const source = await write(ctx, `${prefixFor(ctx, "copy/onto-itself")}object.txt`, "the body that stays");
|
|
414
|
+
await expectStorageError(() => ctx.storage.copy(source.key, source.key), {
|
|
415
|
+
code: "InvalidRequest",
|
|
416
|
+
attempts: 0
|
|
417
|
+
});
|
|
418
|
+
await assertHolds(ctx, source.key, source.body, "The object a copy onto itself refused");
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
name: "copy/invalid-keys",
|
|
423
|
+
requires: [],
|
|
424
|
+
cost: "fast",
|
|
425
|
+
async run(ctx) {
|
|
426
|
+
const prefix = prefixFor(ctx, "copy/invalid-keys");
|
|
427
|
+
const source = await write(ctx, `${prefix}source.txt`, "the body that stays");
|
|
428
|
+
const refusedDestination = `${prefix}destination/`;
|
|
429
|
+
const to = `${prefix}destination.txt`;
|
|
430
|
+
await expectStorageError(() => ctx.storage.copy(source.key, refusedDestination), { code: "InvalidKey" }, "a destination ending in a slash");
|
|
431
|
+
await expectStorageError(() => ctx.storage.copy(`${prefix}../source.txt`, to), { code: "InvalidKey" }, "a source holding a `..` segment");
|
|
432
|
+
await assertHolds(ctx, source.key, source.body, "The source a refused copy left");
|
|
433
|
+
await assertAbsent(ctx, refusedDestination, "a copy whose destination key was refused");
|
|
434
|
+
await assertAbsent(ctx, to, "a copy whose source key was refused");
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
name: "copy/user-metadata",
|
|
439
|
+
requires: ["userMetadata"],
|
|
440
|
+
cost: "fast",
|
|
441
|
+
async run(ctx) {
|
|
442
|
+
const prefix = prefixFor(ctx, "copy/user-metadata");
|
|
443
|
+
const from = `${prefix}source.txt`;
|
|
444
|
+
const to = `${prefix}destination.txt`;
|
|
445
|
+
await ctx.storage.put(from, "the body to copy", {
|
|
446
|
+
contentType: textContentType,
|
|
447
|
+
userMetadata: { WrittenBy: "stowage" }
|
|
448
|
+
});
|
|
449
|
+
await ctx.storage.copy(from, to);
|
|
450
|
+
const described = await ctx.storage.stat(to);
|
|
451
|
+
assert(new Map(Object.entries(described.userMetadata).map(([name, value]) => [name.toLowerCase(), value])).get("writtenby") === "stowage", `The destination carries the user metadata ${JSON.stringify(described.userMetadata)} and not the source's`);
|
|
452
|
+
},
|
|
453
|
+
async runWithout(ctx) {
|
|
454
|
+
const prefix = prefixFor(ctx, "copy/user-metadata");
|
|
455
|
+
const from = `${prefix}source.txt`;
|
|
456
|
+
const to = `${prefix}destination.txt`;
|
|
457
|
+
await ctx.storage.put(from, "the body to copy", { contentType: textContentType });
|
|
458
|
+
await ctx.storage.copy(from, to);
|
|
459
|
+
const described = await ctx.storage.stat(to);
|
|
460
|
+
assert(Object.keys(described.userMetadata).length === 0, `The destination reports the user metadata ${JSON.stringify(described.userMetadata)} on a storage that holds none`);
|
|
461
|
+
}
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
name: "move/round-trip",
|
|
465
|
+
requires: [],
|
|
466
|
+
cost: "fast",
|
|
467
|
+
async run(ctx) {
|
|
468
|
+
const prefix = prefixFor(ctx, "move/round-trip");
|
|
469
|
+
const source = await write(ctx, `${prefix}source.txt`, "the body to move");
|
|
470
|
+
const to = `${prefix}destination.txt`;
|
|
471
|
+
assertNames(await ctx.storage.move(source.key, to), to, "`move`");
|
|
472
|
+
await assertHolds(ctx, to, source.body, "The destination");
|
|
473
|
+
await assertAbsent(ctx, source.key, "a move");
|
|
474
|
+
}
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
name: "move/missing-source",
|
|
478
|
+
requires: [],
|
|
479
|
+
cost: "fast",
|
|
480
|
+
async run(ctx) {
|
|
481
|
+
const prefix = prefixFor(ctx, "move/missing-source");
|
|
482
|
+
const to = `${prefix}destination.txt`;
|
|
483
|
+
await expectStorageError(() => ctx.storage.move(`${prefix}absent.txt`, to), {
|
|
484
|
+
code: "NotFound",
|
|
485
|
+
operation: "move"
|
|
486
|
+
});
|
|
487
|
+
await assertAbsent(ctx, to, "a move whose source is not there");
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
];
|
|
491
|
+
async function write(ctx, key, text) {
|
|
492
|
+
await ctx.storage.put(key, text, { contentType: textContentType });
|
|
493
|
+
return {
|
|
494
|
+
key,
|
|
495
|
+
body: utf8$5.encode(text)
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
function assertNames(written, to, what) {
|
|
499
|
+
assert(written.key === to, `${what} reports the key ${JSON.stringify(written.key)} and not the destination ${JSON.stringify(to)}`);
|
|
500
|
+
}
|
|
501
|
+
async function assertHolds(ctx, key, body, what) {
|
|
502
|
+
const stored = await ctx.storage.get(key);
|
|
503
|
+
assert(stored.stat.contentType === textContentType, `${what} reports the content type ${JSON.stringify(stored.stat.contentType)} and not ${JSON.stringify(textContentType)}`);
|
|
504
|
+
assertSameBytes(await stored.bytes(), body, `The body under ${JSON.stringify(key)}`);
|
|
505
|
+
}
|
|
506
|
+
async function assertAbsent(ctx, key, after) {
|
|
507
|
+
assert(!await ctx.storage.exists(key), `The object under ${JSON.stringify(key)} is there after ${after}`);
|
|
508
|
+
}
|
|
509
|
+
//#endregion
|
|
510
|
+
//#region src/cases/declaration.ts
|
|
511
|
+
const declarationCases = [{
|
|
512
|
+
name: "declaration/valid-names",
|
|
513
|
+
requires: [],
|
|
514
|
+
cost: "fast",
|
|
515
|
+
async run(ctx) {
|
|
516
|
+
const declared = ctx.storage.capabilities;
|
|
517
|
+
const published = capabilityNames;
|
|
518
|
+
for (const name of declared) assert(published.includes(name), `\`capabilities\` holds ${JSON.stringify(name)}, which is no capability name`);
|
|
519
|
+
assert(new Set(declared).size === declared.length, `\`capabilities\` names a capability twice: ${JSON.stringify(declared)}`);
|
|
520
|
+
}
|
|
521
|
+
}, {
|
|
522
|
+
name: "declaration/identity",
|
|
523
|
+
requires: [],
|
|
524
|
+
cost: "fast",
|
|
525
|
+
async run(ctx) {
|
|
526
|
+
assertNonEmptyString(ctx.storage.provider, "provider");
|
|
527
|
+
assertNonEmptyString(ctx.storage.bucket, "bucket");
|
|
528
|
+
}
|
|
529
|
+
}];
|
|
530
|
+
function assertNonEmptyString(value, field) {
|
|
531
|
+
assert(typeof value === "string" && value !== "", `\`${field}\` is ${JSON.stringify(value)}, and not a non-empty string`);
|
|
532
|
+
}
|
|
533
|
+
//#endregion
|
|
534
|
+
//#region src/cases/bytes.ts
|
|
535
|
+
const kibibyte = 1024;
|
|
536
|
+
/** The bytes a body is measured in, where a case names a size spec 9.5 or 9.6 states. */
|
|
537
|
+
const mebibyte = 1024 * kibibyte;
|
|
538
|
+
/** ADR 0016 makes one part the threshold for a stream, so 17 MiB provokes a multipart upload. */
|
|
539
|
+
const multipartSize = 17 * mebibyte;
|
|
540
|
+
/**
|
|
541
|
+
* The bytes a case writes: a pattern it rebuilds rather than holds, so that a body the
|
|
542
|
+
* provider reassembled out of order or lost a part of shows up as a mismatch, which one
|
|
543
|
+
* of the same byte repeated would not. Two patterns of different `seed` differ at every
|
|
544
|
+
* byte, so an object holding parts of both matches neither.
|
|
545
|
+
*/
|
|
546
|
+
function patternOf(size, seed = 0) {
|
|
547
|
+
const bytes = new Uint8Array(size);
|
|
548
|
+
for (let index = 0; index < size; index += 1) bytes[index] = (index * 7 + seed) % 251;
|
|
549
|
+
return bytes;
|
|
550
|
+
}
|
|
551
|
+
/** The same bytes as a stream, which is the body spec 4.2 has an adapter send in parts. */
|
|
552
|
+
function streamOf(bytes, chunkSize, onChunk) {
|
|
553
|
+
let sent = 0;
|
|
554
|
+
return new ReadableStream({ pull(controller) {
|
|
555
|
+
if (sent >= bytes.byteLength) {
|
|
556
|
+
controller.close();
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
controller.enqueue(bytes.subarray(sent, Math.min(sent + chunkSize, bytes.byteLength)));
|
|
560
|
+
sent += chunkSize;
|
|
561
|
+
onChunk?.(Math.min(sent, bytes.byteLength));
|
|
562
|
+
} });
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* The bodies of `put/concurrent-writers` as streams that each hold back their end until
|
|
566
|
+
* every one was read to its last byte. An adapter cannot complete an upload before its
|
|
567
|
+
* stream ends, so before either completes, each writer has read every byte and started
|
|
568
|
+
* every full part, whatever the part size, and the pacing names no adapter. Whether a
|
|
569
|
+
* started part was answered yet is the adapter's timing, which no stream can see.
|
|
570
|
+
*/
|
|
571
|
+
function streamsEndingTogether(bodies, chunkSize) {
|
|
572
|
+
const { promise: everyEndReached, resolve } = Promise.withResolvers();
|
|
573
|
+
let unended = bodies.length;
|
|
574
|
+
return bodies.map((bytes) => {
|
|
575
|
+
let sent = 0;
|
|
576
|
+
let reachedEnd = false;
|
|
577
|
+
let canceled = false;
|
|
578
|
+
const reachEnd = () => {
|
|
579
|
+
if (reachedEnd) return;
|
|
580
|
+
reachedEnd = true;
|
|
581
|
+
unended -= 1;
|
|
582
|
+
if (unended === 0) resolve();
|
|
583
|
+
};
|
|
584
|
+
return new ReadableStream({
|
|
585
|
+
async pull(controller) {
|
|
586
|
+
if (sent < bytes.byteLength) {
|
|
587
|
+
controller.enqueue(bytes.subarray(sent, Math.min(sent + chunkSize, bytes.byteLength)));
|
|
588
|
+
sent += chunkSize;
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
reachEnd();
|
|
592
|
+
await everyEndReached;
|
|
593
|
+
if (!canceled) controller.close();
|
|
594
|
+
},
|
|
595
|
+
cancel() {
|
|
596
|
+
canceled = true;
|
|
597
|
+
reachEnd();
|
|
598
|
+
}
|
|
599
|
+
}, { highWaterMark: 0 });
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
async function collect(stream) {
|
|
603
|
+
const chunks = [];
|
|
604
|
+
let size = 0;
|
|
605
|
+
for await (const chunk of stream) {
|
|
606
|
+
chunks.push(chunk);
|
|
607
|
+
size += chunk.byteLength;
|
|
608
|
+
}
|
|
609
|
+
const bytes = new Uint8Array(size);
|
|
610
|
+
let offset = 0;
|
|
611
|
+
for (const chunk of chunks) {
|
|
612
|
+
bytes.set(chunk, offset);
|
|
613
|
+
offset += chunk.byteLength;
|
|
614
|
+
}
|
|
615
|
+
return bytes;
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* The stream of `put/abort-during-upload` and of flow 1: it fires the signal once half of
|
|
619
|
+
* the body went out, which is where an upload is interrupted rather than refused.
|
|
620
|
+
*/
|
|
621
|
+
function streamAbortedMidway(bytes, chunkSize, controller) {
|
|
622
|
+
return streamOf(bytes, chunkSize, (sent) => {
|
|
623
|
+
if (sent >= bytes.byteLength / 2) controller.abort();
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
//#endregion
|
|
627
|
+
//#region src/cases/delete.ts
|
|
628
|
+
const deleteCases = [
|
|
629
|
+
{
|
|
630
|
+
name: "delete/single",
|
|
631
|
+
requires: [],
|
|
632
|
+
cost: "fast",
|
|
633
|
+
async run(ctx) {
|
|
634
|
+
const key = keyFor(ctx, "delete/single");
|
|
635
|
+
await ctx.storage.put(key, patternOf(16));
|
|
636
|
+
assertAccepted(await ctx.storage.delete(key), 1, "Deleting one key");
|
|
637
|
+
assert(!await ctx.storage.exists(key), "`exists` answers true for the key that was deleted");
|
|
638
|
+
}
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
name: "delete/many",
|
|
642
|
+
requires: [],
|
|
643
|
+
cost: "fast",
|
|
644
|
+
async run(ctx) {
|
|
645
|
+
const prefix = prefixFor(ctx, "delete/many");
|
|
646
|
+
const keys = keysBelow(prefix, 30);
|
|
647
|
+
await putEach(ctx, keys, patternOf(8));
|
|
648
|
+
assertAccepted(await ctx.storage.delete(...keys), keys.length, "Deleting 30 keys in one call");
|
|
649
|
+
await assertNothingBelow(ctx, prefix);
|
|
650
|
+
}
|
|
651
|
+
},
|
|
652
|
+
{
|
|
653
|
+
name: "delete/absent-key-succeeds",
|
|
654
|
+
requires: [],
|
|
655
|
+
cost: "fast",
|
|
656
|
+
async run(ctx) {
|
|
657
|
+
const key = keyFor(ctx, "delete/absent-key-succeeds", "absent");
|
|
658
|
+
assertAccepted(await ctx.storage.delete(key), 1, "Deleting an absent key");
|
|
659
|
+
}
|
|
660
|
+
},
|
|
661
|
+
{
|
|
662
|
+
name: "delete/nothing",
|
|
663
|
+
requires: [],
|
|
664
|
+
cost: "fast",
|
|
665
|
+
async run(ctx) {
|
|
666
|
+
assertAccepted(await ctx.storage.delete(), 0, "Deleting no key at all");
|
|
667
|
+
}
|
|
668
|
+
},
|
|
669
|
+
{
|
|
670
|
+
name: "delete/invalid-key-reported",
|
|
671
|
+
requires: [],
|
|
672
|
+
cost: "fast",
|
|
673
|
+
async run(ctx) {
|
|
674
|
+
const prefix = prefixFor(ctx, "delete/invalid-key-reported");
|
|
675
|
+
const one = `${prefix}one`;
|
|
676
|
+
const two = `${prefix}two`;
|
|
677
|
+
const invalid = `${prefix}../object`;
|
|
678
|
+
await putEach(ctx, [one, two], patternOf(8));
|
|
679
|
+
const report = await ctx.storage.delete(one, invalid, two);
|
|
680
|
+
assert(report.requested === 3, `Deleting three keys reports \`requested: ${report.requested}\``);
|
|
681
|
+
assert(report.failed.length === 1, `The report holds ${report.failed.length} failures for the one invalid key among three`);
|
|
682
|
+
const failure = report.failed[0];
|
|
683
|
+
assert(failure?.code === "InvalidKey", `The report holds the failure as \`${failure?.code}\` and not as \`InvalidKey\``);
|
|
684
|
+
assert(failure.key === invalid, `The failure names the key ${JSON.stringify(failure.key)} and not ${JSON.stringify(invalid)}`);
|
|
685
|
+
await assertNothingBelow(ctx, prefix);
|
|
686
|
+
}
|
|
687
|
+
},
|
|
688
|
+
{
|
|
689
|
+
name: "delete/past-one-thousand",
|
|
690
|
+
requires: [],
|
|
691
|
+
cost: "slow",
|
|
692
|
+
async run(ctx) {
|
|
693
|
+
const prefix = prefixFor(ctx, "delete/past-one-thousand");
|
|
694
|
+
const keys = keysBelow(prefix, pastOneThousand);
|
|
695
|
+
await putEach(ctx, keys, patternOf(8));
|
|
696
|
+
assertAccepted(await ctx.storage.delete(...keys), keys.length, `Deleting ${pastOneThousand} keys in one call`);
|
|
697
|
+
await assertNothingBelow(ctx, prefix);
|
|
698
|
+
}
|
|
699
|
+
},
|
|
700
|
+
{
|
|
701
|
+
name: "deleteAll/below-prefix",
|
|
702
|
+
requires: [],
|
|
703
|
+
cost: "fast",
|
|
704
|
+
async run(ctx) {
|
|
705
|
+
const prefix = prefixFor(ctx, "deleteAll/below-prefix");
|
|
706
|
+
const below = `${prefix}below/`;
|
|
707
|
+
const keys = keysBelow(below, 5);
|
|
708
|
+
const beside = `${prefix}beside`;
|
|
709
|
+
await putEach(ctx, [...keys, beside], patternOf(8));
|
|
710
|
+
assertAccepted(await ctx.storage.deleteAll(below), keys.length, "Deleting below a prefix");
|
|
711
|
+
await assertNothingBelow(ctx, below);
|
|
712
|
+
assert(await ctx.storage.exists(beside), "`deleteAll` deleted the object beside the prefix as well");
|
|
713
|
+
}
|
|
714
|
+
},
|
|
715
|
+
{
|
|
716
|
+
name: "deleteAll/nothing",
|
|
717
|
+
requires: [],
|
|
718
|
+
cost: "fast",
|
|
719
|
+
async run(ctx) {
|
|
720
|
+
const prefix = prefixFor(ctx, "deleteAll/nothing");
|
|
721
|
+
assertAccepted(await ctx.storage.deleteAll(prefix), 0, "Deleting below an empty prefix");
|
|
722
|
+
}
|
|
723
|
+
},
|
|
724
|
+
{
|
|
725
|
+
name: "deleteAll/past-one-thousand",
|
|
726
|
+
requires: [],
|
|
727
|
+
cost: "slow",
|
|
728
|
+
async run(ctx) {
|
|
729
|
+
const prefix = prefixFor(ctx, "deleteAll/past-one-thousand");
|
|
730
|
+
const keys = keysBelow(prefix, pastOneThousand);
|
|
731
|
+
await putEach(ctx, keys, patternOf(8));
|
|
732
|
+
assertAccepted(await ctx.storage.deleteAll(prefix), keys.length, `Deleting ${pastOneThousand} objects below a prefix in one call`);
|
|
733
|
+
await assertNothingBelow(ctx, prefix);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
];
|
|
737
|
+
//#endregion
|
|
738
|
+
//#region src/cases/errors.ts
|
|
739
|
+
/**
|
|
740
|
+
* The closed set of spec 4.10, which the core publishes as a type and not as a list the
|
|
741
|
+
* way it publishes `capabilityNames`. A code outside it is one no caller can branch on.
|
|
742
|
+
*/
|
|
743
|
+
const storageErrorCodes = /* @__PURE__ */ new Set([
|
|
744
|
+
"NotFound",
|
|
745
|
+
"AccessDenied",
|
|
746
|
+
"InvalidCredentials",
|
|
747
|
+
"Expired",
|
|
748
|
+
"InvalidRequest",
|
|
749
|
+
"NetworkError",
|
|
750
|
+
"ProviderError",
|
|
751
|
+
"InvalidKey",
|
|
752
|
+
"InvalidOption",
|
|
753
|
+
"Unsupported"
|
|
754
|
+
]);
|
|
755
|
+
const errorCases = [
|
|
756
|
+
{
|
|
757
|
+
name: "errors/shape",
|
|
758
|
+
requires: [],
|
|
759
|
+
cost: "fast",
|
|
760
|
+
async run(ctx) {
|
|
761
|
+
const prefix = prefixFor(ctx, "errors/shape");
|
|
762
|
+
const key = `${prefix}object`;
|
|
763
|
+
await ctx.storage.put(key, patternOf(16));
|
|
764
|
+
for (const failure of provokedFailures(ctx, prefix, key)) assertShape(await expectAnyStorageError(async () => await failure.provoke(), failure.what), ctx.storage, failure);
|
|
765
|
+
}
|
|
766
|
+
},
|
|
767
|
+
{
|
|
768
|
+
name: "errors/not-a-storage-error",
|
|
769
|
+
requires: [],
|
|
770
|
+
cost: "fast",
|
|
771
|
+
async run(ctx) {
|
|
772
|
+
const prefix = prefixFor(ctx, "errors/not-a-storage-error");
|
|
773
|
+
const key = `${prefix}object.txt`;
|
|
774
|
+
await ctx.storage.put(key, "no JSON in here", { contentType: "text/plain" });
|
|
775
|
+
await expectRuntimeError(() => ctx.storage.put(`${prefix}aborted`, patternOf(16), { signal: AbortSignal.abort() }), "AbortError");
|
|
776
|
+
await expectRuntimeError(async () => await (await ctx.storage.get(key)).json(), "SyntaxError");
|
|
777
|
+
}
|
|
778
|
+
},
|
|
779
|
+
{
|
|
780
|
+
name: "errors/bad-credentials",
|
|
781
|
+
requires: [],
|
|
782
|
+
cost: "fast",
|
|
783
|
+
factory: "createStorageWithBadCredentials",
|
|
784
|
+
async run(ctx) {
|
|
785
|
+
const prefix = prefixFor(ctx, "errors/bad-credentials");
|
|
786
|
+
const storage = await storageFrom(ctx, "createStorageWithBadCredentials");
|
|
787
|
+
const refused = await expectStorageError(() => storage.get(`${prefix}object`), {
|
|
788
|
+
code: "InvalidCredentials",
|
|
789
|
+
retryable: false
|
|
790
|
+
});
|
|
791
|
+
assert(refused.attempts === 1 || refused.attempts === 2, `Expected \`InvalidCredentials\` from \`get\`, and the error carries \`attempts: ${refused.attempts}\` rather than 1 or 2`);
|
|
792
|
+
await expectAnyStorageError(() => storage.exists(`${prefix}object`), "`exists` under a credential the provider refuses");
|
|
793
|
+
await expectAnyStorageError(() => storage.list({ prefix }).page(), "`list` under a credential the provider refuses");
|
|
794
|
+
}
|
|
795
|
+
},
|
|
796
|
+
{
|
|
797
|
+
name: "errors/denied-credentials",
|
|
798
|
+
requires: [],
|
|
799
|
+
cost: "fast",
|
|
800
|
+
factory: "createStorageWithDeniedCredentials",
|
|
801
|
+
async run(ctx) {
|
|
802
|
+
const storage = await storageFrom(ctx, "createStorageWithDeniedCredentials");
|
|
803
|
+
const key = keyFor(ctx, "errors/denied-credentials");
|
|
804
|
+
await expectStorageError(() => storage.put(key, patternOf(16)), {
|
|
805
|
+
code: "AccessDenied",
|
|
806
|
+
retryable: false,
|
|
807
|
+
attempts: 1
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
},
|
|
811
|
+
{
|
|
812
|
+
name: "errors/expired-credentials",
|
|
813
|
+
requires: [],
|
|
814
|
+
cost: "slow",
|
|
815
|
+
factory: "createStorageWithExpiredCredentials",
|
|
816
|
+
async run(ctx) {
|
|
817
|
+
const storage = await storageFrom(ctx, "createStorageWithExpiredCredentials");
|
|
818
|
+
const key = keyFor(ctx, "errors/expired-credentials");
|
|
819
|
+
await expectStorageError(() => storage.get(key), {
|
|
820
|
+
code: "Expired",
|
|
821
|
+
attempts: 2
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
];
|
|
826
|
+
/**
|
|
827
|
+
* One failure per code the parity core reaches without a capability or a credential of
|
|
828
|
+
* its own, across the operations that report them, which is what `errors/shape` reads.
|
|
829
|
+
*/
|
|
830
|
+
function provokedFailures(ctx, prefix, key) {
|
|
831
|
+
return [
|
|
832
|
+
{
|
|
833
|
+
what: "`get` on a missing key",
|
|
834
|
+
operation: "get",
|
|
835
|
+
provoke: async () => await ctx.storage.get(`${prefix}absent`)
|
|
836
|
+
},
|
|
837
|
+
{
|
|
838
|
+
what: "`stat` on a missing key",
|
|
839
|
+
operation: "stat",
|
|
840
|
+
provoke: async () => await ctx.storage.stat(`${prefix}absent`)
|
|
841
|
+
},
|
|
842
|
+
{
|
|
843
|
+
what: "`put` under a key holding a `..` segment",
|
|
844
|
+
operation: "put",
|
|
845
|
+
provoke: async () => await ctx.storage.put(`${prefix}../object`, patternOf(16))
|
|
846
|
+
},
|
|
847
|
+
{
|
|
848
|
+
what: "`list` with a `pageSize` of 0",
|
|
849
|
+
operation: "list",
|
|
850
|
+
provoke: async () => await ctx.storage.list({
|
|
851
|
+
prefix,
|
|
852
|
+
pageSize: 0
|
|
853
|
+
}).page()
|
|
854
|
+
},
|
|
855
|
+
{
|
|
856
|
+
what: "`copy` onto itself",
|
|
857
|
+
operation: "copy",
|
|
858
|
+
provoke: async () => await ctx.storage.copy(key, key)
|
|
859
|
+
}
|
|
860
|
+
];
|
|
861
|
+
}
|
|
862
|
+
function assertShape(thrown, storage, failure) {
|
|
863
|
+
const code = thrown.code;
|
|
864
|
+
assert(storageErrorCodes.has(code), `The error for ${failure.what} carries the code ${JSON.stringify(code)}, which is none of spec 4.10`);
|
|
865
|
+
assertField(thrown, "operation", failure.operation, failure);
|
|
866
|
+
assertField(thrown, "bucket", storage.bucket, failure);
|
|
867
|
+
assertField(thrown, "provider", storage.provider, failure);
|
|
868
|
+
const retryable = thrown.retryable;
|
|
869
|
+
const attempts = thrown.attempts;
|
|
870
|
+
assert(typeof retryable === "boolean", `The error for ${failure.what} reports \`retryable: ${JSON.stringify(retryable)}\`, which is no boolean`);
|
|
871
|
+
assert(typeof attempts === "number" && Number.isInteger(attempts) && attempts >= 0, `The error for ${failure.what} reports \`attempts: ${JSON.stringify(attempts)}\`, which is no count`);
|
|
872
|
+
}
|
|
873
|
+
function assertField(thrown, field, expected, failure) {
|
|
874
|
+
const held = thrown[field];
|
|
875
|
+
assert(held === expected, `The error for ${failure.what} reports \`${field}: ${JSON.stringify(held)}\` and not ${JSON.stringify(expected)}`);
|
|
876
|
+
}
|
|
877
|
+
/** The storage of a credential factory, which spec 9.2 has kept the case out of a run without. */
|
|
878
|
+
async function storageFrom(ctx, factory) {
|
|
879
|
+
const create = ctx.target[factory];
|
|
880
|
+
assert(create !== void 0, `The target supplies no \`${factory}\``);
|
|
881
|
+
return await create.call(ctx.target);
|
|
882
|
+
}
|
|
883
|
+
//#endregion
|
|
884
|
+
//#region src/cases/exists.ts
|
|
885
|
+
const existsCases = [{
|
|
886
|
+
name: "exists/answers",
|
|
887
|
+
requires: [],
|
|
888
|
+
cost: "fast",
|
|
889
|
+
async run(ctx) {
|
|
890
|
+
const stored = keyFor(ctx, "exists/answers", "object");
|
|
891
|
+
const absent = keyFor(ctx, "exists/answers", "absent");
|
|
892
|
+
const trailingSlash = keyFor(ctx, "exists/answers", "absent/");
|
|
893
|
+
await ctx.storage.put(stored, patternOf(16));
|
|
894
|
+
assert(await ctx.storage.exists(stored), "`exists` answers false for a stored key");
|
|
895
|
+
assert(!await ctx.storage.exists(absent), "`exists` answers true for an absent key");
|
|
896
|
+
assert(!await ctx.storage.exists(trailingSlash), "`exists` answers true for an absent key ending in a slash");
|
|
897
|
+
}
|
|
898
|
+
}, {
|
|
899
|
+
name: "exists/invalid-key",
|
|
900
|
+
requires: [],
|
|
901
|
+
cost: "fast",
|
|
902
|
+
async run(ctx) {
|
|
903
|
+
const key = `${prefixFor(ctx, "exists/invalid-key")}../object`;
|
|
904
|
+
await expectStorageError(() => ctx.storage.exists(key), { code: "InvalidKey" });
|
|
905
|
+
}
|
|
906
|
+
}];
|
|
907
|
+
//#endregion
|
|
908
|
+
//#region src/cases/presign.ts
|
|
909
|
+
const utf8$4 = new TextEncoder();
|
|
910
|
+
/** Past the second `presign/expired-url` signs for, which is what spec 9.5 waits out. */
|
|
911
|
+
const pastTheLifetime = 2e3;
|
|
912
|
+
/** The seconds spec 7.10 allows `expiresIn`, which the two refused values sit outside. */
|
|
913
|
+
const refusedLifetimes = [0, 604801];
|
|
914
|
+
const presignNames = ["presignGet", "presignPut"];
|
|
915
|
+
const presignCases = [
|
|
916
|
+
{
|
|
917
|
+
name: "presign/get",
|
|
918
|
+
requires: ["presignedUrls"],
|
|
919
|
+
cost: "fast",
|
|
920
|
+
async run(ctx) {
|
|
921
|
+
const key = keyFor(ctx, "presign/get", "object.txt");
|
|
922
|
+
const body = "the body a signed `GET` hands out";
|
|
923
|
+
await ctx.storage.put(key, body, { contentType: textContentType });
|
|
924
|
+
const response = await fetch(await presignedGet(ctx, key, { expiresIn: 300 }));
|
|
925
|
+
assertStatus(response.status, 200, "`fetch` on a signed `GET`");
|
|
926
|
+
assertHeader(response, "content-type", textContentType);
|
|
927
|
+
assertSameBytes(new Uint8Array(await response.arrayBuffer()), utf8$4.encode(body), "the body a signed `GET` answered with");
|
|
928
|
+
},
|
|
929
|
+
runWithout: assertNeitherMethod
|
|
930
|
+
},
|
|
931
|
+
{
|
|
932
|
+
name: "presign/put",
|
|
933
|
+
requires: ["presignedUrls"],
|
|
934
|
+
cost: "fast",
|
|
935
|
+
async run(ctx) {
|
|
936
|
+
const key = keyFor(ctx, "presign/put", "object.txt");
|
|
937
|
+
const body = utf8$4.encode("the body a signed `PUT` takes");
|
|
938
|
+
const { url, headers } = await presignedPut(ctx, key, {
|
|
939
|
+
expiresIn: 300,
|
|
940
|
+
contentType: textContentType,
|
|
941
|
+
contentLength: body.byteLength
|
|
942
|
+
});
|
|
943
|
+
const response = await fetch(url, {
|
|
944
|
+
method: "PUT",
|
|
945
|
+
body,
|
|
946
|
+
headers
|
|
947
|
+
});
|
|
948
|
+
assert(response.ok, `\`fetch\` on a signed \`PUT\` answered ${response.status} and not a success`);
|
|
949
|
+
await response.arrayBuffer();
|
|
950
|
+
const described = await ctx.storage.stat(key);
|
|
951
|
+
assert(described.contentType === textContentType, `\`stat\` reports the content type ${JSON.stringify(described.contentType)} for what a signed \`PUT\` wrote`);
|
|
952
|
+
assert(described.size === body.byteLength, `\`stat\` reports ${described.size} bytes for the ${body.byteLength} a signed \`PUT\` wrote`);
|
|
953
|
+
},
|
|
954
|
+
runWithout: assertNeitherMethod
|
|
955
|
+
},
|
|
956
|
+
{
|
|
957
|
+
name: "presign/expires-in-bounds",
|
|
958
|
+
requires: ["presignedUrls"],
|
|
959
|
+
cost: "fast",
|
|
960
|
+
async run(ctx) {
|
|
961
|
+
const key = keyFor(ctx, "presign/expires-in-bounds", "object.txt");
|
|
962
|
+
for (const name of presignNames) for (const expiresIn of refusedLifetimes) await expectStorageError(async () => await presignerOf(ctx.storage, name)(key, lifetimeOptions(name, expiresIn)), { code: "InvalidOption" }, `\`${name}\` with an \`expiresIn\` of ${expiresIn}`);
|
|
963
|
+
assert(!await ctx.storage.exists(key), "A signature the lifetime was refused for wrote the object after all");
|
|
964
|
+
},
|
|
965
|
+
runWithout: assertNeitherMethod
|
|
966
|
+
},
|
|
967
|
+
{
|
|
968
|
+
name: "presign/put-rejects-type",
|
|
969
|
+
requires: ["presignedUrls"],
|
|
970
|
+
cost: "slow",
|
|
971
|
+
async run(ctx) {
|
|
972
|
+
const key = keyFor(ctx, "presign/put-rejects-type", "object.txt");
|
|
973
|
+
const body = utf8$4.encode("a body of another type than the signature binds");
|
|
974
|
+
const presigned = await presignedPut(ctx, key, {
|
|
975
|
+
expiresIn: 300,
|
|
976
|
+
contentType: textContentType,
|
|
977
|
+
contentLength: body.byteLength
|
|
978
|
+
});
|
|
979
|
+
const headers = new Headers(presigned.headers);
|
|
980
|
+
headers.set("content-type", "application/json");
|
|
981
|
+
assertStatus(await statusOf(await fetch(presigned.url, {
|
|
982
|
+
method: "PUT",
|
|
983
|
+
body,
|
|
984
|
+
headers
|
|
985
|
+
})), 403, "a signed `PUT` carrying another content type");
|
|
986
|
+
},
|
|
987
|
+
runWithout: assertNeitherMethod
|
|
988
|
+
},
|
|
989
|
+
{
|
|
990
|
+
name: "presign/put-rejects-length",
|
|
991
|
+
requires: ["presignedUrls"],
|
|
992
|
+
cost: "slow",
|
|
993
|
+
async run(ctx) {
|
|
994
|
+
const key = keyFor(ctx, "presign/put-rejects-length", "object.txt");
|
|
995
|
+
const body = utf8$4.encode("a body longer than the signature binds");
|
|
996
|
+
const { url, headers } = await presignedPut(ctx, key, {
|
|
997
|
+
expiresIn: 300,
|
|
998
|
+
contentType: textContentType,
|
|
999
|
+
contentLength: body.byteLength - 1
|
|
1000
|
+
});
|
|
1001
|
+
const status = await statusOf(await fetch(url, {
|
|
1002
|
+
method: "PUT",
|
|
1003
|
+
body,
|
|
1004
|
+
headers
|
|
1005
|
+
}));
|
|
1006
|
+
assert(status >= 400 && status < 500, `A signed \`PUT\` carrying another length was answered ${status} and not refused`);
|
|
1007
|
+
},
|
|
1008
|
+
runWithout: assertNeitherMethod
|
|
1009
|
+
},
|
|
1010
|
+
{
|
|
1011
|
+
name: "presign/expired-url",
|
|
1012
|
+
requires: ["presignedUrls"],
|
|
1013
|
+
cost: "slow",
|
|
1014
|
+
async run(ctx) {
|
|
1015
|
+
const key = keyFor(ctx, "presign/expired-url", "object.txt");
|
|
1016
|
+
await ctx.storage.put(key, "the body the URL stops handing out", { contentType: textContentType });
|
|
1017
|
+
const url = await presignedGet(ctx, key, { expiresIn: 1 });
|
|
1018
|
+
await delay(pastTheLifetime);
|
|
1019
|
+
assertStatus(await statusOf(await fetch(url)), 403, "a signed `GET` that has expired");
|
|
1020
|
+
},
|
|
1021
|
+
runWithout: assertNeitherMethod
|
|
1022
|
+
}
|
|
1023
|
+
];
|
|
1024
|
+
/**
|
|
1025
|
+
* What every `runWithout` half of this file asserts: spec 4.9 keeps both methods off a
|
|
1026
|
+
* storage that declares no `presignedUrls`, so there is no call that could fail with
|
|
1027
|
+
* `Unsupported` and the absence itself is what the half reads.
|
|
1028
|
+
*/
|
|
1029
|
+
async function assertNeitherMethod(ctx) {
|
|
1030
|
+
for (const name of presignNames) assert(!(name in ctx.storage), `The storage declares no \`presignedUrls\` and carries \`${name}\``);
|
|
1031
|
+
}
|
|
1032
|
+
/** The URL `presignGet` handed back, which a target may report as anything at runtime. */
|
|
1033
|
+
async function presignedGet(ctx, key, options) {
|
|
1034
|
+
const url = await presignerOf(ctx.storage, "presignGet")(key, options);
|
|
1035
|
+
assert(isUrl(url), `\`presignGet\` handed back ${JSON.stringify(url)} and not a URL`);
|
|
1036
|
+
return url;
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* What `presignPut` handed back, which a target may report as anything at runtime. The
|
|
1040
|
+
* upload sends its headers as they are, so the suite names no provider (spec 4.13).
|
|
1041
|
+
*/
|
|
1042
|
+
async function presignedPut(ctx, key, options) {
|
|
1043
|
+
const presigned = await presignerOf(ctx.storage, "presignPut")(key, options);
|
|
1044
|
+
assert(isPresignedPut(presigned), `\`presignPut\` handed back ${JSON.stringify(presigned)} and not a URL with the headers that go beside the body`);
|
|
1045
|
+
return presigned;
|
|
1046
|
+
}
|
|
1047
|
+
function isUrl(value) {
|
|
1048
|
+
return typeof value === "string" && value !== "";
|
|
1049
|
+
}
|
|
1050
|
+
/** The shape spec 4.13 gives `PresignedPut`, its rule on `Content-Length` included. */
|
|
1051
|
+
function isPresignedPut(value) {
|
|
1052
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1053
|
+
const url = Reflect.get(value, "url");
|
|
1054
|
+
const headers = Reflect.get(value, "headers");
|
|
1055
|
+
if (!isUrl(url) || !isPlainObject(headers)) return false;
|
|
1056
|
+
return Object.entries(headers).every(([name, field]) => typeof field === "string" && name.toLowerCase() !== "content-length");
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* An array or a `Headers` would pass the entries check above: the one lists its indices,
|
|
1060
|
+
* the other lists nothing at all.
|
|
1061
|
+
*/
|
|
1062
|
+
function isPlainObject(value) {
|
|
1063
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1064
|
+
const prototype = Object.getPrototypeOf(value);
|
|
1065
|
+
return prototype === Object.prototype || prototype === null;
|
|
1066
|
+
}
|
|
1067
|
+
function presignerOf(storage, name) {
|
|
1068
|
+
const held = Reflect.get(storage, name);
|
|
1069
|
+
assert(typeof held === "function", `The storage declares \`presignedUrls\` and carries no \`${name}\``);
|
|
1070
|
+
return held.bind(storage);
|
|
1071
|
+
}
|
|
1072
|
+
/** The options each method takes beside the lifetime the case is about (spec 7.10). */
|
|
1073
|
+
function lifetimeOptions(name, expiresIn) {
|
|
1074
|
+
if (name === "presignGet") return { expiresIn };
|
|
1075
|
+
return {
|
|
1076
|
+
expiresIn,
|
|
1077
|
+
contentType: textContentType,
|
|
1078
|
+
contentLength: 0
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
function assertStatus(status, expected, what) {
|
|
1082
|
+
assert(status === expected, `${what} was answered ${status} and not ${expected}`);
|
|
1083
|
+
}
|
|
1084
|
+
/** The status, with the body read, so that the runtime is free to close the connection. */
|
|
1085
|
+
async function statusOf(response) {
|
|
1086
|
+
await response.arrayBuffer();
|
|
1087
|
+
return response.status;
|
|
1088
|
+
}
|
|
1089
|
+
async function delay(milliseconds) {
|
|
1090
|
+
await new Promise((resolve) => void setTimeout(resolve, milliseconds));
|
|
1091
|
+
}
|
|
1092
|
+
//#endregion
|
|
1093
|
+
//#region src/cases/flow.ts
|
|
1094
|
+
const utf8$3 = new TextEncoder();
|
|
1095
|
+
/** Small enough to read whole in an assertion, and long enough to hold a range within it. */
|
|
1096
|
+
const rangedSize$1 = 1024;
|
|
1097
|
+
/** The page size of the row, which spec 4.6 bounds the objects of one page with. */
|
|
1098
|
+
const pageSize = 5;
|
|
1099
|
+
/** The tree of flow 3: seven objects, three of them one level below the listed one. */
|
|
1100
|
+
const fileBrowserTree = [
|
|
1101
|
+
"images/logo.txt",
|
|
1102
|
+
"notes/monday.txt",
|
|
1103
|
+
"reports/2026.txt",
|
|
1104
|
+
"index.txt",
|
|
1105
|
+
"readme.txt",
|
|
1106
|
+
"settings.txt",
|
|
1107
|
+
"wireframe.txt"
|
|
1108
|
+
];
|
|
1109
|
+
/** The objects flow 5 moves, below the source prefix it walks. */
|
|
1110
|
+
const movedObjects = [
|
|
1111
|
+
"one.txt",
|
|
1112
|
+
"notes/two.txt",
|
|
1113
|
+
"notes/deep/three.txt"
|
|
1114
|
+
];
|
|
1115
|
+
const flowCases = [
|
|
1116
|
+
{
|
|
1117
|
+
name: "flow/1-large-upload",
|
|
1118
|
+
requires: [],
|
|
1119
|
+
cost: "fast",
|
|
1120
|
+
async run(ctx) {
|
|
1121
|
+
const prefix = prefixFor(ctx, "flow/1-large-upload");
|
|
1122
|
+
const key = `${prefix}upload.txt`;
|
|
1123
|
+
const bytes = patternOf(multipartSize);
|
|
1124
|
+
await ctx.storage.put(key, streamOf(bytes, mebibyte), { contentType: textContentType });
|
|
1125
|
+
const described = await ctx.storage.stat(key);
|
|
1126
|
+
assert(described.contentType === textContentType, `\`stat\` reports the content type ${JSON.stringify(described.contentType)} for the upload`);
|
|
1127
|
+
assertSameBytes(await collect((await ctx.storage.get(key)).stream()), bytes, "the body the upload left behind");
|
|
1128
|
+
const aborted = `${prefix}aborted.txt`;
|
|
1129
|
+
const controller = new AbortController();
|
|
1130
|
+
await expectRuntimeError(() => ctx.storage.put(aborted, streamAbortedMidway(bytes, mebibyte, controller), {
|
|
1131
|
+
contentType: textContentType,
|
|
1132
|
+
signal: controller.signal
|
|
1133
|
+
}), "AbortError");
|
|
1134
|
+
assert(!await ctx.storage.exists(aborted), "The key of an aborted upload holds an object");
|
|
1135
|
+
}
|
|
1136
|
+
},
|
|
1137
|
+
{
|
|
1138
|
+
name: "flow/2-presigned-put",
|
|
1139
|
+
requires: ["presignedUrls"],
|
|
1140
|
+
cost: "fast",
|
|
1141
|
+
async run(ctx) {
|
|
1142
|
+
const key = keyFor(ctx, "flow/2-presigned-put", "upload.txt");
|
|
1143
|
+
const body = utf8$3.encode("the body a browser uploads to the provider");
|
|
1144
|
+
const { url, headers } = await presignedPut(ctx, key, {
|
|
1145
|
+
expiresIn: 300,
|
|
1146
|
+
contentType: textContentType,
|
|
1147
|
+
contentLength: body.byteLength
|
|
1148
|
+
});
|
|
1149
|
+
const response = await fetch(url, {
|
|
1150
|
+
method: "PUT",
|
|
1151
|
+
body,
|
|
1152
|
+
headers
|
|
1153
|
+
});
|
|
1154
|
+
assert(response.ok, `The upload to the presigned URL was answered ${response.status}`);
|
|
1155
|
+
await response.arrayBuffer();
|
|
1156
|
+
const described = await ctx.storage.stat(key);
|
|
1157
|
+
assert(described.contentType === textContentType, `\`stat\` reports the content type ${JSON.stringify(described.contentType)} for the upload`);
|
|
1158
|
+
assert(described.size === body.byteLength, `\`stat\` reports ${described.size} bytes for the ${body.byteLength} that were uploaded`);
|
|
1159
|
+
},
|
|
1160
|
+
runWithout: assertNeitherMethod
|
|
1161
|
+
},
|
|
1162
|
+
{
|
|
1163
|
+
name: "flow/3-file-browser",
|
|
1164
|
+
requires: [],
|
|
1165
|
+
cost: "fast",
|
|
1166
|
+
async run(ctx) {
|
|
1167
|
+
const prefix = prefixFor(ctx, "flow/3-file-browser");
|
|
1168
|
+
await Promise.all(fileBrowserTree.map(async (name) => await ctx.storage.put(`${prefix}${name}`, name)));
|
|
1169
|
+
const page = await ctx.storage.list({
|
|
1170
|
+
prefix,
|
|
1171
|
+
delimiter: "/",
|
|
1172
|
+
pageSize
|
|
1173
|
+
}).page();
|
|
1174
|
+
assertPageSize(page.objects.length, "The first page");
|
|
1175
|
+
assert(page.cursor !== void 0, `The first page of ${fileBrowserTree.length} entries carries no cursor`);
|
|
1176
|
+
const rest = await ctx.storage.list({
|
|
1177
|
+
prefix,
|
|
1178
|
+
delimiter: "/",
|
|
1179
|
+
pageSize,
|
|
1180
|
+
cursor: page.cursor
|
|
1181
|
+
}).page();
|
|
1182
|
+
assertPageSize(rest.objects.length, "The page the cursor continued with");
|
|
1183
|
+
assert(rest.cursor === void 0, "The second page of the level carries a cursor");
|
|
1184
|
+
assertNamesEachOnce([...page.objects, ...rest.objects].map((entry) => entry.key), levelOf(prefix), "The two pages of the level");
|
|
1185
|
+
assertNamesEachOnce([...page.prefixes, ...rest.prefixes], pseudoDirectoriesOf(prefix), "The pseudo-directories of the level");
|
|
1186
|
+
}
|
|
1187
|
+
},
|
|
1188
|
+
{
|
|
1189
|
+
name: "flow/4-streaming-download",
|
|
1190
|
+
requires: ["rangeReads"],
|
|
1191
|
+
cost: "fast",
|
|
1192
|
+
async run(ctx) {
|
|
1193
|
+
const key = keyFor(ctx, "flow/4-streaming-download", "object.txt");
|
|
1194
|
+
const bytes = patternOf(rangedSize$1);
|
|
1195
|
+
await ctx.storage.put(key, bytes, { contentType: textContentType });
|
|
1196
|
+
const stored = await ctx.storage.get(key, { range: {
|
|
1197
|
+
start: 100,
|
|
1198
|
+
end: 199
|
|
1199
|
+
} });
|
|
1200
|
+
const answer = answerWith(stored.stream(), stored.stat.contentType);
|
|
1201
|
+
assertHeader(answer, "content-type", textContentType);
|
|
1202
|
+
assertSameBytes(new Uint8Array(await answer.arrayBuffer()), bytes.subarray(100, 200), "the range the answer carried");
|
|
1203
|
+
},
|
|
1204
|
+
async runWithout(ctx) {
|
|
1205
|
+
const key = keyFor(ctx, "flow/4-streaming-download", "object.txt");
|
|
1206
|
+
const bytes = patternOf(rangedSize$1);
|
|
1207
|
+
await ctx.storage.put(key, bytes, { contentType: textContentType });
|
|
1208
|
+
const stored = await ctx.storage.get(key);
|
|
1209
|
+
const answer = answerWith(stored.stream(), stored.stat.contentType);
|
|
1210
|
+
assertHeader(answer, "content-type", textContentType);
|
|
1211
|
+
assertSameBytes(new Uint8Array(await answer.arrayBuffer()), bytes, "the object the answer carried whole");
|
|
1212
|
+
await expectUnsupported(() => ctx.storage.get(key, { range: {
|
|
1213
|
+
start: 100,
|
|
1214
|
+
end: 199
|
|
1215
|
+
} }), "rangeReads");
|
|
1216
|
+
}
|
|
1217
|
+
},
|
|
1218
|
+
{
|
|
1219
|
+
name: "flow/5-prefix-move",
|
|
1220
|
+
requires: [],
|
|
1221
|
+
cost: "fast",
|
|
1222
|
+
async run(ctx) {
|
|
1223
|
+
const prefix = prefixFor(ctx, "flow/5-prefix-move");
|
|
1224
|
+
const from = `${prefix}source/`;
|
|
1225
|
+
const to = `${prefix}target/`;
|
|
1226
|
+
await Promise.all(movedObjects.map(async (name) => await ctx.storage.put(`${from}${name}`, name, { contentType: textContentType })));
|
|
1227
|
+
const moved = await collectEntries(ctx.storage.list({ prefix: from }));
|
|
1228
|
+
for (const entry of moved) {
|
|
1229
|
+
const stored = await ctx.storage.get(entry.key);
|
|
1230
|
+
await ctx.storage.put(`${to}${entry.key.slice(from.length)}`, stored.stream(), { contentType: stored.stat.contentType });
|
|
1231
|
+
}
|
|
1232
|
+
const report = await ctx.storage.deleteAll(from);
|
|
1233
|
+
assert(report.requested === movedObjects.length, `\`deleteAll\` reports ${report.requested} of the ${movedObjects.length} objects that moved`);
|
|
1234
|
+
assert(report.failed.length === 0, `\`deleteAll\` reports ${report.failed.length} objects it could not delete`);
|
|
1235
|
+
const listed = await collectEntries(ctx.storage.list({ prefix: to }));
|
|
1236
|
+
assertNamesEachOnce(listed.map((entry) => entry.key), movedObjects.map((name) => `${to}${name}`), "The listing below the target prefix");
|
|
1237
|
+
await assertSameBodies(ctx, listed, to);
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
];
|
|
1241
|
+
/** The objects of the listed level, which are the keys of the tree holding no delimiter. */
|
|
1242
|
+
function levelOf(prefix) {
|
|
1243
|
+
return fileBrowserTree.filter((name) => !name.includes("/")).map((name) => `${prefix}${name}`);
|
|
1244
|
+
}
|
|
1245
|
+
function pseudoDirectoriesOf(prefix) {
|
|
1246
|
+
const directories = fileBrowserTree.filter((name) => name.includes("/")).map((name) => `${prefix}${name.slice(0, name.indexOf("/") + 1)}`);
|
|
1247
|
+
return [...new Set(directories)];
|
|
1248
|
+
}
|
|
1249
|
+
/** Spec 4.6: a page holds at most `pageSize` objects, whatever the delimiter adds to it. */
|
|
1250
|
+
function assertPageSize(held, what) {
|
|
1251
|
+
assert(held <= pageSize, `${what} holds ${held} objects, past the ${pageSize} it asked for`);
|
|
1252
|
+
}
|
|
1253
|
+
/** The answer flow 4 builds out of the stored object, which nothing reads in between. */
|
|
1254
|
+
function answerWith(body, type) {
|
|
1255
|
+
return new Response(body, { headers: { "content-type": type } });
|
|
1256
|
+
}
|
|
1257
|
+
/** That every object arrived below the target prefix as it was written, name for name. */
|
|
1258
|
+
async function assertSameBodies(ctx, listed, to) {
|
|
1259
|
+
await Promise.all(listed.map(async (entry) => {
|
|
1260
|
+
const stored = await ctx.storage.get(entry.key);
|
|
1261
|
+
assert(stored.stat.contentType === textContentType, `The moved object under ${JSON.stringify(entry.key)} reports the content type ${JSON.stringify(stored.stat.contentType)}`);
|
|
1262
|
+
assertSameBytes(await stored.bytes(), utf8$3.encode(entry.key.slice(to.length)), `The moved object under ${JSON.stringify(entry.key)}`);
|
|
1263
|
+
}));
|
|
1264
|
+
}
|
|
1265
|
+
//#endregion
|
|
1266
|
+
//#region src/cases/get.ts
|
|
1267
|
+
/** Small enough to read whole in an assertion, and long enough to hold a range within it. */
|
|
1268
|
+
const rangedSize = 1024;
|
|
1269
|
+
const getCases = [
|
|
1270
|
+
{
|
|
1271
|
+
name: "get/missing-key",
|
|
1272
|
+
requires: [],
|
|
1273
|
+
cost: "fast",
|
|
1274
|
+
async run(ctx) {
|
|
1275
|
+
const key = keyFor(ctx, "get/missing-key", "absent");
|
|
1276
|
+
await expectStorageError(() => ctx.storage.get(key), {
|
|
1277
|
+
code: "NotFound",
|
|
1278
|
+
key,
|
|
1279
|
+
operation: "get",
|
|
1280
|
+
retryable: false,
|
|
1281
|
+
attempts: 1
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
},
|
|
1285
|
+
{
|
|
1286
|
+
name: "get/stream",
|
|
1287
|
+
requires: [],
|
|
1288
|
+
cost: "fast",
|
|
1289
|
+
async run(ctx) {
|
|
1290
|
+
const key = keyFor(ctx, "get/stream");
|
|
1291
|
+
const bytes = patternOf(4096);
|
|
1292
|
+
await ctx.storage.put(key, bytes);
|
|
1293
|
+
assertSameBytes(await collect((await ctx.storage.get(key)).stream()), bytes, "the body `stream()` yielded");
|
|
1294
|
+
await (await ctx.storage.get(key)).stream().cancel();
|
|
1295
|
+
}
|
|
1296
|
+
},
|
|
1297
|
+
{
|
|
1298
|
+
name: "get/text-and-json",
|
|
1299
|
+
requires: [],
|
|
1300
|
+
cost: "fast",
|
|
1301
|
+
async run(ctx) {
|
|
1302
|
+
const key = keyFor(ctx, "get/text-and-json", "object.json");
|
|
1303
|
+
const other = keyFor(ctx, "get/text-and-json", "object.txt");
|
|
1304
|
+
const text = "{\"grüße\":\"日本語\"}";
|
|
1305
|
+
await ctx.storage.put(key, text, { contentType: "application/json" });
|
|
1306
|
+
await ctx.storage.put(other, "no JSON in here", { contentType: "text/plain" });
|
|
1307
|
+
const read = await (await ctx.storage.get(key)).text();
|
|
1308
|
+
assert(read === text, `\`text()\` decoded ${JSON.stringify(read)}`);
|
|
1309
|
+
const parsed = await (await ctx.storage.get(key)).json();
|
|
1310
|
+
assert(parsed["grüße"] === "日本語", `\`json()\` parsed ${JSON.stringify(parsed)}`);
|
|
1311
|
+
await expectRuntimeError(async () => await (await ctx.storage.get(other)).json(), "SyntaxError");
|
|
1312
|
+
}
|
|
1313
|
+
},
|
|
1314
|
+
{
|
|
1315
|
+
name: "get/body-read-once",
|
|
1316
|
+
requires: [],
|
|
1317
|
+
cost: "fast",
|
|
1318
|
+
async run(ctx) {
|
|
1319
|
+
const key = keyFor(ctx, "get/body-read-once");
|
|
1320
|
+
await ctx.storage.put(key, patternOf(32));
|
|
1321
|
+
const stored = await ctx.storage.get(key);
|
|
1322
|
+
await stored.bytes();
|
|
1323
|
+
await expectStorageError(() => stored.text(), { code: "InvalidRequest" });
|
|
1324
|
+
}
|
|
1325
|
+
},
|
|
1326
|
+
{
|
|
1327
|
+
name: "get/stat-from-response",
|
|
1328
|
+
requires: [],
|
|
1329
|
+
cost: "fast",
|
|
1330
|
+
async run(ctx) {
|
|
1331
|
+
const key = keyFor(ctx, "get/stat-from-response", "object.txt");
|
|
1332
|
+
await ctx.storage.put(key, "a body to describe", { contentType: "text/plain" });
|
|
1333
|
+
const stored = await ctx.storage.get(key);
|
|
1334
|
+
const described = await ctx.storage.stat(key);
|
|
1335
|
+
assertSameDescription(stored.stat, described, [
|
|
1336
|
+
"key",
|
|
1337
|
+
"size",
|
|
1338
|
+
"contentType",
|
|
1339
|
+
"etag"
|
|
1340
|
+
], "`get` and `stat`");
|
|
1341
|
+
}
|
|
1342
|
+
},
|
|
1343
|
+
{
|
|
1344
|
+
name: "get/addressable-keys",
|
|
1345
|
+
requires: [],
|
|
1346
|
+
cost: "fast",
|
|
1347
|
+
async run(ctx) {
|
|
1348
|
+
const prefix = prefixFor(ctx, "get/addressable-keys");
|
|
1349
|
+
const absent = [{
|
|
1350
|
+
label: "a key ending in a slash",
|
|
1351
|
+
key: `${prefix}absent/`
|
|
1352
|
+
}, {
|
|
1353
|
+
label: "a key holding a backslash",
|
|
1354
|
+
key: `${prefix}absent\\key`
|
|
1355
|
+
}];
|
|
1356
|
+
await Promise.all(absent.map(async ({ label, key }) => await expectStorageError(() => ctx.storage.get(key), { code: "NotFound" }, label)));
|
|
1357
|
+
}
|
|
1358
|
+
},
|
|
1359
|
+
{
|
|
1360
|
+
name: "get/refused-keys",
|
|
1361
|
+
requires: [],
|
|
1362
|
+
cost: "fast",
|
|
1363
|
+
async run(ctx) {
|
|
1364
|
+
const refused = refusedAddressableKeys(prefixFor(ctx, "get/refused-keys"));
|
|
1365
|
+
const reads = [
|
|
1366
|
+
["get", (key) => ctx.storage.get(key)],
|
|
1367
|
+
["stat", (key) => ctx.storage.stat(key)],
|
|
1368
|
+
["exists", (key) => ctx.storage.exists(key)]
|
|
1369
|
+
];
|
|
1370
|
+
await Promise.all(refused.flatMap(({ label, key }) => reads.map(async ([operation, read]) => await expectStorageError(() => read(key), {
|
|
1371
|
+
code: "InvalidKey",
|
|
1372
|
+
attempts: 0
|
|
1373
|
+
}, `\`${operation}\` of ${label}`))));
|
|
1374
|
+
}
|
|
1375
|
+
},
|
|
1376
|
+
{
|
|
1377
|
+
name: "get/aborted-signal",
|
|
1378
|
+
requires: [],
|
|
1379
|
+
cost: "fast",
|
|
1380
|
+
async run(ctx) {
|
|
1381
|
+
const key = keyFor(ctx, "get/aborted-signal");
|
|
1382
|
+
await ctx.storage.put(key, patternOf(16));
|
|
1383
|
+
await expectRuntimeError(() => ctx.storage.get(key, { signal: AbortSignal.abort() }), "AbortError");
|
|
1384
|
+
}
|
|
1385
|
+
},
|
|
1386
|
+
{
|
|
1387
|
+
name: "get/range",
|
|
1388
|
+
requires: ["rangeReads"],
|
|
1389
|
+
cost: "fast",
|
|
1390
|
+
async run(ctx) {
|
|
1391
|
+
const key = keyFor(ctx, "get/range");
|
|
1392
|
+
const bytes = patternOf(rangedSize);
|
|
1393
|
+
await ctx.storage.put(key, bytes);
|
|
1394
|
+
const middle = await ctx.storage.get(key, { range: {
|
|
1395
|
+
start: 100,
|
|
1396
|
+
end: 199
|
|
1397
|
+
} });
|
|
1398
|
+
assertWholeSize(middle.stat);
|
|
1399
|
+
assertSameBytes(await middle.bytes(), bytes.subarray(100, 200), "the bytes of a range");
|
|
1400
|
+
const tail = await ctx.storage.get(key, { range: { start: 1e3 } });
|
|
1401
|
+
assertWholeSize(tail.stat);
|
|
1402
|
+
assertSameBytes(await tail.bytes(), bytes.subarray(1e3), "the bytes of a range without an end");
|
|
1403
|
+
},
|
|
1404
|
+
async runWithout(ctx) {
|
|
1405
|
+
await refuseRanges(ctx, "get/range", [{
|
|
1406
|
+
start: 0,
|
|
1407
|
+
end: 15
|
|
1408
|
+
}]);
|
|
1409
|
+
}
|
|
1410
|
+
},
|
|
1411
|
+
{
|
|
1412
|
+
name: "get/range-unsatisfiable",
|
|
1413
|
+
requires: ["rangeReads"],
|
|
1414
|
+
cost: "fast",
|
|
1415
|
+
async run(ctx) {
|
|
1416
|
+
const key = keyFor(ctx, "get/range-unsatisfiable");
|
|
1417
|
+
await ctx.storage.put(key, patternOf(rangedSize));
|
|
1418
|
+
await expectStorageError(() => ctx.storage.get(key, { range: { start: rangedSize } }), { code: "InvalidRequest" }, "a range starting at the size of the object");
|
|
1419
|
+
await expectStorageError(() => ctx.storage.get(key, { range: {
|
|
1420
|
+
start: 8,
|
|
1421
|
+
end: 4
|
|
1422
|
+
} }), { code: "InvalidOption" }, "a range whose start is above its end");
|
|
1423
|
+
},
|
|
1424
|
+
async runWithout(ctx) {
|
|
1425
|
+
await refuseRanges(ctx, "get/range-unsatisfiable", [{ start: rangedSize }, {
|
|
1426
|
+
start: 8,
|
|
1427
|
+
end: 4
|
|
1428
|
+
}]);
|
|
1429
|
+
}
|
|
1430
|
+
},
|
|
1431
|
+
{
|
|
1432
|
+
name: "get/range-clipped",
|
|
1433
|
+
requires: ["rangeReads"],
|
|
1434
|
+
cost: "fast",
|
|
1435
|
+
async run(ctx) {
|
|
1436
|
+
const key = keyFor(ctx, "get/range-clipped");
|
|
1437
|
+
const bytes = patternOf(rangedSize);
|
|
1438
|
+
await ctx.storage.put(key, bytes);
|
|
1439
|
+
assertSameBytes(await (await ctx.storage.get(key, { range: {
|
|
1440
|
+
start: 1016,
|
|
1441
|
+
end: 4 * rangedSize
|
|
1442
|
+
} })).bytes(), bytes.subarray(1016), "the bytes of a range reaching beyond the object");
|
|
1443
|
+
},
|
|
1444
|
+
async runWithout(ctx) {
|
|
1445
|
+
await refuseRanges(ctx, "get/range-clipped", [{
|
|
1446
|
+
start: 1016,
|
|
1447
|
+
end: 4 * rangedSize
|
|
1448
|
+
}]);
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
];
|
|
1452
|
+
function assertWholeSize(described) {
|
|
1453
|
+
assert(described.size === rangedSize, `A ranged \`get\` reports ${described.size} bytes for an object of ${rangedSize}`);
|
|
1454
|
+
}
|
|
1455
|
+
async function refuseRanges(ctx, caseName, ranges) {
|
|
1456
|
+
const key = keyFor(ctx, caseName);
|
|
1457
|
+
await ctx.storage.put(key, patternOf(rangedSize));
|
|
1458
|
+
await Promise.all(ranges.map(async (range) => await expectUnsupported(() => ctx.storage.get(key, { range }), "rangeReads")));
|
|
1459
|
+
}
|
|
1460
|
+
//#endregion
|
|
1461
|
+
//#region src/cases/list.ts
|
|
1462
|
+
const utf8$2 = new TextEncoder();
|
|
1463
|
+
/** The page size spec 4.6 bounds a page at, which two page sizes sit on either side of. */
|
|
1464
|
+
const pageSizeLimit = 1e3;
|
|
1465
|
+
/**
|
|
1466
|
+
* One name in the two Unicode normal forms spec 4.8 leaves as two keys or as one. The
|
|
1467
|
+
* forms are built from their code points rather than written down, because two source
|
|
1468
|
+
* strings that differ in nothing a reader sees are two a tool folds into one.
|
|
1469
|
+
*/
|
|
1470
|
+
const normalForms = [{
|
|
1471
|
+
form: "NFC",
|
|
1472
|
+
name: `caf${String.fromCodePoint(233)}`
|
|
1473
|
+
}, {
|
|
1474
|
+
form: "NFD",
|
|
1475
|
+
name: `cafe${String.fromCodePoint(769)}`
|
|
1476
|
+
}];
|
|
1477
|
+
const listCases = [
|
|
1478
|
+
{
|
|
1479
|
+
name: "list/nothing",
|
|
1480
|
+
requires: [],
|
|
1481
|
+
cost: "fast",
|
|
1482
|
+
async run(ctx) {
|
|
1483
|
+
const prefix = prefixFor(ctx, "list/nothing");
|
|
1484
|
+
const iterated = await collectEntries(ctx.storage.list({ prefix }));
|
|
1485
|
+
assert(iterated.length === 0, `A listing below a prefix holding nothing yields ${iterated.length} entries`);
|
|
1486
|
+
const page = await ctx.storage.list({ prefix }).page();
|
|
1487
|
+
assert(page.objects.length === 0, `A page below a prefix holding nothing holds ${page.objects.length} objects`);
|
|
1488
|
+
assert(page.prefixes.length === 0, `A page below a prefix holding nothing holds ${page.prefixes.length} pseudo-directories`);
|
|
1489
|
+
assert(page.cursor === void 0, "A page below a prefix holding nothing carries a cursor, so the listing is not complete");
|
|
1490
|
+
}
|
|
1491
|
+
},
|
|
1492
|
+
{
|
|
1493
|
+
name: "list/every-object-once",
|
|
1494
|
+
requires: [],
|
|
1495
|
+
cost: "fast",
|
|
1496
|
+
async run(ctx) {
|
|
1497
|
+
const prefix = prefixFor(ctx, "list/every-object-once");
|
|
1498
|
+
const keys = keysBelow(prefix, 25);
|
|
1499
|
+
await putEach(ctx, keys, patternOf(8));
|
|
1500
|
+
assertNamesEachOnce((await collectEntries(ctx.storage.list({ prefix }))).map((entry) => entry.key), keys, "The iteration below the prefix");
|
|
1501
|
+
}
|
|
1502
|
+
},
|
|
1503
|
+
{
|
|
1504
|
+
name: "list/entry-shape",
|
|
1505
|
+
requires: [],
|
|
1506
|
+
cost: "fast",
|
|
1507
|
+
async run(ctx) {
|
|
1508
|
+
const prefix = prefixFor(ctx, "list/entry-shape");
|
|
1509
|
+
const sizes = new Map(keysBelow(prefix, 3).map((key, index) => [key, 16 * (index + 1)]));
|
|
1510
|
+
await Promise.all([...sizes].map(async ([key, size]) => void await ctx.storage.put(key, patternOf(size))));
|
|
1511
|
+
const iterated = await collectEntries(ctx.storage.list({ prefix }));
|
|
1512
|
+
assertNamesEachOnce(iterated.map((entry) => entry.key), [...sizes.keys()], "The iteration below the prefix");
|
|
1513
|
+
for (const entry of iterated) {
|
|
1514
|
+
const written = sizes.get(entry.key);
|
|
1515
|
+
assert(entry.size === written, `The entry for ${JSON.stringify(entry.key)} reports ${entry.size} bytes and not the ${written} that were written`);
|
|
1516
|
+
assertDate(entry.lastModified, `The last modification of ${JSON.stringify(entry.key)}`);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
},
|
|
1520
|
+
{
|
|
1521
|
+
name: "list/pages-and-cursor",
|
|
1522
|
+
requires: [],
|
|
1523
|
+
cost: "fast",
|
|
1524
|
+
async run(ctx) {
|
|
1525
|
+
const prefix = prefixFor(ctx, "list/pages-and-cursor");
|
|
1526
|
+
const keys = keysBelow(prefix, 5);
|
|
1527
|
+
await putEach(ctx, keys, patternOf(8));
|
|
1528
|
+
const held = [];
|
|
1529
|
+
const counts = [];
|
|
1530
|
+
let cursor;
|
|
1531
|
+
for (let page = 1; page <= 3; page += 1) {
|
|
1532
|
+
const read = await ctx.storage.list({
|
|
1533
|
+
prefix,
|
|
1534
|
+
pageSize: 2,
|
|
1535
|
+
cursor
|
|
1536
|
+
}).page();
|
|
1537
|
+
held.push(...read.objects.map((entry) => entry.key));
|
|
1538
|
+
counts.push(read.objects.length);
|
|
1539
|
+
cursor = read.cursor;
|
|
1540
|
+
if (page === 3) assert(cursor === void 0, "Page 3 of 3 carries a cursor, so the listing continues after its final object");
|
|
1541
|
+
else assert(cursor !== void 0, `Page ${page} of 3 carries no cursor, so the listing ends before its objects do`);
|
|
1542
|
+
}
|
|
1543
|
+
assert(counts.join(", ") === "2, 2, 1", `Pages of 2 over 5 objects hold ${counts.join(", ")} objects`);
|
|
1544
|
+
assertNamesEachOnce(held, keys, "The three pages together");
|
|
1545
|
+
}
|
|
1546
|
+
},
|
|
1547
|
+
{
|
|
1548
|
+
name: "list/delimiter",
|
|
1549
|
+
requires: [],
|
|
1550
|
+
cost: "fast",
|
|
1551
|
+
async run(ctx) {
|
|
1552
|
+
const prefix = prefixFor(ctx, "list/delimiter");
|
|
1553
|
+
const atTheLevel = [`${prefix}a.txt`, `${prefix}b.txt`];
|
|
1554
|
+
const below = [
|
|
1555
|
+
`${prefix}one/x.txt`,
|
|
1556
|
+
`${prefix}one/y.txt`,
|
|
1557
|
+
`${prefix}two/x.txt`
|
|
1558
|
+
];
|
|
1559
|
+
await putEach(ctx, [...atTheLevel, ...below], patternOf(8));
|
|
1560
|
+
const page = await ctx.storage.list({
|
|
1561
|
+
prefix,
|
|
1562
|
+
delimiter: "/"
|
|
1563
|
+
}).page();
|
|
1564
|
+
assertNamesEachOnce(page.objects.map((entry) => entry.key), atTheLevel, "The objects of a page with a delimiter");
|
|
1565
|
+
assertNamesEachOnce(page.prefixes, [`${prefix}one/`, `${prefix}two/`], "The pseudo-directories of a page with a delimiter");
|
|
1566
|
+
assertNamesEachOnce((await collectEntries(ctx.storage.list({
|
|
1567
|
+
prefix,
|
|
1568
|
+
delimiter: "/"
|
|
1569
|
+
}))).map((entry) => entry.key), atTheLevel, "The iteration with a delimiter");
|
|
1570
|
+
}
|
|
1571
|
+
},
|
|
1572
|
+
{
|
|
1573
|
+
name: "list/prefix-mid-segment",
|
|
1574
|
+
requires: [],
|
|
1575
|
+
cost: "fast",
|
|
1576
|
+
async run(ctx) {
|
|
1577
|
+
const prefix = prefixFor(ctx, "list/prefix-mid-segment");
|
|
1578
|
+
const matching = [`${prefix}report-1.txt`, `${prefix}report-2.txt`];
|
|
1579
|
+
const beside = `${prefix}receipt.txt`;
|
|
1580
|
+
await putEach(ctx, [...matching, beside], patternOf(8));
|
|
1581
|
+
assertNamesEachOnce((await collectEntries(ctx.storage.list({ prefix: `${prefix}report-` }))).map((entry) => entry.key), matching, "The iteration below a prefix ending inside a segment");
|
|
1582
|
+
}
|
|
1583
|
+
},
|
|
1584
|
+
{
|
|
1585
|
+
name: "list/lazy",
|
|
1586
|
+
requires: [],
|
|
1587
|
+
cost: "fast",
|
|
1588
|
+
async run(ctx) {
|
|
1589
|
+
const prefix = prefixFor(ctx, "list/lazy");
|
|
1590
|
+
const listing = listWithoutReading(ctx, { prefix });
|
|
1591
|
+
const key = `${prefix}written-after-the-listing-was-built`;
|
|
1592
|
+
await ctx.storage.put(key, patternOf(8));
|
|
1593
|
+
assertNamesEachOnce((await listing.page()).objects.map((entry) => entry.key), [key], "The first page of a listing built before the write");
|
|
1594
|
+
const refused = listWithoutReading(ctx, {
|
|
1595
|
+
prefix,
|
|
1596
|
+
pageSize: 0
|
|
1597
|
+
});
|
|
1598
|
+
await expectStorageError(() => refused.page(), { code: "InvalidOption" });
|
|
1599
|
+
}
|
|
1600
|
+
},
|
|
1601
|
+
{
|
|
1602
|
+
name: "list/page-size-bounds",
|
|
1603
|
+
requires: [],
|
|
1604
|
+
cost: "fast",
|
|
1605
|
+
async run(ctx) {
|
|
1606
|
+
const prefix = prefixFor(ctx, "list/page-size-bounds");
|
|
1607
|
+
await Promise.all([0, 1001].map(async (pageSize) => {
|
|
1608
|
+
assertNamesTheOption((await expectStorageError(async () => await listWithoutReading(ctx, {
|
|
1609
|
+
prefix,
|
|
1610
|
+
pageSize
|
|
1611
|
+
}).page(), { code: "InvalidOption" }, `a page size of ${pageSize}`)).message, "pageSize");
|
|
1612
|
+
}));
|
|
1613
|
+
}
|
|
1614
|
+
},
|
|
1615
|
+
{
|
|
1616
|
+
name: "list/invalid-cursor",
|
|
1617
|
+
requires: [],
|
|
1618
|
+
cost: "fast",
|
|
1619
|
+
async run(ctx) {
|
|
1620
|
+
const prefix = prefixFor(ctx, "list/invalid-cursor");
|
|
1621
|
+
const cursor = "this-is-no-cursor-the-storage-handed-out";
|
|
1622
|
+
assertNamesTheOption((await expectStorageError(async () => await listWithoutReading(ctx, {
|
|
1623
|
+
prefix,
|
|
1624
|
+
cursor
|
|
1625
|
+
}).page(), { code: "InvalidOption" })).message, "cursor");
|
|
1626
|
+
}
|
|
1627
|
+
},
|
|
1628
|
+
{
|
|
1629
|
+
name: "list/invalid-delimiter",
|
|
1630
|
+
requires: [],
|
|
1631
|
+
cost: "fast",
|
|
1632
|
+
async run(ctx) {
|
|
1633
|
+
const prefix = prefixFor(ctx, "list/invalid-delimiter");
|
|
1634
|
+
await expectStorageError(async () => await listWithoutReading(ctx, {
|
|
1635
|
+
prefix,
|
|
1636
|
+
delimiter: ""
|
|
1637
|
+
}).page(), { code: "InvalidOption" });
|
|
1638
|
+
}
|
|
1639
|
+
},
|
|
1640
|
+
{
|
|
1641
|
+
name: "list/past-one-thousand",
|
|
1642
|
+
requires: [],
|
|
1643
|
+
cost: "slow",
|
|
1644
|
+
async run(ctx) {
|
|
1645
|
+
const prefix = prefixFor(ctx, "list/past-one-thousand");
|
|
1646
|
+
const keys = keysBelow(prefix, pastOneThousand);
|
|
1647
|
+
await putEach(ctx, keys, patternOf(8));
|
|
1648
|
+
assertNamesEachOnce((await collectEntries(ctx.storage.list({ prefix }))).map((entry) => entry.key), keys, "The iteration below the prefix");
|
|
1649
|
+
const page = await ctx.storage.list({ prefix }).page();
|
|
1650
|
+
assert(page.objects.length <= pageSizeLimit, `One page at the default size holds ${page.objects.length} objects`);
|
|
1651
|
+
assert(page.cursor !== void 0, `A page of ${page.objects.length} objects out of ${pastOneThousand} carries no cursor`);
|
|
1652
|
+
}
|
|
1653
|
+
},
|
|
1654
|
+
{
|
|
1655
|
+
name: "list/key-bytes",
|
|
1656
|
+
requires: ["keyBytesPreserved"],
|
|
1657
|
+
cost: "fast",
|
|
1658
|
+
async run(ctx) {
|
|
1659
|
+
const prefix = prefixFor(ctx, "list/key-bytes");
|
|
1660
|
+
await Promise.all(normalForms.map(async ({ form, name }) => void await ctx.storage.put(`${prefix}${name}`, utf8$2.encode(form))));
|
|
1661
|
+
assertNamesEachOnce((await collectEntries(ctx.storage.list({ prefix }))).map((entry) => entry.key), normalForms.map(({ name }) => `${prefix}${name}`), "The iteration below the prefix");
|
|
1662
|
+
await Promise.all(normalForms.map(async ({ form, name }) => {
|
|
1663
|
+
assertSameBytes(await (await ctx.storage.get(`${prefix}${name}`)).bytes(), utf8$2.encode(form), `the object under the ${form} key`);
|
|
1664
|
+
}));
|
|
1665
|
+
},
|
|
1666
|
+
async runWithout(ctx) {
|
|
1667
|
+
await Promise.all(normalForms.map(async ({ form, name }) => {
|
|
1668
|
+
const prefix = `${prefixFor(ctx, "list/key-bytes")}${form}/`;
|
|
1669
|
+
const key = `${prefix}${name}`;
|
|
1670
|
+
await ctx.storage.put(key, patternOf(8));
|
|
1671
|
+
const iterated = await collectEntries(ctx.storage.list({ prefix }));
|
|
1672
|
+
const listed = iterated[0]?.key;
|
|
1673
|
+
assert(iterated.length === 1, `The listing below the ${form} prefix yields ${iterated.length} entries for the one object the case wrote`);
|
|
1674
|
+
assert(listed?.normalize() === key.normalize(), `The listing names the ${form} key as ${JSON.stringify(listed)}, which is no Unicode equivalent of ${JSON.stringify(key)}`);
|
|
1675
|
+
}));
|
|
1676
|
+
}
|
|
1677
|
+
},
|
|
1678
|
+
{
|
|
1679
|
+
name: "list/noncharacter-key",
|
|
1680
|
+
requires: [],
|
|
1681
|
+
cost: "fast",
|
|
1682
|
+
async run(ctx) {
|
|
1683
|
+
const prefix = prefixFor(ctx, "list/noncharacter-key");
|
|
1684
|
+
const key = `${prefix}noncharacter-${String.fromCodePoint(65534)}.txt`;
|
|
1685
|
+
const body = patternOf(16);
|
|
1686
|
+
await ctx.storage.put(key, body);
|
|
1687
|
+
assertNamesEachOnce((await collectEntries(ctx.storage.list({ prefix }))).map((entry) => entry.key), [key], "The iteration below the prefix");
|
|
1688
|
+
const stored = await ctx.storage.get(key);
|
|
1689
|
+
assert(stored.stat.key === key, `\`get\` describes the object as ${JSON.stringify(stored.stat.key)} and not as the key it was asked for`);
|
|
1690
|
+
assertSameBytes(await stored.bytes(), body, "the object under the key holding U+FFFE");
|
|
1691
|
+
assertAccepted(await ctx.storage.delete(key), 1, "Deleting the key holding U+FFFE");
|
|
1692
|
+
await assertNothingBelow(ctx, prefix);
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
];
|
|
1696
|
+
/**
|
|
1697
|
+
* A listing built but not read. Spec 4.6 has `list` perform no request until then, so it
|
|
1698
|
+
* reports nothing — not even an option it is going to refuse. A throw here is that
|
|
1699
|
+
* promise broken rather than the refusal the case is after, and worth saying apart.
|
|
1700
|
+
*/
|
|
1701
|
+
function listWithoutReading(ctx, options) {
|
|
1702
|
+
try {
|
|
1703
|
+
return ctx.storage.list(options);
|
|
1704
|
+
} catch (thrown) {
|
|
1705
|
+
throw new Error(`\`list\` refused ${JSON.stringify(options)} before the listing was read: ${serializeError(thrown).message}`, { cause: thrown });
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
function assertNamesTheOption(message, option) {
|
|
1709
|
+
assert(message.includes(option), `The message ${JSON.stringify(message)} does not name the option \`${option}\``);
|
|
1710
|
+
}
|
|
1711
|
+
//#endregion
|
|
1712
|
+
//#region src/cases/put.ts
|
|
1713
|
+
const utf8$1 = new TextEncoder();
|
|
1714
|
+
/** Spec 4.3 takes an ASCII HTTP token as a user metadata key, which this is not. */
|
|
1715
|
+
const metadataKeyAboveAscii = { grüße: "hallo" };
|
|
1716
|
+
/** No Unicode character, so no UTF-8 form for the 2 KB of spec 4.3 to measure. */
|
|
1717
|
+
const metadataValueWithLoneSurrogate = { note: "lone-\ud800" };
|
|
1718
|
+
/** An ASCII HTTP token and no identifier, which spec 4.9 promises under `userMetadataTokenKeys`. */
|
|
1719
|
+
const metadataTokenKey = { "content-hash": "sha256-conformance" };
|
|
1720
|
+
/** Over the 2 KB of encoded header bytes spec 4.3 allows a whole user metadata set. */
|
|
1721
|
+
const metadataOverTheLimit = { note: "x".repeat(2 * kibibyte) };
|
|
1722
|
+
const putCases = [
|
|
1723
|
+
{
|
|
1724
|
+
name: "put/bytes-round-trip",
|
|
1725
|
+
requires: [],
|
|
1726
|
+
cost: "fast",
|
|
1727
|
+
async run(ctx) {
|
|
1728
|
+
const key = keyFor(ctx, "put/bytes-round-trip", "object.json");
|
|
1729
|
+
const bytes = patternOf(kibibyte);
|
|
1730
|
+
const written = await ctx.storage.put(key, bytes, { contentType: "application/json" });
|
|
1731
|
+
assertSameBytes(await (await ctx.storage.get(key)).bytes(), bytes, "the body `get` read back");
|
|
1732
|
+
assertSameDescription(written, await ctx.storage.stat(key), [
|
|
1733
|
+
"key",
|
|
1734
|
+
"size",
|
|
1735
|
+
"contentType"
|
|
1736
|
+
], "`put` and a later `stat`");
|
|
1737
|
+
}
|
|
1738
|
+
},
|
|
1739
|
+
{
|
|
1740
|
+
name: "put/string-round-trip",
|
|
1741
|
+
requires: [],
|
|
1742
|
+
cost: "fast",
|
|
1743
|
+
async run(ctx) {
|
|
1744
|
+
const key = keyFor(ctx, "put/string-round-trip");
|
|
1745
|
+
const text = "Grüße aus 日本 — ключ";
|
|
1746
|
+
const written = await ctx.storage.put(key, text, { contentType: "text/plain; charset=utf-8" });
|
|
1747
|
+
const read = await (await ctx.storage.get(key)).text();
|
|
1748
|
+
assert(read === text, `\`text()\` read back ${JSON.stringify(read)}`);
|
|
1749
|
+
assert(written.size === utf8$1.encode(text).byteLength, `\`put\` reported ${written.size} bytes for a string of ${utf8$1.encode(text).byteLength} UTF-8 bytes`);
|
|
1750
|
+
}
|
|
1751
|
+
},
|
|
1752
|
+
{
|
|
1753
|
+
name: "put/stream-round-trip",
|
|
1754
|
+
requires: [],
|
|
1755
|
+
cost: "fast",
|
|
1756
|
+
async run(ctx) {
|
|
1757
|
+
const key = keyFor(ctx, "put/stream-round-trip");
|
|
1758
|
+
const bytes = patternOf(mebibyte);
|
|
1759
|
+
await ctx.storage.put(key, streamOf(bytes, 64 * kibibyte));
|
|
1760
|
+
assertSameBytes(await (await ctx.storage.get(key)).bytes(), bytes, "the body `get` read back");
|
|
1761
|
+
}
|
|
1762
|
+
},
|
|
1763
|
+
{
|
|
1764
|
+
name: "put/multipart-round-trip",
|
|
1765
|
+
requires: [],
|
|
1766
|
+
cost: "fast",
|
|
1767
|
+
async run(ctx) {
|
|
1768
|
+
const key = keyFor(ctx, "put/multipart-round-trip");
|
|
1769
|
+
const bytes = patternOf(multipartSize);
|
|
1770
|
+
await ctx.storage.put(key, streamOf(bytes, mebibyte));
|
|
1771
|
+
const described = await ctx.storage.stat(key);
|
|
1772
|
+
assert(described.size === multipartSize, `\`stat\` reports ${described.size} bytes for an upload of ${multipartSize}`);
|
|
1773
|
+
assertSameBytes(await collect((await ctx.storage.get(key)).stream()), bytes, "the body `get` read back");
|
|
1774
|
+
}
|
|
1775
|
+
},
|
|
1776
|
+
{
|
|
1777
|
+
name: "put/empty-body",
|
|
1778
|
+
requires: [],
|
|
1779
|
+
cost: "fast",
|
|
1780
|
+
async run(ctx) {
|
|
1781
|
+
const empty = /* @__PURE__ */ new Uint8Array(0);
|
|
1782
|
+
const bodies = [{
|
|
1783
|
+
what: "an empty `Uint8Array`",
|
|
1784
|
+
key: keyFor(ctx, "put/empty-body", "bytes"),
|
|
1785
|
+
body: empty
|
|
1786
|
+
}, {
|
|
1787
|
+
what: "a stream that yields nothing",
|
|
1788
|
+
key: keyFor(ctx, "put/empty-body", "stream"),
|
|
1789
|
+
body: streamOf(empty, kibibyte)
|
|
1790
|
+
}];
|
|
1791
|
+
await Promise.all(bodies.map(async ({ what, key, body }) => {
|
|
1792
|
+
const written = await ctx.storage.put(key, body);
|
|
1793
|
+
assert(written.size === 0, `\`put\` reported ${written.size} bytes for ${what}`);
|
|
1794
|
+
assertSameBytes(await (await ctx.storage.get(key)).bytes(), empty, `the body ${what} left behind`);
|
|
1795
|
+
}));
|
|
1796
|
+
}
|
|
1797
|
+
},
|
|
1798
|
+
{
|
|
1799
|
+
name: "put/overwrites",
|
|
1800
|
+
requires: [],
|
|
1801
|
+
cost: "fast",
|
|
1802
|
+
async run(ctx) {
|
|
1803
|
+
const key = keyFor(ctx, "put/overwrites", "object.json");
|
|
1804
|
+
const second = patternOf(128);
|
|
1805
|
+
await ctx.storage.put(key, patternOf(64), { contentType: "text/plain" });
|
|
1806
|
+
await ctx.storage.put(key, second, { contentType: "application/json" });
|
|
1807
|
+
const stored = await ctx.storage.get(key);
|
|
1808
|
+
assert(stored.stat.contentType === "application/json", `The second \`put\` left the content type at ${JSON.stringify(stored.stat.contentType)}`);
|
|
1809
|
+
assertSameBytes(await stored.bytes(), second, "the body of the overwritten object");
|
|
1810
|
+
}
|
|
1811
|
+
},
|
|
1812
|
+
{
|
|
1813
|
+
name: "put/content-type-stored",
|
|
1814
|
+
requires: [],
|
|
1815
|
+
cost: "fast",
|
|
1816
|
+
async run(ctx) {
|
|
1817
|
+
const key = keyFor(ctx, "put/content-type-stored", "note.txt");
|
|
1818
|
+
await ctx.storage.put(key, "a note", { contentType: "text/plain" });
|
|
1819
|
+
assertContentType(await ctx.storage.stat(key), "text/plain", "`stat`");
|
|
1820
|
+
assertContentType((await ctx.storage.get(key)).stat, "text/plain", "`get`");
|
|
1821
|
+
}
|
|
1822
|
+
},
|
|
1823
|
+
{
|
|
1824
|
+
name: "put/content-type-default",
|
|
1825
|
+
requires: [],
|
|
1826
|
+
cost: "fast",
|
|
1827
|
+
async run(ctx) {
|
|
1828
|
+
const key = keyFor(ctx, "put/content-type-default", "object-without-an-extension");
|
|
1829
|
+
await ctx.storage.put(key, patternOf(16));
|
|
1830
|
+
assertContentType(await ctx.storage.stat(key), "application/octet-stream", "`stat`");
|
|
1831
|
+
}
|
|
1832
|
+
},
|
|
1833
|
+
{
|
|
1834
|
+
name: "put/accepted-keys",
|
|
1835
|
+
requires: [],
|
|
1836
|
+
cost: "fast",
|
|
1837
|
+
async run(ctx) {
|
|
1838
|
+
const prefix = prefixFor(ctx, "put/accepted-keys");
|
|
1839
|
+
const accepted = acceptedKeys(prefix);
|
|
1840
|
+
await Promise.all(accepted.map(async ({ label, key }) => {
|
|
1841
|
+
const bytes = utf8$1.encode(label);
|
|
1842
|
+
await ctx.storage.put(key, bytes);
|
|
1843
|
+
assertSameBytes(await (await ctx.storage.get(key)).bytes(), bytes, `the object under ${label}`);
|
|
1844
|
+
}));
|
|
1845
|
+
const listed = /* @__PURE__ */ new Set();
|
|
1846
|
+
for await (const entry of ctx.storage.list({ prefix })) listed.add(entry.key.normalize());
|
|
1847
|
+
for (const { label, key } of accepted) assert(listed.has(key.normalize()), `The listing below the prefix holds no ${label}`);
|
|
1848
|
+
}
|
|
1849
|
+
},
|
|
1850
|
+
{
|
|
1851
|
+
name: "put/refused-keys",
|
|
1852
|
+
requires: [],
|
|
1853
|
+
cost: "fast",
|
|
1854
|
+
async run(ctx) {
|
|
1855
|
+
const refused = refusedWritableKeys(prefixFor(ctx, "put/refused-keys"));
|
|
1856
|
+
const bytes = patternOf(16);
|
|
1857
|
+
await Promise.all(refused.map(async (refusal) => {
|
|
1858
|
+
await expectStorageError(() => ctx.storage.put(refusal.key, bytes), {
|
|
1859
|
+
code: "InvalidKey",
|
|
1860
|
+
...refusal.existsAnswers === "false-or-refusal" ? {} : { attempts: 0 }
|
|
1861
|
+
}, refusal.label);
|
|
1862
|
+
await assertNothingWasWritten(ctx, refusal);
|
|
1863
|
+
}));
|
|
1864
|
+
}
|
|
1865
|
+
},
|
|
1866
|
+
{
|
|
1867
|
+
name: "put/unknown-option",
|
|
1868
|
+
requires: [],
|
|
1869
|
+
cost: "fast",
|
|
1870
|
+
async run(ctx) {
|
|
1871
|
+
const key = keyFor(ctx, "put/unknown-option");
|
|
1872
|
+
const option = "stowageTakesNoSuchOption";
|
|
1873
|
+
const options = { [option]: true };
|
|
1874
|
+
const refusal = await expectStorageError(() => ctx.storage.put(key, patternOf(16), options), { code: "InvalidOption" });
|
|
1875
|
+
assert(refusal.message.includes(option), `The message ${JSON.stringify(refusal.message)} does not name the option`);
|
|
1876
|
+
assert(!await ctx.storage.exists(key), "`put` wrote the object although it refused the option");
|
|
1877
|
+
}
|
|
1878
|
+
},
|
|
1879
|
+
{
|
|
1880
|
+
name: "put/aborted-signal",
|
|
1881
|
+
requires: [],
|
|
1882
|
+
cost: "fast",
|
|
1883
|
+
async run(ctx) {
|
|
1884
|
+
const key = keyFor(ctx, "put/aborted-signal");
|
|
1885
|
+
await expectRuntimeError(() => ctx.storage.put(key, patternOf(16), { signal: AbortSignal.abort() }), "AbortError");
|
|
1886
|
+
assert(!await ctx.storage.exists(key), "`put` wrote the object although the signal was aborted");
|
|
1887
|
+
}
|
|
1888
|
+
},
|
|
1889
|
+
{
|
|
1890
|
+
name: "put/abort-during-upload",
|
|
1891
|
+
requires: [],
|
|
1892
|
+
cost: "fast",
|
|
1893
|
+
async run(ctx) {
|
|
1894
|
+
const key = keyFor(ctx, "put/abort-during-upload");
|
|
1895
|
+
const bytes = patternOf(multipartSize);
|
|
1896
|
+
const controller = new AbortController();
|
|
1897
|
+
await expectRuntimeError(() => ctx.storage.put(key, streamAbortedMidway(bytes, mebibyte, controller), { signal: controller.signal }), "AbortError");
|
|
1898
|
+
}
|
|
1899
|
+
},
|
|
1900
|
+
{
|
|
1901
|
+
name: "put/stream-consumed",
|
|
1902
|
+
requires: [],
|
|
1903
|
+
cost: "fast",
|
|
1904
|
+
async run(ctx) {
|
|
1905
|
+
const key = keyFor(ctx, "put/stream-consumed");
|
|
1906
|
+
const body = streamOf(patternOf(64 * kibibyte), 8 * kibibyte);
|
|
1907
|
+
await ctx.storage.put(key, body);
|
|
1908
|
+
assert(!body.locked, "The source stream is still locked to a reader `put` kept");
|
|
1909
|
+
const { done } = await body.getReader().read();
|
|
1910
|
+
assert(done, "The source stream is neither at its end nor canceled after `put`");
|
|
1911
|
+
}
|
|
1912
|
+
},
|
|
1913
|
+
{
|
|
1914
|
+
name: "put/user-metadata",
|
|
1915
|
+
requires: ["userMetadata"],
|
|
1916
|
+
cost: "fast",
|
|
1917
|
+
async run(ctx) {
|
|
1918
|
+
const key = keyFor(ctx, "put/user-metadata");
|
|
1919
|
+
const userMetadata = {
|
|
1920
|
+
WrittenBy: "stowage",
|
|
1921
|
+
run: "conformance"
|
|
1922
|
+
};
|
|
1923
|
+
await ctx.storage.put(key, patternOf(16), { userMetadata });
|
|
1924
|
+
assertHoldsMetadata((await ctx.storage.stat(key)).userMetadata, userMetadata, "`stat`");
|
|
1925
|
+
assertHoldsMetadata((await ctx.storage.get(key)).stat.userMetadata, userMetadata, "`get`");
|
|
1926
|
+
},
|
|
1927
|
+
async runWithout(ctx) {
|
|
1928
|
+
const refused = keyFor(ctx, "put/user-metadata", "refused");
|
|
1929
|
+
const empty = keyFor(ctx, "put/user-metadata", "empty");
|
|
1930
|
+
await expectUnsupported(() => ctx.storage.put(refused, patternOf(16), { userMetadata: { WrittenBy: "stowage" } }), "userMetadata");
|
|
1931
|
+
await ctx.storage.put(empty, patternOf(16), { userMetadata: {} });
|
|
1932
|
+
assertNoMetadata((await ctx.storage.stat(empty)).userMetadata, "`stat`");
|
|
1933
|
+
assertNoMetadata((await ctx.storage.get(empty)).stat.userMetadata, "`get`");
|
|
1934
|
+
}
|
|
1935
|
+
},
|
|
1936
|
+
{
|
|
1937
|
+
name: "put/user-metadata-limits",
|
|
1938
|
+
requires: ["userMetadata"],
|
|
1939
|
+
cost: "fast",
|
|
1940
|
+
async run(ctx) {
|
|
1941
|
+
const key = keyFor(ctx, "put/user-metadata-limits");
|
|
1942
|
+
const bytes = patternOf(16);
|
|
1943
|
+
await expectStorageError(() => ctx.storage.put(key, bytes, { userMetadata: metadataKeyAboveAscii }), {
|
|
1944
|
+
code: "InvalidRequest",
|
|
1945
|
+
attempts: 0
|
|
1946
|
+
}, "a user metadata key above ASCII");
|
|
1947
|
+
await expectStorageError(() => ctx.storage.put(key, bytes, { userMetadata: metadataValueWithLoneSurrogate }), {
|
|
1948
|
+
code: "InvalidRequest",
|
|
1949
|
+
attempts: 0
|
|
1950
|
+
}, "a user metadata value holding a lone surrogate");
|
|
1951
|
+
await expectStorageError(() => ctx.storage.put(key, bytes, { userMetadata: metadataOverTheLimit }), {
|
|
1952
|
+
code: "InvalidRequest",
|
|
1953
|
+
attempts: 0
|
|
1954
|
+
}, "a user metadata set over 2 KB");
|
|
1955
|
+
},
|
|
1956
|
+
async runWithout(ctx) {
|
|
1957
|
+
const key = keyFor(ctx, "put/user-metadata-limits");
|
|
1958
|
+
const bytes = patternOf(16);
|
|
1959
|
+
await expectUnsupported(() => ctx.storage.put(key, bytes, { userMetadata: metadataKeyAboveAscii }), "userMetadata");
|
|
1960
|
+
await expectUnsupported(() => ctx.storage.put(key, bytes, { userMetadata: metadataValueWithLoneSurrogate }), "userMetadata");
|
|
1961
|
+
await expectUnsupported(() => ctx.storage.put(key, bytes, { userMetadata: metadataOverTheLimit }), "userMetadata");
|
|
1962
|
+
}
|
|
1963
|
+
},
|
|
1964
|
+
{
|
|
1965
|
+
name: "put/user-metadata-token-keys",
|
|
1966
|
+
requires: ["userMetadata", "userMetadataTokenKeys"],
|
|
1967
|
+
cost: "fast",
|
|
1968
|
+
async run(ctx) {
|
|
1969
|
+
const key = keyFor(ctx, "put/user-metadata-token-keys");
|
|
1970
|
+
await ctx.storage.put(key, patternOf(16), { userMetadata: metadataTokenKey });
|
|
1971
|
+
assertHoldsMetadata((await ctx.storage.stat(key)).userMetadata, metadataTokenKey, "`stat`");
|
|
1972
|
+
assertHoldsMetadata((await ctx.storage.get(key)).stat.userMetadata, metadataTokenKey, "`get`");
|
|
1973
|
+
},
|
|
1974
|
+
async runWithout(ctx) {
|
|
1975
|
+
const key = keyFor(ctx, "put/user-metadata-token-keys");
|
|
1976
|
+
await expectStorageError(() => ctx.storage.put(key, patternOf(16), { userMetadata: metadataTokenKey }), {
|
|
1977
|
+
code: "Unsupported",
|
|
1978
|
+
attempts: 0,
|
|
1979
|
+
capability: ctx.declares("userMetadata") ? "userMetadataTokenKeys" : "userMetadata"
|
|
1980
|
+
}, "a user metadata key beyond identifiers");
|
|
1981
|
+
}
|
|
1982
|
+
},
|
|
1983
|
+
{
|
|
1984
|
+
name: "put/concurrent-writers",
|
|
1985
|
+
requires: [],
|
|
1986
|
+
cost: "fast",
|
|
1987
|
+
async run(ctx) {
|
|
1988
|
+
const key = keyFor(ctx, "put/concurrent-writers");
|
|
1989
|
+
const patterns = [patternOf(multipartSize), patternOf(multipartSize, 1)];
|
|
1990
|
+
const bodies = streamsEndingTogether(patterns, mebibyte);
|
|
1991
|
+
const outcomes = await Promise.allSettled(bodies.map((body) => ctx.storage.put(key, body)));
|
|
1992
|
+
assert(outcomes.some((outcome) => outcome.status === "fulfilled"), `Both writers rejected: ${outcomes.map(describeOutcome).join("; ")}`);
|
|
1993
|
+
const held = await collect((await ctx.storage.get(key)).stream());
|
|
1994
|
+
assert(patterns.some((pattern) => sameBytes(held, pattern)), `The key holds ${held.byteLength} bytes that are neither writer's object whole`);
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
];
|
|
1998
|
+
function describeOutcome(outcome) {
|
|
1999
|
+
return outcome.status === "fulfilled" ? "resolved" : String(outcome.reason);
|
|
2000
|
+
}
|
|
2001
|
+
function sameBytes(one, other) {
|
|
2002
|
+
return one.byteLength === other.byteLength && one.every((byte, index) => byte === other[index]);
|
|
2003
|
+
}
|
|
2004
|
+
function assertContentType(described, expected, where) {
|
|
2005
|
+
assert(described.contentType === expected, `${where} reports the content type ${JSON.stringify(described.contentType)} and not ${JSON.stringify(expected)}`);
|
|
2006
|
+
}
|
|
2007
|
+
function assertHoldsMetadata(held, expected, where) {
|
|
2008
|
+
const lowered = new Map(Object.entries(held).map(([name, value]) => [name.toLowerCase(), value]));
|
|
2009
|
+
for (const [name, value] of Object.entries(expected)) {
|
|
2010
|
+
const read = lowered.get(name.toLowerCase());
|
|
2011
|
+
assert(read === value, `${where} reports the user metadata ${JSON.stringify(name)} as ${JSON.stringify(read)} and not as ${JSON.stringify(value)}`);
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
function assertNoMetadata(held, where) {
|
|
2015
|
+
assert(Object.keys(held).length === 0, `${where} reports the user metadata ${JSON.stringify(held)} on a storage that holds none`);
|
|
2016
|
+
}
|
|
2017
|
+
async function assertNothingWasWritten(ctx, refused) {
|
|
2018
|
+
if (refused.existsAnswers === "unasked") return;
|
|
2019
|
+
assert(await existsOrRefusal(ctx, refused) !== true, `\`exists\` answers true for ${refused.label}, which \`put\` refused`);
|
|
2020
|
+
}
|
|
2021
|
+
async function existsOrRefusal(ctx, refused) {
|
|
2022
|
+
try {
|
|
2023
|
+
return await ctx.storage.exists(refused.key);
|
|
2024
|
+
} catch (thrown) {
|
|
2025
|
+
if (!(refused.existsAnswers === "false-or-refusal" && isStorageError(thrown) && thrown.code === "InvalidKey")) throw thrown;
|
|
2026
|
+
return "refusal";
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
//#endregion
|
|
2030
|
+
//#region src/cases/stat.ts
|
|
2031
|
+
const utf8 = new TextEncoder();
|
|
2032
|
+
/** What spec 4.4 allows between the time the provider reported and the clock reading it. */
|
|
2033
|
+
const clockTolerance = 6e4;
|
|
2034
|
+
const statCases = [{
|
|
2035
|
+
name: "stat/describes-object",
|
|
2036
|
+
requires: [],
|
|
2037
|
+
cost: "fast",
|
|
2038
|
+
async run(ctx) {
|
|
2039
|
+
const key = keyFor(ctx, "stat/describes-object", "object.txt");
|
|
2040
|
+
const body = "a body to describe";
|
|
2041
|
+
const size = utf8.encode(body).byteLength;
|
|
2042
|
+
await ctx.storage.put(key, body, { contentType: "text/plain" });
|
|
2043
|
+
const described = await ctx.storage.stat(key);
|
|
2044
|
+
assert(described.key === key, `\`stat\` reports the key ${JSON.stringify(described.key)} for ${JSON.stringify(key)}`);
|
|
2045
|
+
assert(described.size === size, `\`stat\` reports ${described.size} bytes and not ${size}`);
|
|
2046
|
+
assert(described.contentType === "text/plain", `\`stat\` reports the content type ${JSON.stringify(described.contentType)}`);
|
|
2047
|
+
assertWrittenJustNow(described.lastModified);
|
|
2048
|
+
assert(typeof described.userMetadata === "object" && described.userMetadata !== null, `\`stat\` reports the user metadata as ${JSON.stringify(described.userMetadata)}`);
|
|
2049
|
+
}
|
|
2050
|
+
}, {
|
|
2051
|
+
name: "stat/missing-key",
|
|
2052
|
+
requires: [],
|
|
2053
|
+
cost: "fast",
|
|
2054
|
+
async run(ctx) {
|
|
2055
|
+
const key = keyFor(ctx, "stat/missing-key", "absent");
|
|
2056
|
+
await expectStorageError(() => ctx.storage.stat(key), {
|
|
2057
|
+
code: "NotFound",
|
|
2058
|
+
operation: "stat"
|
|
2059
|
+
});
|
|
2060
|
+
}
|
|
2061
|
+
}];
|
|
2062
|
+
function assertWrittenJustNow(lastModified) {
|
|
2063
|
+
assertDate(lastModified, "The last modification `stat` reports");
|
|
2064
|
+
assert(Math.abs(Date.now() - lastModified.getTime()) <= clockTolerance, `\`stat\` reports the last modification as ${lastModified.toISOString()}, which is no moment ago`);
|
|
2065
|
+
}
|
|
2066
|
+
//#endregion
|
|
2067
|
+
//#region src/cases/index.ts
|
|
2068
|
+
const conformanceCaseSources = [
|
|
2069
|
+
...declarationCases,
|
|
2070
|
+
...putCases,
|
|
2071
|
+
...getCases,
|
|
2072
|
+
...statCases,
|
|
2073
|
+
...existsCases,
|
|
2074
|
+
...listCases,
|
|
2075
|
+
...deleteCases,
|
|
2076
|
+
...copyAndMoveCases,
|
|
2077
|
+
...errorCases,
|
|
2078
|
+
...presignCases,
|
|
2079
|
+
...flowCases
|
|
2080
|
+
];
|
|
2081
|
+
/** The same cases as the package publishes them, without the factory of spec 9.3. */
|
|
2082
|
+
const conformanceCases = conformanceCaseSources;
|
|
2083
|
+
//#endregion
|
|
2084
|
+
//#region src/run.ts
|
|
2085
|
+
/** The cases a run performs: the `fast` tier alone unless it asks for both (spec 9.2). */
|
|
2086
|
+
function selectedCases(options) {
|
|
2087
|
+
if (options?.includeSlow === true) return conformanceCaseSources;
|
|
2088
|
+
return conformanceCaseSources.filter((source) => source.cost === "fast");
|
|
2089
|
+
}
|
|
2090
|
+
const keyPrefixRoot = "stowage-conformance";
|
|
2091
|
+
function createKeyPrefix() {
|
|
2092
|
+
return `${keyPrefixRoot}/${crypto.randomUUID()}/`;
|
|
2093
|
+
}
|
|
2094
|
+
/**
|
|
2095
|
+
* Spec 9.2: the declaration is read once per run, before the first case, because every
|
|
2096
|
+
* storage the target creates in one run declares the same.
|
|
2097
|
+
*/
|
|
2098
|
+
async function startRun(target, keyPrefix) {
|
|
2099
|
+
const storage = await target.createStorage();
|
|
2100
|
+
const declared = new Set(storage.capabilities);
|
|
2101
|
+
return {
|
|
2102
|
+
storage,
|
|
2103
|
+
keyPrefix,
|
|
2104
|
+
target,
|
|
2105
|
+
declares: (name) => declared.has(name)
|
|
2106
|
+
};
|
|
2107
|
+
}
|
|
2108
|
+
/**
|
|
2109
|
+
* Spec 9.2: the default deletes below the prefix on a storage of its own, so that a run
|
|
2110
|
+
* whose storage broke on the way still clears what it wrote.
|
|
2111
|
+
*/
|
|
2112
|
+
async function cleanUp(target, keyPrefix) {
|
|
2113
|
+
if (target.cleanup !== void 0) return await target.cleanup(keyPrefix);
|
|
2114
|
+
const [failure] = (await (await target.createStorage()).deleteAll(keyPrefix)).failed;
|
|
2115
|
+
if (failure !== void 0) throw failure;
|
|
2116
|
+
}
|
|
2117
|
+
/** The factory the case needs and the target left out, which spec 9.2 skips it for. */
|
|
2118
|
+
function skipReasonFor(source, target) {
|
|
2119
|
+
if (source.factory === void 0 || target[source.factory] !== void 0) return void 0;
|
|
2120
|
+
return source.factory;
|
|
2121
|
+
}
|
|
2122
|
+
/**
|
|
2123
|
+
* Spec 9.2: a case whose requirements are all declared runs `run`, and one missing a
|
|
2124
|
+
* name runs `runWithout`.
|
|
2125
|
+
*/
|
|
2126
|
+
function selectHalf(source, context) {
|
|
2127
|
+
if ("runWithout" in source && !source.requires.every((name) => context.declares(name))) return {
|
|
2128
|
+
mode: "without",
|
|
2129
|
+
run: async () => await source.runWithout(context)
|
|
2130
|
+
};
|
|
2131
|
+
return {
|
|
2132
|
+
mode: "declared",
|
|
2133
|
+
run: async () => await source.run(context)
|
|
2134
|
+
};
|
|
2135
|
+
}
|
|
2136
|
+
//#endregion
|
|
2137
|
+
//#region src/describe.ts
|
|
2138
|
+
/**
|
|
2139
|
+
* Maps every case onto one test of Vitest, `bun:test` or `Deno.test`, whose `describe`
|
|
2140
|
+
* and `test` agree in shape (ADR 0006), so that a failing case is a failing test of its
|
|
2141
|
+
* own rather than one red line covering the suite.
|
|
2142
|
+
*/
|
|
2143
|
+
function describeConformance(target, framework) {
|
|
2144
|
+
describeCases(selectedCases(framework), target, framework);
|
|
2145
|
+
}
|
|
2146
|
+
/** The mapping behind `describeConformance`, over the cases a caller picked. */
|
|
2147
|
+
function describeCases(sources, target, framework) {
|
|
2148
|
+
const keyPrefix = createKeyPrefix();
|
|
2149
|
+
let opened;
|
|
2150
|
+
const open = async () => await (opened ??= startRun(target, keyPrefix));
|
|
2151
|
+
framework.describe(target.name, () => {
|
|
2152
|
+
for (const source of sources) {
|
|
2153
|
+
const skipped = skipReasonFor(source, target);
|
|
2154
|
+
if (skipped !== void 0) {
|
|
2155
|
+
framework.test(`${source.name} (skipped: ${skipped})`, async () => {});
|
|
2156
|
+
continue;
|
|
2157
|
+
}
|
|
2158
|
+
framework.test(source.name, async () => {
|
|
2159
|
+
await selectHalf(source, await open()).run();
|
|
2160
|
+
});
|
|
2161
|
+
}
|
|
2162
|
+
framework.test("cleanup", async () => await cleanUp(target, keyPrefix));
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
//#endregion
|
|
2166
|
+
//#region src/case.ts
|
|
2167
|
+
function metadataOf(source) {
|
|
2168
|
+
return {
|
|
2169
|
+
name: source.name,
|
|
2170
|
+
requires: [...source.requires],
|
|
2171
|
+
cost: source.cost
|
|
2172
|
+
};
|
|
2173
|
+
}
|
|
2174
|
+
//#endregion
|
|
2175
|
+
//#region src/run-all.ts
|
|
2176
|
+
/**
|
|
2177
|
+
* Runs the cases without a test framework and reports them, which is how a runtime that
|
|
2178
|
+
* has none — `workerd` — runs the suite (ADR 0006). A failing case is one result among
|
|
2179
|
+
* the others; only a target that cannot produce a storage at all rejects the run.
|
|
2180
|
+
*/
|
|
2181
|
+
async function runAll(target, options) {
|
|
2182
|
+
return await runCases(selectedCases(options), target);
|
|
2183
|
+
}
|
|
2184
|
+
/** One run over the cases a caller picked, which is what `runAll` is around the suite. */
|
|
2185
|
+
async function runCases(sources, target) {
|
|
2186
|
+
const keyPrefix = createKeyPrefix();
|
|
2187
|
+
const context = await startRun(target, keyPrefix);
|
|
2188
|
+
const results = [];
|
|
2189
|
+
for (const source of sources) {
|
|
2190
|
+
const skipped = skipReasonFor(source, target);
|
|
2191
|
+
if (skipped !== void 0) {
|
|
2192
|
+
results.push({
|
|
2193
|
+
case: metadataOf(source),
|
|
2194
|
+
status: "skipped",
|
|
2195
|
+
reason: skipped
|
|
2196
|
+
});
|
|
2197
|
+
continue;
|
|
2198
|
+
}
|
|
2199
|
+
const half = selectHalf(source, context);
|
|
2200
|
+
try {
|
|
2201
|
+
await half.run();
|
|
2202
|
+
results.push({
|
|
2203
|
+
case: metadataOf(source),
|
|
2204
|
+
status: "passed",
|
|
2205
|
+
mode: half.mode
|
|
2206
|
+
});
|
|
2207
|
+
} catch (thrown) {
|
|
2208
|
+
results.push({
|
|
2209
|
+
case: metadataOf(source),
|
|
2210
|
+
status: "failed",
|
|
2211
|
+
mode: half.mode,
|
|
2212
|
+
error: serializeError(thrown)
|
|
2213
|
+
});
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
await cleanUp(target, keyPrefix);
|
|
2217
|
+
return results;
|
|
2218
|
+
}
|
|
2219
|
+
//#endregion
|
|
2220
|
+
export { conformanceCases, describeConformance, expectUnsupported, runAll };
|