@nestm/storage 0.1.0-alpha.7 → 0.1.0-alpha.8
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/CHANGELOG.md +110 -0
- package/README.md +217 -18
- package/SECURITY.md +34 -5
- package/dist/ai-sdk/ai-sdk-workspace-tools.d.ts +14 -3
- package/dist/ai-sdk/ai-sdk-workspace-tools.d.ts.map +1 -1
- package/dist/ai-sdk/ai-sdk-workspace-tools.js +54 -23
- package/dist/ai-sdk/ai-sdk-workspace-tools.js.map +1 -1
- package/dist/ai-sdk/index.d.ts +1 -1
- package/dist/ai-sdk/index.d.ts.map +1 -1
- package/dist/ai-sdk/index.js.map +1 -1
- package/dist/files-sdk/files-sdk.driver.d.ts +54 -10
- package/dist/files-sdk/files-sdk.driver.d.ts.map +1 -1
- package/dist/files-sdk/files-sdk.driver.js +784 -63
- package/dist/files-sdk/files-sdk.driver.js.map +1 -1
- package/dist/files-sdk/fs/index.d.ts +4 -4
- package/dist/files-sdk/fs/index.d.ts.map +1 -1
- package/dist/files-sdk/fs/index.js +242 -9
- package/dist/files-sdk/fs/index.js.map +1 -1
- package/dist/files-sdk/index.d.ts +1 -1
- package/dist/files-sdk/index.d.ts.map +1 -1
- package/dist/files-sdk/index.js.map +1 -1
- package/dist/files-sdk/provider/index.d.ts +9 -5
- package/dist/files-sdk/provider/index.d.ts.map +1 -1
- package/dist/files-sdk/provider/index.js +53 -15
- package/dist/files-sdk/provider/index.js.map +1 -1
- package/dist/files-sdk/s3/construction-metadata.d.ts +7 -0
- package/dist/files-sdk/s3/construction-metadata.d.ts.map +1 -0
- package/dist/files-sdk/s3/construction-metadata.js +13 -0
- package/dist/files-sdk/s3/construction-metadata.js.map +1 -0
- package/dist/files-sdk/s3/index.d.ts +39 -19
- package/dist/files-sdk/s3/index.d.ts.map +1 -1
- package/dist/files-sdk/s3/index.js +936 -113
- package/dist/files-sdk/s3/index.js.map +1 -1
- package/dist/gateway/storage-gateway.controller.d.ts.map +1 -1
- package/dist/gateway/storage-gateway.controller.js +25 -5
- package/dist/gateway/storage-gateway.controller.js.map +1 -1
- package/dist/storage-etag.d.ts +13 -0
- package/dist/storage-etag.d.ts.map +1 -0
- package/dist/storage-etag.js +31 -0
- package/dist/storage-etag.js.map +1 -0
- package/dist/storage.client.d.ts +3 -1
- package/dist/storage.client.d.ts.map +1 -1
- package/dist/storage.client.js +87 -22
- package/dist/storage.client.js.map +1 -1
- package/dist/storage.driver.d.ts +21 -1
- package/dist/storage.driver.d.ts.map +1 -1
- package/dist/storage.driver.js.map +1 -1
- package/dist/storage.types.d.ts +90 -19
- package/dist/storage.types.d.ts.map +1 -1
- package/dist/storage.types.js.map +1 -1
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +1 -0
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/provider-conformance.d.ts +65 -0
- package/dist/testing/provider-conformance.d.ts.map +1 -0
- package/dist/testing/provider-conformance.js +853 -0
- package/dist/testing/provider-conformance.js.map +1 -0
- package/dist/workspace/index.d.ts +1 -0
- package/dist/workspace/index.d.ts.map +1 -1
- package/dist/workspace/index.js +1 -0
- package/dist/workspace/index.js.map +1 -1
- package/dist/workspace/storage-workspace.cursor.d.ts +68 -3
- package/dist/workspace/storage-workspace.cursor.d.ts.map +1 -1
- package/dist/workspace/storage-workspace.cursor.js +402 -39
- package/dist/workspace/storage-workspace.cursor.js.map +1 -1
- package/dist/workspace/storage-workspace.d.ts.map +1 -1
- package/dist/workspace/storage-workspace.js +54 -46
- package/dist/workspace/storage-workspace.js.map +1 -1
- package/dist/workspace/storage-workspace.types.d.ts +10 -2
- package/dist/workspace/storage-workspace.types.d.ts.map +1 -1
- package/dist/workspace/storage-workspace.types.js +1 -0
- package/dist/workspace/storage-workspace.types.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,853 @@
|
|
|
1
|
+
import { deepStrictEqual, equal, fail, match, ok } from 'node:assert/strict';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { inspect } from 'node:util';
|
|
4
|
+
import { isCanonicalStorageEtag } from '../storage-etag.js';
|
|
5
|
+
import { isStorageError, StorageErrorCode, } from '../storage.error.js';
|
|
6
|
+
const CONDITIONAL_CAPABILITY_KEYS = [
|
|
7
|
+
'conditionalCreate',
|
|
8
|
+
'conditionalReplace',
|
|
9
|
+
'conditionalDelete',
|
|
10
|
+
'conditionalRead',
|
|
11
|
+
'conditionalCopySource',
|
|
12
|
+
'conditionalCopyDestination',
|
|
13
|
+
'conditionalMultipartCompletion',
|
|
14
|
+
];
|
|
15
|
+
const DEFAULT_MULTIPART_BYTES = 5 * 1024 * 1024 + 1;
|
|
16
|
+
const PASSED = Object.freeze({ status: 'passed' });
|
|
17
|
+
/**
|
|
18
|
+
* Builds runner-agnostic provider contract cases. A test runner can register
|
|
19
|
+
* each returned case independently and translate a `skipped` result into its
|
|
20
|
+
* native skip primitive. Every case receives a fresh client and namespace.
|
|
21
|
+
*/
|
|
22
|
+
export function createStorageProviderConformanceCases(options) {
|
|
23
|
+
positiveSafeInteger(options.expected.physicalKey.maxBytes, 'expected.physicalKey.maxBytes');
|
|
24
|
+
positiveSafeInteger(options.multipartBytes ?? DEFAULT_MULTIPART_BYTES, 'multipartBytes');
|
|
25
|
+
ok(options.provider.trim().length > 0, 'provider must not be empty');
|
|
26
|
+
const expected = options.expected;
|
|
27
|
+
return Object.freeze([
|
|
28
|
+
providerCase(options, 'declares the exact provider capability matrix', ({ client }) => {
|
|
29
|
+
deepStrictEqual(conditionalCapabilitiesOf(client.capabilities), expected, `${options.provider} advertised a capability matrix different from its verified profile`);
|
|
30
|
+
}),
|
|
31
|
+
providerCase(options, 'performs baseline upload, read, and delete operations', async (context) => {
|
|
32
|
+
const key = context.key('baseline.txt');
|
|
33
|
+
await seed(context, key, 'baseline');
|
|
34
|
+
assertOptionalResultEtag(await context.client.head(key), 'provider head returned a non-canonical ETag');
|
|
35
|
+
equal(await readText(context.client, key), 'baseline');
|
|
36
|
+
await context.client.delete(key);
|
|
37
|
+
equal(await context.client.exists(key), false);
|
|
38
|
+
}),
|
|
39
|
+
listCursorReplayCase(options),
|
|
40
|
+
providerCase(options, 'rejects an over-budget physical key before dispatch', async ({ client }) => {
|
|
41
|
+
const key = 'x'.repeat(expected.physicalKey.maxBytes + 1);
|
|
42
|
+
const error = await expectStorageError(() => client.upload(key, 'must-not-dispatch'), StorageErrorCode.LIMIT_EXCEEDED, options);
|
|
43
|
+
equal(error.permanent, true);
|
|
44
|
+
}),
|
|
45
|
+
invalidPreconditionEtagCase(options),
|
|
46
|
+
conditionalCreateCase(options),
|
|
47
|
+
conditionalReplaceCase(options),
|
|
48
|
+
conditionalDeleteCase(options),
|
|
49
|
+
conditionalReadEtagCase(options),
|
|
50
|
+
conditionalReadVersionCase(options),
|
|
51
|
+
conditionalCopySourceEtagCase(options),
|
|
52
|
+
conditionalCopySourceVersionCase(options),
|
|
53
|
+
conditionalCopyDestinationCreateCase(options),
|
|
54
|
+
conditionalCopyDestinationReplaceCase(options),
|
|
55
|
+
...conditionalCopyAtomicityCases(options),
|
|
56
|
+
conditionalMultipartCreateCase(options),
|
|
57
|
+
conditionalMultipartReplaceCase(options),
|
|
58
|
+
providerCase(options, 'sanitizes a provider not-found response', async (context) => {
|
|
59
|
+
await expectStorageError(() => context.client.head(context.key('missing.txt')), StorageErrorCode.NOT_FOUND, options);
|
|
60
|
+
}),
|
|
61
|
+
]);
|
|
62
|
+
}
|
|
63
|
+
function listCursorReplayCase(options) {
|
|
64
|
+
return providerCase(options, 'replays portable list cursors after descendants and limit changes', async (context) => {
|
|
65
|
+
await verifyCursorReplay(context, 'cursor-replay-flat', [
|
|
66
|
+
'01.txt',
|
|
67
|
+
'02.txt',
|
|
68
|
+
'03.txt',
|
|
69
|
+
'04.txt',
|
|
70
|
+
'05.txt',
|
|
71
|
+
]);
|
|
72
|
+
if (context.client.capabilities.delimiter) {
|
|
73
|
+
await verifyCursorReplay(context, 'cursor-replay-delimited', [
|
|
74
|
+
'01.txt',
|
|
75
|
+
'02-dir/inside.txt',
|
|
76
|
+
'03.txt',
|
|
77
|
+
'04-dir/inside.txt',
|
|
78
|
+
'05.txt',
|
|
79
|
+
], '/');
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
async function verifyCursorReplay(context, label, names, delimiter) {
|
|
84
|
+
const [firstName, ...remainingNames] = names;
|
|
85
|
+
const firstKey = context.key(`${label}/${firstName}`);
|
|
86
|
+
const prefix = firstKey.slice(0, -firstName.length);
|
|
87
|
+
await seed(context, firstKey, firstName);
|
|
88
|
+
for (const name of remainingNames) {
|
|
89
|
+
await seed(context, context.track(`${prefix}${name}`), name);
|
|
90
|
+
}
|
|
91
|
+
const request = {
|
|
92
|
+
...(delimiter === undefined ? {} : { delimiter }),
|
|
93
|
+
limit: 1,
|
|
94
|
+
prefix,
|
|
95
|
+
};
|
|
96
|
+
const first = await context.client.list(request);
|
|
97
|
+
assertPageSize(first, 1);
|
|
98
|
+
const cursor = requiredCursor(first, 'provider omitted a continuation cursor while matching entries remained');
|
|
99
|
+
const replayRequest = { ...request, cursor };
|
|
100
|
+
const expected = await context.client.list(replayRequest);
|
|
101
|
+
assertPageSize(expected, 1);
|
|
102
|
+
const descendantCursor = requiredCursor(expected, 'provider omitted a descendant cursor while matching entries remained');
|
|
103
|
+
// Follow a cursor derived from the input before replaying its ancestor. A
|
|
104
|
+
// consuming cursor implementation will now fail or change the page.
|
|
105
|
+
const descendant = await context.client.list({
|
|
106
|
+
...request,
|
|
107
|
+
cursor: descendantCursor,
|
|
108
|
+
});
|
|
109
|
+
assertPageSize(descendant, 1);
|
|
110
|
+
const replayed = await context.client.list(replayRequest);
|
|
111
|
+
assertEquivalentListPage(replayed, expected, 'reusing a provider cursor after its descendant changed the page');
|
|
112
|
+
await assertEquivalentNextPage(context.client, replayed, descendant, request, 'replayed cursor returned a continuation for a different position');
|
|
113
|
+
const wider = await context.client.list({
|
|
114
|
+
...replayRequest,
|
|
115
|
+
limit: 2,
|
|
116
|
+
});
|
|
117
|
+
assertPageSize(wider, 2);
|
|
118
|
+
deepStrictEqual(logicalEntryIds(wider), [...logicalEntryIds(expected), ...logicalEntryIds(descendant)].sort(), 'changing the page limit changed the cursor starting position');
|
|
119
|
+
const afterWider = await context.client.list({
|
|
120
|
+
...request,
|
|
121
|
+
cursor: requiredCursor(descendant, 'provider ended before the wider-page continuation could be verified'),
|
|
122
|
+
});
|
|
123
|
+
await assertEquivalentNextPage(context.client, wider, afterWider, request, 'changed-limit cursor returned a continuation for a different position');
|
|
124
|
+
const replica = await context.createReplica();
|
|
125
|
+
const replicaReplay = await replica.list(replayRequest);
|
|
126
|
+
assertEquivalentListPage(replicaReplay, expected, 'an independently constructed replica resumed at a different page');
|
|
127
|
+
await assertEquivalentNextPage(replica, replicaReplay, descendant, request, 'replica cursor returned a continuation for a different position');
|
|
128
|
+
const replicaWider = await replica.list({ ...replayRequest, limit: 2 });
|
|
129
|
+
assertEquivalentListPage(replicaWider, wider, 'a replica changed the page returned with a different limit');
|
|
130
|
+
await assertEquivalentNextPage(replica, replicaWider, afterWider, request, 'replica changed-limit cursor returned a continuation for a different position');
|
|
131
|
+
}
|
|
132
|
+
function comparableListPage(page) {
|
|
133
|
+
return {
|
|
134
|
+
items: page.items.map((item) => ({
|
|
135
|
+
contentType: item.contentType,
|
|
136
|
+
etag: item.etag,
|
|
137
|
+
key: item.key,
|
|
138
|
+
lastModified: item.lastModified?.toISOString(),
|
|
139
|
+
metadata: item.metadata,
|
|
140
|
+
name: item.name,
|
|
141
|
+
size: item.size,
|
|
142
|
+
})),
|
|
143
|
+
prefixes: page.prefixes,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function assertEquivalentListPage(actual, expected, message) {
|
|
147
|
+
deepStrictEqual(comparableListPage(actual), comparableListPage(expected), message);
|
|
148
|
+
equal(actual.cursor === undefined, expected.cursor === undefined, `${message}: continuation presence differed`);
|
|
149
|
+
}
|
|
150
|
+
async function assertEquivalentNextPage(client, actual, expectedNext, request, message) {
|
|
151
|
+
const cursor = requiredCursor(actual, `${message}: provider did not return a continuation cursor`);
|
|
152
|
+
const actualNext = await client.list({ ...request, cursor });
|
|
153
|
+
assertEquivalentListPage(actualNext, expectedNext, message);
|
|
154
|
+
}
|
|
155
|
+
function assertPageSize(page, expected) {
|
|
156
|
+
equal(page.items.length + (page.prefixes?.length ?? 0), expected, 'provider ignored the list page limit across items and common prefixes');
|
|
157
|
+
}
|
|
158
|
+
function requiredCursor(page, message) {
|
|
159
|
+
ok(page.cursor !== undefined && page.cursor.length > 0, message);
|
|
160
|
+
return page.cursor;
|
|
161
|
+
}
|
|
162
|
+
function logicalEntryIds(page) {
|
|
163
|
+
return [
|
|
164
|
+
...page.items.map((item) => `item:${item.key}`),
|
|
165
|
+
...(page.prefixes ?? []).map((prefix) => `prefix:${prefix}`),
|
|
166
|
+
].sort();
|
|
167
|
+
}
|
|
168
|
+
function invalidPreconditionEtagCase(options) {
|
|
169
|
+
return providerCase(options, 'rejects invalid conditional ETags before dispatch or mutation', async (context) => {
|
|
170
|
+
const source = context.key('invalid-etag-source.txt');
|
|
171
|
+
const destination = context.key('invalid-etag-destination.txt');
|
|
172
|
+
await seed(context, source, 'source-original');
|
|
173
|
+
await seed(context, destination, 'destination-original');
|
|
174
|
+
const countDispatches = context.dispatchCount;
|
|
175
|
+
const dispatchedBefore = countDispatches?.();
|
|
176
|
+
const invalidEtag = '"stale","current"';
|
|
177
|
+
const operations = [
|
|
178
|
+
() => context.client.uploadConditional(source, 'must-not-replace', {
|
|
179
|
+
condition: { etag: invalidEtag, type: 'replace' },
|
|
180
|
+
}),
|
|
181
|
+
() => context.client.downloadConditional(source, {
|
|
182
|
+
condition: { etag: invalidEtag },
|
|
183
|
+
}),
|
|
184
|
+
() => context.client.deleteConditional(source, {
|
|
185
|
+
condition: { etag: invalidEtag },
|
|
186
|
+
}),
|
|
187
|
+
() => context.client.promote(source, destination, {
|
|
188
|
+
sourceEtag: invalidEtag,
|
|
189
|
+
}),
|
|
190
|
+
() => context.client.promote(source, destination, {
|
|
191
|
+
destination: { etag: invalidEtag, type: 'replace' },
|
|
192
|
+
}),
|
|
193
|
+
];
|
|
194
|
+
for (const operation of operations) {
|
|
195
|
+
const error = await expectStorageError(operation, StorageErrorCode.INVALID_ARGUMENT, options);
|
|
196
|
+
equal(error.permanent, true);
|
|
197
|
+
}
|
|
198
|
+
if (countDispatches !== undefined && dispatchedBefore !== undefined) {
|
|
199
|
+
equal(countDispatches(), dispatchedBefore, 'invalid conditional ETags reached the provider driver');
|
|
200
|
+
}
|
|
201
|
+
equal(await readText(context.client, source), 'source-original');
|
|
202
|
+
equal(await readText(context.client, destination), 'destination-original');
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
function conditionalCreateCase(options) {
|
|
206
|
+
const capability = options.expected.conditionalCreate;
|
|
207
|
+
return providerCase(options, capability === undefined
|
|
208
|
+
? 'fails closed when conditional create is unsupported'
|
|
209
|
+
: 'enforces conditional create atomically', async (context) => {
|
|
210
|
+
const key = context.key('conditional-create.txt');
|
|
211
|
+
if (capability === undefined) {
|
|
212
|
+
await expectStorageError(() => context.client.uploadConditional(key, 'blocked', {
|
|
213
|
+
condition: { type: 'create' },
|
|
214
|
+
}), StorageErrorCode.NOT_SUPPORTED, options);
|
|
215
|
+
equal(await context.client.exists(key), false);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const created = await context.client.uploadConditional(key, 'created', {
|
|
219
|
+
condition: { type: 'create' },
|
|
220
|
+
});
|
|
221
|
+
assertResultEtag(created, capability.resultEtag);
|
|
222
|
+
await expectStorageError(() => context.client.uploadConditional(key, 'overwritten', {
|
|
223
|
+
condition: { type: 'create' },
|
|
224
|
+
}), StorageErrorCode.CONFLICT, options);
|
|
225
|
+
equal(await readText(context.client, key), 'created');
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
function conditionalReplaceCase(options) {
|
|
229
|
+
const capability = options.expected.conditionalReplace;
|
|
230
|
+
return providerCase(options, capability === undefined
|
|
231
|
+
? 'fails closed when conditional replace is unsupported'
|
|
232
|
+
: 'enforces conditional replace atomically', async (context) => {
|
|
233
|
+
const key = context.key('conditional-replace.txt');
|
|
234
|
+
const original = await seed(context, key, 'original');
|
|
235
|
+
const etag = await resultEtag(context.client, key, original);
|
|
236
|
+
if (capability === undefined) {
|
|
237
|
+
await expectStorageError(() => context.client.uploadConditional(key, 'blocked', {
|
|
238
|
+
condition: { etag, type: 'replace' },
|
|
239
|
+
}), StorageErrorCode.NOT_SUPPORTED, options);
|
|
240
|
+
equal(await readText(context.client, key), 'original');
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const replaced = await context.client.uploadConditional(key, 'replaced', {
|
|
244
|
+
condition: { etag, type: 'replace' },
|
|
245
|
+
});
|
|
246
|
+
assertResultEtag(replaced, capability.resultEtag);
|
|
247
|
+
await expectStorageError(() => context.client.uploadConditional(key, 'stale-overwrite', {
|
|
248
|
+
condition: { etag, type: 'replace' },
|
|
249
|
+
}), StorageErrorCode.CONFLICT, options);
|
|
250
|
+
equal(await readText(context.client, key), 'replaced');
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
function conditionalDeleteCase(options) {
|
|
254
|
+
const capability = options.expected.conditionalDelete;
|
|
255
|
+
return providerCase(options, capability?.etag === true
|
|
256
|
+
? 'enforces ETag-conditional delete atomically'
|
|
257
|
+
: 'fails closed when ETag-conditional delete is unsupported', async (context) => {
|
|
258
|
+
const key = context.key('conditional-delete.txt');
|
|
259
|
+
const original = await seed(context, key, 'retained');
|
|
260
|
+
const etag = await resultEtag(context.client, key, original);
|
|
261
|
+
if (capability?.etag !== true) {
|
|
262
|
+
await expectStorageError(() => context.client.deleteConditional(key, {
|
|
263
|
+
condition: { etag },
|
|
264
|
+
}), StorageErrorCode.NOT_SUPPORTED, options);
|
|
265
|
+
equal(await context.client.exists(key), true);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
await expectStorageError(() => context.client.deleteConditional(key, {
|
|
269
|
+
condition: { etag: staleEtag(etag) },
|
|
270
|
+
}), StorageErrorCode.CONFLICT, options);
|
|
271
|
+
equal(await context.client.exists(key), true);
|
|
272
|
+
await context.client.deleteConditional(key, { condition: { etag } });
|
|
273
|
+
equal(await context.client.exists(key), false);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
function conditionalReadEtagCase(options) {
|
|
277
|
+
const supported = options.expected.conditionalRead?.etag === true;
|
|
278
|
+
return providerCase(options, supported
|
|
279
|
+
? 'reads only the requested ETag identity'
|
|
280
|
+
: 'fails closed when ETag-conditional read is unsupported', async (context) => {
|
|
281
|
+
const key = context.key('conditional-read-etag.txt');
|
|
282
|
+
const original = await seed(context, key, 'observed');
|
|
283
|
+
const etag = await resultEtag(context.client, key, original);
|
|
284
|
+
if (!supported) {
|
|
285
|
+
await expectStorageError(() => context.client.downloadConditional(key, {
|
|
286
|
+
condition: { etag },
|
|
287
|
+
}), StorageErrorCode.NOT_SUPPORTED, options);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
equal(await objectText(await context.client.downloadConditional(key, {
|
|
291
|
+
condition: { etag },
|
|
292
|
+
})), 'observed');
|
|
293
|
+
await seed(context, key, 'changed');
|
|
294
|
+
await expectStorageError(() => context.client.downloadConditional(key, {
|
|
295
|
+
condition: { etag },
|
|
296
|
+
}), StorageErrorCode.CONFLICT, options);
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
function conditionalReadVersionCase(options) {
|
|
300
|
+
const supported = options.expected.conditionalRead?.version === true;
|
|
301
|
+
return providerCase(options, supported
|
|
302
|
+
? 'reads an immutable provider version'
|
|
303
|
+
: 'fails closed when version-conditional read is unsupported', async (context) => {
|
|
304
|
+
const key = context.key('conditional-read-version.txt');
|
|
305
|
+
await seed(context, key, 'version-one');
|
|
306
|
+
if (!supported) {
|
|
307
|
+
await expectStorageError(() => context.client.downloadConditional(key, {
|
|
308
|
+
condition: { version: 'unsupported-version' },
|
|
309
|
+
}), StorageErrorCode.NOT_SUPPORTED, options);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const version = await context.resolveVersion(key);
|
|
313
|
+
if (version === undefined) {
|
|
314
|
+
return skippedVersion(options.provider);
|
|
315
|
+
}
|
|
316
|
+
await seed(context, key, 'version-two');
|
|
317
|
+
equal(await objectText(await context.client.downloadConditional(key, {
|
|
318
|
+
condition: { version },
|
|
319
|
+
})), 'version-one');
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
function conditionalCopySourceEtagCase(options) {
|
|
323
|
+
const supported = options.expected.conditionalCopySource?.etag === true;
|
|
324
|
+
return providerCase(options, supported
|
|
325
|
+
? 'copies only the requested source ETag'
|
|
326
|
+
: 'fails closed when source-ETag copy is unsupported', async (context) => {
|
|
327
|
+
const source = context.key('copy-source-etag.txt');
|
|
328
|
+
const destination = context.key('copy-source-etag-result.txt');
|
|
329
|
+
const original = await seed(context, source, 'source-one');
|
|
330
|
+
const etag = await resultEtag(context.client, source, original);
|
|
331
|
+
if (!supported) {
|
|
332
|
+
await expectStorageError(() => context.client.promote(source, destination, { sourceEtag: etag }), StorageErrorCode.NOT_SUPPORTED, options);
|
|
333
|
+
equal(await context.client.exists(destination), false);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
await context.client.promote(source, destination, { sourceEtag: etag });
|
|
337
|
+
equal(await readText(context.client, destination), 'source-one');
|
|
338
|
+
const staleDestination = context.key('copy-source-etag-stale.txt');
|
|
339
|
+
await seed(context, source, 'source-two');
|
|
340
|
+
await expectStorageError(() => context.client.promote(source, staleDestination, {
|
|
341
|
+
sourceEtag: etag,
|
|
342
|
+
}), StorageErrorCode.CONFLICT, options);
|
|
343
|
+
equal(await context.client.exists(staleDestination), false);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
function conditionalCopySourceVersionCase(options) {
|
|
347
|
+
const supported = options.expected.conditionalCopySource?.version === true;
|
|
348
|
+
return providerCase(options, supported
|
|
349
|
+
? 'copies an immutable source version'
|
|
350
|
+
: 'fails closed when source-version copy is unsupported', async (context) => {
|
|
351
|
+
const source = context.key('copy-source-version.txt');
|
|
352
|
+
const destination = context.key('copy-source-version-result.txt');
|
|
353
|
+
await seed(context, source, 'source-version-one');
|
|
354
|
+
if (!supported) {
|
|
355
|
+
await expectStorageError(() => context.client.promote(source, destination, {
|
|
356
|
+
sourceVersion: 'unsupported-version',
|
|
357
|
+
}), StorageErrorCode.NOT_SUPPORTED, options);
|
|
358
|
+
equal(await context.client.exists(destination), false);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const version = await context.resolveVersion(source);
|
|
362
|
+
if (version === undefined) {
|
|
363
|
+
return skippedVersion(options.provider);
|
|
364
|
+
}
|
|
365
|
+
await seed(context, source, 'source-version-two');
|
|
366
|
+
await context.client.promote(source, destination, {
|
|
367
|
+
sourceVersion: version,
|
|
368
|
+
});
|
|
369
|
+
equal(await readText(context.client, destination), 'source-version-one');
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
function conditionalCopyDestinationCreateCase(options) {
|
|
373
|
+
const supported = options.expected.conditionalCopyDestination?.create === true;
|
|
374
|
+
return providerCase(options, supported
|
|
375
|
+
? 'enforces create-only copy at the destination'
|
|
376
|
+
: 'fails closed when create-only destination copy is unsupported', async (context) => {
|
|
377
|
+
const source = context.key('copy-destination-create-source.txt');
|
|
378
|
+
const destination = context.key('copy-destination-create-result.txt');
|
|
379
|
+
await seed(context, source, 'copy-created');
|
|
380
|
+
if (!supported) {
|
|
381
|
+
await expectStorageError(() => context.client.promote(source, destination, {
|
|
382
|
+
destination: { type: 'create' },
|
|
383
|
+
}), StorageErrorCode.NOT_SUPPORTED, options);
|
|
384
|
+
equal(await context.client.exists(destination), false);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
await context.client.promote(source, destination, {
|
|
388
|
+
destination: { type: 'create' },
|
|
389
|
+
});
|
|
390
|
+
equal(await readText(context.client, destination), 'copy-created');
|
|
391
|
+
await expectStorageError(() => context.client.promote(source, destination, {
|
|
392
|
+
destination: { type: 'create' },
|
|
393
|
+
}), StorageErrorCode.CONFLICT, options);
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
function conditionalCopyDestinationReplaceCase(options) {
|
|
397
|
+
const supported = options.expected.conditionalCopyDestination?.replace === true;
|
|
398
|
+
return providerCase(options, supported
|
|
399
|
+
? 'enforces ETag replacement at the copy destination'
|
|
400
|
+
: 'fails closed when destination replacement copy is unsupported', async (context) => {
|
|
401
|
+
const source = context.key('copy-destination-replace-source.txt');
|
|
402
|
+
const destination = context.key('copy-destination-replace-result.txt');
|
|
403
|
+
await seed(context, source, 'replacement');
|
|
404
|
+
const original = await seed(context, destination, 'destination-old');
|
|
405
|
+
const etag = await resultEtag(context.client, destination, original);
|
|
406
|
+
if (!supported) {
|
|
407
|
+
await expectStorageError(() => context.client.promote(source, destination, {
|
|
408
|
+
destination: { etag, type: 'replace' },
|
|
409
|
+
}), StorageErrorCode.NOT_SUPPORTED, options);
|
|
410
|
+
equal(await readText(context.client, destination), 'destination-old');
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
await context.client.promote(source, destination, {
|
|
414
|
+
destination: { etag, type: 'replace' },
|
|
415
|
+
});
|
|
416
|
+
equal(await readText(context.client, destination), 'replacement');
|
|
417
|
+
await expectStorageError(() => context.client.promote(source, destination, {
|
|
418
|
+
destination: { etag, type: 'replace' },
|
|
419
|
+
}), StorageErrorCode.CONFLICT, options);
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
function conditionalCopyAtomicityCases(options) {
|
|
423
|
+
const destination = options.expected.conditionalCopyDestination;
|
|
424
|
+
const source = options.expected.conditionalCopySource;
|
|
425
|
+
const sources = [
|
|
426
|
+
...(source?.etag === true ? ['etag'] : []),
|
|
427
|
+
...(source?.version === true ? ['version'] : []),
|
|
428
|
+
];
|
|
429
|
+
const destinations = [
|
|
430
|
+
...(destination?.create === true ? ['create'] : []),
|
|
431
|
+
...(destination?.replace === true ? ['replace'] : []),
|
|
432
|
+
];
|
|
433
|
+
if (sources.length === 0 || destinations.length === 0) {
|
|
434
|
+
return [conditionalCopyNonAtomicCase(options, 'etag', 'create')];
|
|
435
|
+
}
|
|
436
|
+
if (destination?.atomicWithSource !== true) {
|
|
437
|
+
return sources.flatMap((sourcePredicate) => destinations.map((destinationPredicate) => conditionalCopyNonAtomicCase(options, sourcePredicate, destinationPredicate)));
|
|
438
|
+
}
|
|
439
|
+
return sources.flatMap((sourcePredicate) => destinations.map((destinationPredicate) => providerCase(options, `combines ${sourcePredicate} source and ${destinationPredicate} destination copy predicates atomically`, (context) => verifyAtomicCopyCombination(context, options, sourcePredicate, destinationPredicate))));
|
|
440
|
+
}
|
|
441
|
+
async function verifyAtomicCopyCombination(context, options, sourcePredicate, destinationPredicate) {
|
|
442
|
+
const label = `${sourcePredicate}-${destinationPredicate}`;
|
|
443
|
+
const sourceKey = context.key(`copy-atomic-${label}-source.txt`);
|
|
444
|
+
const destinationKey = context.key(`copy-atomic-${label}-result.txt`);
|
|
445
|
+
const original = await seed(context, sourceKey, 'source-original');
|
|
446
|
+
const originalSource = await sourceCopyCondition(context, sourceKey, original, sourcePredicate);
|
|
447
|
+
if (originalSource === undefined)
|
|
448
|
+
return skippedVersion(options.provider);
|
|
449
|
+
const validDestination = await destinationCopyCondition(context, destinationKey, destinationPredicate, 'destination-original');
|
|
450
|
+
const staleSource = sourcePredicate === 'etag'
|
|
451
|
+
? originalSource
|
|
452
|
+
: { sourceVersion: `missing-${randomUUID()}` };
|
|
453
|
+
if (sourcePredicate === 'etag') {
|
|
454
|
+
await seed(context, sourceKey, 'source-current');
|
|
455
|
+
}
|
|
456
|
+
await expectAnyStorageError(() => context.client.promote(sourceKey, destinationKey, {
|
|
457
|
+
...staleSource,
|
|
458
|
+
...validDestination,
|
|
459
|
+
}), options);
|
|
460
|
+
await assertDestinationContent(context, destinationKey, destinationPredicate, 'destination-original');
|
|
461
|
+
const currentSource = await sourceCopyCondition(context, sourceKey, await context.client.head(sourceKey), sourcePredicate);
|
|
462
|
+
if (currentSource === undefined)
|
|
463
|
+
return skippedVersion(options.provider);
|
|
464
|
+
const staleDestination = await staleDestinationCopyCondition(context, destinationKey, destinationPredicate);
|
|
465
|
+
await expectStorageError(() => context.client.promote(sourceKey, destinationKey, {
|
|
466
|
+
...currentSource,
|
|
467
|
+
...staleDestination,
|
|
468
|
+
}), StorageErrorCode.CONFLICT, options);
|
|
469
|
+
equal(await readText(context.client, destinationKey), 'destination-raced');
|
|
470
|
+
const successDestination = await currentDestinationCopyCondition(context, destinationKey, destinationPredicate);
|
|
471
|
+
await context.client.promote(sourceKey, destinationKey, {
|
|
472
|
+
...currentSource,
|
|
473
|
+
...successDestination,
|
|
474
|
+
});
|
|
475
|
+
equal(await readText(context.client, destinationKey), sourcePredicate === 'etag' ? 'source-current' : 'source-original');
|
|
476
|
+
await verifyAtomicCopyRace(context, options, sourcePredicate, destinationPredicate, label);
|
|
477
|
+
}
|
|
478
|
+
async function verifyAtomicCopyRace(context, options, sourcePredicate, destinationPredicate, label) {
|
|
479
|
+
const staleSourceKey = context.key(`copy-race-${label}-stale.txt`);
|
|
480
|
+
const validSourceKey = context.key(`copy-race-${label}-valid.txt`);
|
|
481
|
+
const destinationKey = context.key(`copy-race-${label}-destination.txt`);
|
|
482
|
+
const staleResult = await seed(context, staleSourceKey, 'stale-original');
|
|
483
|
+
const observedStale = await sourceCopyCondition(context, staleSourceKey, staleResult, sourcePredicate);
|
|
484
|
+
ok(observedStale !== undefined, 'versioned provider stopped resolving versions during the atomicity race');
|
|
485
|
+
const staleCondition = sourcePredicate === 'etag'
|
|
486
|
+
? observedStale
|
|
487
|
+
: { sourceVersion: `missing-${randomUUID()}` };
|
|
488
|
+
if (sourcePredicate === 'etag') {
|
|
489
|
+
await seed(context, staleSourceKey, 'stale-current');
|
|
490
|
+
}
|
|
491
|
+
const validResult = await seed(context, validSourceKey, 'race-winner');
|
|
492
|
+
const validCondition = await sourceCopyCondition(context, validSourceKey, validResult, sourcePredicate);
|
|
493
|
+
ok(validCondition !== undefined, 'versioned provider did not resolve the valid race source version');
|
|
494
|
+
const destinationCondition = await destinationCopyCondition(context, destinationKey, destinationPredicate, 'race-destination');
|
|
495
|
+
const staleAttempt = expectAnyStorageError(() => context.client.promote(staleSourceKey, destinationKey, {
|
|
496
|
+
...staleCondition,
|
|
497
|
+
...destinationCondition,
|
|
498
|
+
}), options);
|
|
499
|
+
await Promise.resolve();
|
|
500
|
+
const validAttempt = context.client.promote(validSourceKey, destinationKey, {
|
|
501
|
+
...validCondition,
|
|
502
|
+
...destinationCondition,
|
|
503
|
+
});
|
|
504
|
+
await Promise.all([staleAttempt, validAttempt]);
|
|
505
|
+
equal(await readText(context.client, destinationKey), 'race-winner');
|
|
506
|
+
}
|
|
507
|
+
async function sourceCopyCondition(context, key, result, predicate) {
|
|
508
|
+
if (predicate === 'etag') {
|
|
509
|
+
return {
|
|
510
|
+
sourceEtag: await resultEtag(context.client, key, result),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
const version = await context.resolveVersion(key);
|
|
514
|
+
return version === undefined ? undefined : { sourceVersion: version };
|
|
515
|
+
}
|
|
516
|
+
async function destinationCopyCondition(context, key, predicate, body) {
|
|
517
|
+
if (predicate === 'create') {
|
|
518
|
+
return { destination: { type: 'create' } };
|
|
519
|
+
}
|
|
520
|
+
const result = await seed(context, key, body);
|
|
521
|
+
return {
|
|
522
|
+
destination: {
|
|
523
|
+
etag: await resultEtag(context.client, key, result),
|
|
524
|
+
type: 'replace',
|
|
525
|
+
},
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
async function staleDestinationCopyCondition(context, key, predicate) {
|
|
529
|
+
if (predicate === 'create') {
|
|
530
|
+
await seed(context, key, 'destination-raced');
|
|
531
|
+
return { destination: { type: 'create' } };
|
|
532
|
+
}
|
|
533
|
+
const observed = await context.client.head(key);
|
|
534
|
+
const etag = await resultEtag(context.client, key, observed);
|
|
535
|
+
await seed(context, key, 'destination-raced');
|
|
536
|
+
return { destination: { etag, type: 'replace' } };
|
|
537
|
+
}
|
|
538
|
+
async function currentDestinationCopyCondition(context, key, predicate) {
|
|
539
|
+
if (predicate === 'create') {
|
|
540
|
+
await context.client.delete(key);
|
|
541
|
+
return { destination: { type: 'create' } };
|
|
542
|
+
}
|
|
543
|
+
const current = await context.client.head(key);
|
|
544
|
+
return {
|
|
545
|
+
destination: {
|
|
546
|
+
etag: await resultEtag(context.client, key, current),
|
|
547
|
+
type: 'replace',
|
|
548
|
+
},
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
async function assertDestinationContent(context, key, predicate, expected) {
|
|
552
|
+
if (predicate === 'create') {
|
|
553
|
+
equal(await context.client.exists(key), false);
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
equal(await readText(context.client, key), expected);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
function conditionalCopyNonAtomicCase(options, sourcePredicate, destinationPredicate) {
|
|
560
|
+
return providerCase(options, `fails closed for combined ${sourcePredicate} source and ${destinationPredicate} destination when copy predicates are not atomic`, async (context) => {
|
|
561
|
+
const sourceKey = context.key('copy-atomic-source.txt');
|
|
562
|
+
const destinationKey = context.key('copy-atomic-result.txt');
|
|
563
|
+
const original = await seed(context, sourceKey, 'atomic-copy');
|
|
564
|
+
let sourceCondition;
|
|
565
|
+
if (sourcePredicate === 'version') {
|
|
566
|
+
sourceCondition = { sourceVersion: 'unsupported-version' };
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
sourceCondition = {
|
|
570
|
+
sourceEtag: await resultEtag(context.client, sourceKey, original),
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
const destinationCondition = destinationPredicate === 'replace'
|
|
574
|
+
? {
|
|
575
|
+
destination: {
|
|
576
|
+
etag: await resultEtag(context.client, destinationKey, await seed(context, destinationKey, 'destination-old')),
|
|
577
|
+
type: 'replace',
|
|
578
|
+
},
|
|
579
|
+
}
|
|
580
|
+
: { destination: { type: 'create' } };
|
|
581
|
+
const promotion = { ...sourceCondition, ...destinationCondition };
|
|
582
|
+
const dispatchedBefore = context.dispatchCount?.();
|
|
583
|
+
await expectStorageError(() => context.client.promote(sourceKey, destinationKey, promotion), StorageErrorCode.NOT_SUPPORTED, options);
|
|
584
|
+
if (dispatchedBefore !== undefined) {
|
|
585
|
+
equal(context.dispatchCount?.(), dispatchedBefore, 'non-atomic combined promotion reached the provider driver');
|
|
586
|
+
}
|
|
587
|
+
if (destinationPredicate === 'replace') {
|
|
588
|
+
equal(await readText(context.client, destinationKey), 'destination-old');
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
equal(await context.client.exists(destinationKey), false);
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
function conditionalMultipartCreateCase(options) {
|
|
596
|
+
const supported = options.expected.conditionalMultipartCompletion?.create === true;
|
|
597
|
+
return providerCase(options, supported
|
|
598
|
+
? 'enforces create-only multipart completion'
|
|
599
|
+
: 'fails closed when conditional multipart create is unsupported', async (context) => {
|
|
600
|
+
const key = context.key('multipart-create.bin');
|
|
601
|
+
if (!supported) {
|
|
602
|
+
await expectStorageError(() => context.client.uploadConditional(key, 'blocked', {
|
|
603
|
+
condition: { type: 'create' },
|
|
604
|
+
multipart: true,
|
|
605
|
+
}), StorageErrorCode.NOT_SUPPORTED, options);
|
|
606
|
+
equal(await context.client.exists(key), false);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
const body = multipartBody(options);
|
|
610
|
+
const result = await context.client.uploadConditional(key, body, {
|
|
611
|
+
condition: { type: 'create' },
|
|
612
|
+
multipart: { concurrency: 1, partSize: 5 * 1024 * 1024 },
|
|
613
|
+
});
|
|
614
|
+
assertResultEtag(result, options.expected.conditionalCreate?.resultEtag === true);
|
|
615
|
+
await expectStorageError(() => context.client.uploadConditional(key, body, {
|
|
616
|
+
condition: { type: 'create' },
|
|
617
|
+
multipart: { concurrency: 1, partSize: 5 * 1024 * 1024 },
|
|
618
|
+
}), StorageErrorCode.CONFLICT, options);
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
function conditionalMultipartReplaceCase(options) {
|
|
622
|
+
const supported = options.expected.conditionalMultipartCompletion?.replace === true;
|
|
623
|
+
return providerCase(options, supported
|
|
624
|
+
? 'enforces ETag-conditional multipart replacement'
|
|
625
|
+
: 'fails closed when conditional multipart replace is unsupported', async (context) => {
|
|
626
|
+
const key = context.key('multipart-replace.bin');
|
|
627
|
+
const original = await seed(context, key, 'multipart-original');
|
|
628
|
+
const etag = await resultEtag(context.client, key, original);
|
|
629
|
+
if (!supported) {
|
|
630
|
+
await expectStorageError(() => context.client.uploadConditional(key, 'blocked', {
|
|
631
|
+
condition: { etag, type: 'replace' },
|
|
632
|
+
multipart: true,
|
|
633
|
+
}), StorageErrorCode.NOT_SUPPORTED, options);
|
|
634
|
+
equal(await readText(context.client, key), 'multipart-original');
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
const body = multipartBody(options);
|
|
638
|
+
const result = await context.client.uploadConditional(key, body, {
|
|
639
|
+
condition: { etag, type: 'replace' },
|
|
640
|
+
multipart: { concurrency: 1, partSize: 5 * 1024 * 1024 },
|
|
641
|
+
});
|
|
642
|
+
assertResultEtag(result, options.expected.conditionalReplace?.resultEtag === true);
|
|
643
|
+
await expectStorageError(() => context.client.uploadConditional(key, body, {
|
|
644
|
+
condition: { etag, type: 'replace' },
|
|
645
|
+
multipart: { concurrency: 1, partSize: 5 * 1024 * 1024 },
|
|
646
|
+
}), StorageErrorCode.CONFLICT, options);
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
function providerCase(options, name, operation) {
|
|
650
|
+
return Object.freeze({
|
|
651
|
+
name,
|
|
652
|
+
async run() {
|
|
653
|
+
return withFixture(options, operation);
|
|
654
|
+
},
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
async function withFixture(options, operation) {
|
|
658
|
+
const fixture = await options.createFixture();
|
|
659
|
+
const keys = new Set();
|
|
660
|
+
const replicas = [];
|
|
661
|
+
const namespace = `nestm-conformance/${safeSegment(options.provider)}/${randomUUID()}`;
|
|
662
|
+
const context = {
|
|
663
|
+
client: fixture.client,
|
|
664
|
+
...(fixture.dispatchCount === undefined
|
|
665
|
+
? {}
|
|
666
|
+
: { dispatchCount: fixture.dispatchCount }),
|
|
667
|
+
async createReplica() {
|
|
668
|
+
const replica = await fixture.createReplica();
|
|
669
|
+
ok(replica !== fixture.client && !replicas.includes(replica), 'createReplica must return a fresh StorageClient instance');
|
|
670
|
+
replicas.push(replica);
|
|
671
|
+
equal(replica.name, fixture.client.name, 'createReplica must address the same logical store name');
|
|
672
|
+
return replica;
|
|
673
|
+
},
|
|
674
|
+
key(label) {
|
|
675
|
+
const key = `${namespace}/${label}`;
|
|
676
|
+
keys.add(key);
|
|
677
|
+
return key;
|
|
678
|
+
},
|
|
679
|
+
resolveVersion(key) {
|
|
680
|
+
return fixture.resolveVersion?.(key) ?? Promise.resolve(undefined);
|
|
681
|
+
},
|
|
682
|
+
track(key) {
|
|
683
|
+
keys.add(key);
|
|
684
|
+
return key;
|
|
685
|
+
},
|
|
686
|
+
};
|
|
687
|
+
const noError = Symbol('no-error');
|
|
688
|
+
let operationError = noError;
|
|
689
|
+
let result = PASSED;
|
|
690
|
+
try {
|
|
691
|
+
result = (await operation(context)) ?? PASSED;
|
|
692
|
+
}
|
|
693
|
+
catch (error) {
|
|
694
|
+
operationError = error;
|
|
695
|
+
}
|
|
696
|
+
let cleanupError = noError;
|
|
697
|
+
try {
|
|
698
|
+
if (fixture.cleanup === undefined) {
|
|
699
|
+
await cleanupWithClient(fixture.client, [...keys]);
|
|
700
|
+
}
|
|
701
|
+
else {
|
|
702
|
+
await fixture.cleanup([...keys]);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
catch (error) {
|
|
706
|
+
cleanupError = error;
|
|
707
|
+
}
|
|
708
|
+
for (const replica of replicas.reverse()) {
|
|
709
|
+
try {
|
|
710
|
+
await replica.onApplicationShutdown();
|
|
711
|
+
}
|
|
712
|
+
catch (error) {
|
|
713
|
+
if (cleanupError === noError)
|
|
714
|
+
cleanupError = error;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
try {
|
|
718
|
+
await fixture.client.onApplicationShutdown();
|
|
719
|
+
}
|
|
720
|
+
catch (error) {
|
|
721
|
+
if (cleanupError === noError)
|
|
722
|
+
cleanupError = error;
|
|
723
|
+
}
|
|
724
|
+
try {
|
|
725
|
+
await fixture.close?.();
|
|
726
|
+
}
|
|
727
|
+
catch (error) {
|
|
728
|
+
if (cleanupError === noError)
|
|
729
|
+
cleanupError = error;
|
|
730
|
+
}
|
|
731
|
+
if (operationError !== noError)
|
|
732
|
+
throw operationError;
|
|
733
|
+
if (cleanupError !== noError)
|
|
734
|
+
throw cleanupError;
|
|
735
|
+
return result;
|
|
736
|
+
}
|
|
737
|
+
async function cleanupWithClient(client, keys) {
|
|
738
|
+
for (const key of keys) {
|
|
739
|
+
try {
|
|
740
|
+
await client.delete(key);
|
|
741
|
+
}
|
|
742
|
+
catch (error) {
|
|
743
|
+
if (!isStorageError(error) || error.code !== StorageErrorCode.NOT_FOUND) {
|
|
744
|
+
throw error;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
function conditionalCapabilitiesOf(capabilities) {
|
|
750
|
+
const conditional = {};
|
|
751
|
+
for (const key of CONDITIONAL_CAPABILITY_KEYS) {
|
|
752
|
+
const capability = capabilities[key];
|
|
753
|
+
if (capability !== undefined) {
|
|
754
|
+
Object.assign(conditional, { [key]: capability });
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
ok(capabilities.physicalKey !== undefined, 'provider must declare its complete physical-key byte budget');
|
|
758
|
+
return {
|
|
759
|
+
...conditional,
|
|
760
|
+
physicalKey: capabilities.physicalKey,
|
|
761
|
+
...(capabilities.signedUploadPolicy === undefined
|
|
762
|
+
? {}
|
|
763
|
+
: { signedUploadPolicy: capabilities.signedUploadPolicy }),
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
async function seed(context, key, body) {
|
|
767
|
+
context.track(key);
|
|
768
|
+
const result = await context.client.upload(key, body);
|
|
769
|
+
assertOptionalResultEtag(result, 'provider upload returned a non-canonical ETag');
|
|
770
|
+
return result;
|
|
771
|
+
}
|
|
772
|
+
async function resultEtag(client, key, result) {
|
|
773
|
+
const etag = result.etag ?? (await client.head(key)).etag;
|
|
774
|
+
ok(isCanonicalStorageEtag(etag), 'provider did not return a canonical bare strong ETag');
|
|
775
|
+
return etag;
|
|
776
|
+
}
|
|
777
|
+
function assertResultEtag(result, required) {
|
|
778
|
+
assertOptionalResultEtag(result, 'conditional write returned a non-canonical ETag');
|
|
779
|
+
if (required) {
|
|
780
|
+
ok(isCanonicalStorageEtag(result.etag), 'conditional write committed without the advertised result ETag');
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
function assertOptionalResultEtag(result, message) {
|
|
784
|
+
ok(result.etag === undefined || isCanonicalStorageEtag(result.etag), message);
|
|
785
|
+
}
|
|
786
|
+
async function readText(client, key) {
|
|
787
|
+
return objectText(await client.downloadStream(key));
|
|
788
|
+
}
|
|
789
|
+
async function objectText(object) {
|
|
790
|
+
assertOptionalResultEtag(object, 'provider download returned a non-canonical ETag');
|
|
791
|
+
return new Response(object.body).text();
|
|
792
|
+
}
|
|
793
|
+
async function expectStorageError(operation, code, options) {
|
|
794
|
+
try {
|
|
795
|
+
await operation();
|
|
796
|
+
}
|
|
797
|
+
catch (error) {
|
|
798
|
+
ok(isStorageError(error), 'provider operation did not return StorageError');
|
|
799
|
+
equal(error.code, code);
|
|
800
|
+
assertSanitizedError(error, options.forbiddenErrorValues ?? []);
|
|
801
|
+
return error;
|
|
802
|
+
}
|
|
803
|
+
fail(`provider operation unexpectedly succeeded; expected ${code}`);
|
|
804
|
+
}
|
|
805
|
+
async function expectAnyStorageError(operation, options) {
|
|
806
|
+
try {
|
|
807
|
+
await operation();
|
|
808
|
+
}
|
|
809
|
+
catch (error) {
|
|
810
|
+
ok(isStorageError(error), 'provider operation did not return StorageError');
|
|
811
|
+
assertSanitizedError(error, options.forbiddenErrorValues ?? []);
|
|
812
|
+
return error;
|
|
813
|
+
}
|
|
814
|
+
fail('provider operation unexpectedly succeeded');
|
|
815
|
+
}
|
|
816
|
+
function assertSanitizedError(error, forbiddenValues) {
|
|
817
|
+
equal(error.cause, undefined, 'public storage error retained a raw cause');
|
|
818
|
+
ok(error.message.length <= 512, 'public storage error is unexpectedly large');
|
|
819
|
+
match(error.message, /^[^\r\n]*$/u, 'public storage error contains line breaks');
|
|
820
|
+
ok(!/<(?:Error|Code|Message|RequestId|HostId)>/iu.test(error.message), 'public storage error contains a serialized provider response');
|
|
821
|
+
const serialized = `${inspect(error, { depth: null })}\n${JSON.stringify({ error })}`;
|
|
822
|
+
ok(!/(?:\$metadata|request.?id|host.?id|<Error>|<Message>)/iu.test(serialized), 'public storage error serialization contains provider response metadata');
|
|
823
|
+
for (const value of forbiddenValues) {
|
|
824
|
+
if (value.length > 0) {
|
|
825
|
+
ok(!serialized.includes(value), 'public storage error serialization contains a forbidden provider value');
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
function staleEtag(etag) {
|
|
830
|
+
return etag === 'nestm-conformance-stale-etag'
|
|
831
|
+
? 'nestm-conformance-other-etag'
|
|
832
|
+
: 'nestm-conformance-stale-etag';
|
|
833
|
+
}
|
|
834
|
+
function multipartBody(options) {
|
|
835
|
+
return new Uint8Array(options.multipartBytes ?? DEFAULT_MULTIPART_BYTES);
|
|
836
|
+
}
|
|
837
|
+
function skippedVersion(provider) {
|
|
838
|
+
return {
|
|
839
|
+
reason: `${provider} returned no version ID; use a dedicated version-enabled bucket to exercise this advertised subcapability`,
|
|
840
|
+
status: 'skipped',
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
function positiveSafeInteger(value, label) {
|
|
844
|
+
ok(Number.isSafeInteger(value) && value > 0, `${label} must be a positive safe integer`);
|
|
845
|
+
}
|
|
846
|
+
function safeSegment(value) {
|
|
847
|
+
return (value
|
|
848
|
+
.trim()
|
|
849
|
+
.toLowerCase()
|
|
850
|
+
.replace(/[^a-z0-9_-]+/gu, '-')
|
|
851
|
+
.replace(/^-+|-+$/gu, '') || 'provider');
|
|
852
|
+
}
|
|
853
|
+
//# sourceMappingURL=provider-conformance.js.map
|