@kutur-kit/assets 0.0.0-stage → 0.1.0-alpha.1
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 +161 -2
- package/dist/Asset.d.ts +174 -0
- package/dist/Asset.d.ts.map +1 -0
- package/dist/Asset.js +156 -0
- package/dist/Asset.js.map +1 -0
- package/dist/AssetBucket.d.ts +73 -0
- package/dist/AssetBucket.d.ts.map +1 -0
- package/dist/AssetBucket.js +19 -0
- package/dist/AssetBucket.js.map +1 -0
- package/dist/AssetDelivery.d.ts +23 -0
- package/dist/AssetDelivery.d.ts.map +1 -0
- package/dist/AssetDelivery.js +12 -0
- package/dist/AssetDelivery.js.map +1 -0
- package/dist/AssetSigning.d.ts +109 -0
- package/dist/AssetSigning.d.ts.map +1 -0
- package/dist/AssetSigning.js +93 -0
- package/dist/AssetSigning.js.map +1 -0
- package/dist/alchemy/r2.d.ts +40 -0
- package/dist/alchemy/r2.d.ts.map +1 -0
- package/dist/alchemy/r2.js +83 -0
- package/dist/alchemy/r2.js.map +1 -0
- package/dist/delivery/local.d.ts +17 -0
- package/dist/delivery/local.d.ts.map +1 -0
- package/dist/delivery/local.js +20 -0
- package/dist/delivery/local.js.map +1 -0
- package/dist/filesystem.d.ts +15 -0
- package/dist/filesystem.d.ts.map +1 -0
- package/dist/filesystem.js +90 -0
- package/dist/filesystem.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/bytes.d.ts +3 -0
- package/dist/internal/bytes.d.ts.map +1 -0
- package/dist/internal/bytes.js +11 -0
- package/dist/internal/bytes.js.map +1 -0
- package/dist/internal/http.d.ts +17 -0
- package/dist/internal/http.d.ts.map +1 -0
- package/dist/internal/http.js +38 -0
- package/dist/internal/http.js.map +1 -0
- package/dist/internal/sigv4.d.ts +15 -0
- package/dist/internal/sigv4.d.ts.map +1 -0
- package/dist/internal/sigv4.js +52 -0
- package/dist/internal/sigv4.js.map +1 -0
- package/dist/internal/url.d.ts +3 -0
- package/dist/internal/url.d.ts.map +1 -0
- package/dist/internal/url.js +3 -0
- package/dist/internal/url.js.map +1 -0
- package/dist/memory.d.ts +11 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +34 -0
- package/dist/memory.js.map +1 -0
- package/dist/s3.d.ts +42 -0
- package/dist/s3.d.ts.map +1 -0
- package/dist/s3.js +67 -0
- package/dist/s3.js.map +1 -0
- package/dist/signing/local.d.ts +27 -0
- package/dist/signing/local.d.ts.map +1 -0
- package/dist/signing/local.js +128 -0
- package/dist/signing/local.js.map +1 -0
- package/dist/testing/AssetBucketContract.d.ts +18 -0
- package/dist/testing/AssetBucketContract.d.ts.map +1 -0
- package/dist/testing/AssetBucketContract.js +124 -0
- package/dist/testing/AssetBucketContract.js.map +1 -0
- package/dist/testing/AssetSigningContract.d.ts +18 -0
- package/dist/testing/AssetSigningContract.d.ts.map +1 -0
- package/dist/testing/AssetSigningContract.js +132 -0
- package/dist/testing/AssetSigningContract.js.map +1 -0
- package/dist/testing/index.d.ts +3 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +3 -0
- package/dist/testing/index.js.map +1 -0
- package/package.json +109 -3
- package/src/Asset.ts +443 -0
- package/src/AssetBucket.ts +82 -0
- package/src/AssetDelivery.ts +41 -0
- package/src/AssetSigning.ts +206 -0
- package/src/alchemy/r2.ts +149 -0
- package/src/delivery/local.ts +41 -0
- package/src/filesystem.ts +166 -0
- package/src/index.ts +3 -0
- package/src/internal/bytes.ts +12 -0
- package/src/internal/http.ts +54 -0
- package/src/internal/sigv4.ts +84 -0
- package/src/internal/url.ts +2 -0
- package/src/memory.ts +65 -0
- package/src/s3.ts +138 -0
- package/src/signing/local.ts +210 -0
- package/src/testing/AssetBucketContract.ts +215 -0
- package/src/testing/AssetSigningContract.ts +254 -0
- package/src/testing/index.ts +3 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { ByteSize, Effect, Option, Result, Schema, Stream } from "effect";
|
|
2
|
+
|
|
3
|
+
import * as AssetBucket from "../AssetBucket.ts";
|
|
4
|
+
|
|
5
|
+
/** The bucket the cases use. Back it with the implementation under test. */
|
|
6
|
+
export class ContractBucket extends AssetBucket.Service<ContractBucket>()("assets-contract") {}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Checks an `AssetBucket.Bucket` implementation against the guarantees it
|
|
10
|
+
* documents. Cases overwrite or delete the keys they use, under `contract/`.
|
|
11
|
+
* Run each case with any test runner.
|
|
12
|
+
*/
|
|
13
|
+
export interface Case {
|
|
14
|
+
readonly name: string;
|
|
15
|
+
readonly run: Effect.Effect<void, never, ContractBucket>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const text = (value: string) => new TextEncoder().encode(value);
|
|
19
|
+
|
|
20
|
+
const put = (
|
|
21
|
+
key: string,
|
|
22
|
+
chunks: ReadonlyArray<string>,
|
|
23
|
+
contentType = "text/plain",
|
|
24
|
+
cacheControl?: string,
|
|
25
|
+
) =>
|
|
26
|
+
Effect.flatMap(ContractBucket, (bucket) =>
|
|
27
|
+
bucket.put({ key, contentType, cacheControl, body: Stream.fromIterable(chunks.map(text)) }),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
// The stored body as text, or null when the object is missing.
|
|
31
|
+
const read = (key: string) =>
|
|
32
|
+
Effect.flatMap(ContractBucket, (bucket) => bucket.get(key)).pipe(
|
|
33
|
+
Effect.flatMap(
|
|
34
|
+
Option.match({
|
|
35
|
+
onNone: () => Effect.succeed(null),
|
|
36
|
+
onSome: ({ body }) =>
|
|
37
|
+
Stream.runCollect(body).pipe(
|
|
38
|
+
Effect.map((chunks) => chunks.map((chunk) => new TextDecoder().decode(chunk)).join("")),
|
|
39
|
+
),
|
|
40
|
+
}),
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const head = (key: string) => Effect.flatMap(ContractBucket, (bucket) => bucket.head(key));
|
|
45
|
+
|
|
46
|
+
const remove = (key: string) => Effect.flatMap(ContractBucket, (bucket) => bucket.delete(key));
|
|
47
|
+
|
|
48
|
+
const expectEqual = <A>(label: string, expected: A, received: A) => {
|
|
49
|
+
const [want, got] = [expected, received].map((value) => JSON.stringify(value));
|
|
50
|
+
|
|
51
|
+
return want === got
|
|
52
|
+
? Effect.void
|
|
53
|
+
: Effect.die(
|
|
54
|
+
new Error(`AssetBucket contract violated: ${label}\nexpected: ${want}\nreceived: ${got}`),
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const expectBody = (key: string, expected: string | null) =>
|
|
59
|
+
Effect.flatMap(read(key), (body) => expectEqual(`body of ${key}`, expected, body));
|
|
60
|
+
|
|
61
|
+
// Encoding checks that sizes are ByteSize values and drops fields stores may add.
|
|
62
|
+
const encodeInfo = Schema.encodeEffect(
|
|
63
|
+
Schema.toCodecJson(
|
|
64
|
+
Schema.NullOr(
|
|
65
|
+
Schema.Struct({
|
|
66
|
+
size: Schema.ByteSize,
|
|
67
|
+
contentType: Schema.String,
|
|
68
|
+
cacheControl: Schema.optionalKey(Schema.String),
|
|
69
|
+
}),
|
|
70
|
+
),
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// No cache control compares equal whether a store omits it or sets it to undefined.
|
|
75
|
+
const comparable = (info: AssetBucket.Info | null) => {
|
|
76
|
+
if (info === null) return null;
|
|
77
|
+
|
|
78
|
+
const { size, contentType, cacheControl } = info;
|
|
79
|
+
|
|
80
|
+
return cacheControl === undefined ? { size, contentType } : { size, contentType, cacheControl };
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const expectInfo = (
|
|
84
|
+
label: string,
|
|
85
|
+
expected: AssetBucket.Info | null,
|
|
86
|
+
received: AssetBucket.Info | null,
|
|
87
|
+
) =>
|
|
88
|
+
Effect.flatMap(
|
|
89
|
+
Effect.all([encodeInfo(comparable(expected)), encodeInfo(comparable(received))]),
|
|
90
|
+
([want, got]) => expectEqual(label, want, got),
|
|
91
|
+
).pipe(Effect.orDie);
|
|
92
|
+
|
|
93
|
+
const expectHead = (key: string, expected: AssetBucket.Info | null) =>
|
|
94
|
+
Effect.flatMap(head(key), (info) =>
|
|
95
|
+
expectInfo(`head of ${key}`, expected, Option.getOrNull(info)),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const stored = "contract/stored.txt";
|
|
99
|
+
|
|
100
|
+
const nested = "contract/nested";
|
|
101
|
+
|
|
102
|
+
const child = "contract/nested/child";
|
|
103
|
+
|
|
104
|
+
export const cases: ReadonlyArray<Case> = [
|
|
105
|
+
{
|
|
106
|
+
name: "returns the stored bytes and info",
|
|
107
|
+
run: Effect.gen(function* () {
|
|
108
|
+
const info = yield* put(stored, ["hello, ", "", "wörld"], "text/plain; charset=utf-8");
|
|
109
|
+
const expected = { size: ByteSize.bytes(13), contentType: "text/plain; charset=utf-8" };
|
|
110
|
+
yield* expectInfo("put info", expected, info);
|
|
111
|
+
yield* expectHead(stored, expected);
|
|
112
|
+
yield* expectBody(stored, "hello, wörld");
|
|
113
|
+
|
|
114
|
+
const object = yield* Effect.flatMap(ContractBucket, (bucket) => bucket.get(stored));
|
|
115
|
+
|
|
116
|
+
yield* expectInfo(
|
|
117
|
+
`get info of ${stored}`,
|
|
118
|
+
expected,
|
|
119
|
+
Option.getOrNull(Option.map(object, ({ info }) => info)),
|
|
120
|
+
);
|
|
121
|
+
}).pipe(Effect.orDie),
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: "stores empty objects",
|
|
125
|
+
run: Effect.gen(function* () {
|
|
126
|
+
yield* put(stored, [], "application/octet-stream");
|
|
127
|
+
yield* expectHead(stored, { size: ByteSize.zero, contentType: "application/octet-stream" });
|
|
128
|
+
yield* expectBody(stored, "");
|
|
129
|
+
}).pipe(Effect.orDie),
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: "replaces an object on put",
|
|
133
|
+
run: Effect.gen(function* () {
|
|
134
|
+
yield* put(stored, ["first version"]);
|
|
135
|
+
yield* put(stored, ["second"], "text/markdown");
|
|
136
|
+
yield* expectHead(stored, { size: ByteSize.bytes(6), contentType: "text/markdown" });
|
|
137
|
+
yield* expectBody(stored, "second");
|
|
138
|
+
}).pipe(Effect.orDie),
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: "stores the cache control an object is served with, and replaces it on put",
|
|
142
|
+
run: Effect.gen(function* () {
|
|
143
|
+
const immutable = "public, max-age=31536000, immutable";
|
|
144
|
+
const info = yield* put(stored, ["cached"], "text/plain", immutable);
|
|
145
|
+
|
|
146
|
+
const expected = {
|
|
147
|
+
size: ByteSize.bytes(6),
|
|
148
|
+
contentType: "text/plain",
|
|
149
|
+
cacheControl: immutable,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
yield* expectInfo("put info", expected, info);
|
|
153
|
+
yield* expectHead(stored, expected);
|
|
154
|
+
|
|
155
|
+
const object = yield* Effect.flatMap(ContractBucket, (bucket) => bucket.get(stored));
|
|
156
|
+
|
|
157
|
+
yield* expectInfo(
|
|
158
|
+
`get info of ${stored}`,
|
|
159
|
+
expected,
|
|
160
|
+
Option.getOrNull(Option.map(object, ({ info }) => info)),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
yield* put(stored, ["uncached"]);
|
|
164
|
+
yield* expectHead(stored, { size: ByteSize.bytes(8), contentType: "text/plain" });
|
|
165
|
+
}).pipe(Effect.orDie),
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: "keeps a key and the keys nested under it apart",
|
|
169
|
+
run: Effect.gen(function* () {
|
|
170
|
+
yield* put(nested, ["parent"]);
|
|
171
|
+
yield* put(child, ["child"]);
|
|
172
|
+
yield* remove(child);
|
|
173
|
+
yield* expectBody(nested, "parent");
|
|
174
|
+
yield* expectBody(child, null);
|
|
175
|
+
yield* put(child, ["child"]);
|
|
176
|
+
yield* remove(nested);
|
|
177
|
+
yield* expectBody(child, "child");
|
|
178
|
+
}).pipe(Effect.orDie),
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: "reports missing objects and deletes them more than once",
|
|
182
|
+
run: Effect.gen(function* () {
|
|
183
|
+
yield* put(stored, ["to delete"]);
|
|
184
|
+
yield* remove(stored);
|
|
185
|
+
yield* expectHead(stored, null);
|
|
186
|
+
yield* expectBody(stored, null);
|
|
187
|
+
yield* remove(stored);
|
|
188
|
+
}).pipe(Effect.orDie),
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: "keeps the previous object when a body fails",
|
|
192
|
+
run: Effect.gen(function* () {
|
|
193
|
+
yield* put(stored, ["previous"]);
|
|
194
|
+
|
|
195
|
+
const failing = Stream.concat(
|
|
196
|
+
Stream.make(text("partial")),
|
|
197
|
+
Stream.fail("body failed" as const),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const bucket = yield* ContractBucket;
|
|
201
|
+
|
|
202
|
+
const result = yield* Effect.result(
|
|
203
|
+
bucket.put({ key: stored, contentType: "text/plain", body: failing }),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
yield* expectEqual(
|
|
207
|
+
"error of a put whose body failed",
|
|
208
|
+
"body failed",
|
|
209
|
+
Result.isFailure(result) ? result.failure : "success",
|
|
210
|
+
);
|
|
211
|
+
yield* expectHead(stored, { size: ByteSize.bytes(8), contentType: "text/plain" });
|
|
212
|
+
yield* expectBody(stored, "previous");
|
|
213
|
+
}).pipe(Effect.orDie),
|
|
214
|
+
},
|
|
215
|
+
];
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { ByteSize, Duration, Effect, Option } from "effect";
|
|
2
|
+
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
3
|
+
|
|
4
|
+
import * as AssetSigning from "../AssetSigning.ts";
|
|
5
|
+
import { ContractBucket } from "./AssetBucketContract.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Checks a `Signing` implementation and the storage it signs for, over HTTP:
|
|
9
|
+
* signed requests succeed, and requests that differ from what was signed are
|
|
10
|
+
* refused without changing what is stored, and a signed upload writes once. Provide the `ContractBucket`, its
|
|
11
|
+
* `Signing` and an `HttpClient` that can reach the signed URLs. Expiry is not
|
|
12
|
+
* checked: it depends on the storage's clock. Cases delete the keys they
|
|
13
|
+
* use under `contract/` first, so storage may keep objects between cases.
|
|
14
|
+
*/
|
|
15
|
+
export interface Case {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly run: Effect.Effect<
|
|
18
|
+
void,
|
|
19
|
+
never,
|
|
20
|
+
ContractBucket | AssetSigning.SigningOf<ContractBucket> | HttpClient.HttpClient
|
|
21
|
+
>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const signing = AssetSigning.of(ContractBucket);
|
|
25
|
+
|
|
26
|
+
const key = "contract/signed.png";
|
|
27
|
+
|
|
28
|
+
const expiresIn = Duration.minutes(5);
|
|
29
|
+
|
|
30
|
+
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
|
31
|
+
|
|
32
|
+
// Not `no-cache`, which clients add to conditional requests themselves.
|
|
33
|
+
const cacheControl = "public, max-age=60";
|
|
34
|
+
|
|
35
|
+
const violated = (message: string) =>
|
|
36
|
+
Effect.die(new Error(`AssetSigning contract violated: ${message}`));
|
|
37
|
+
|
|
38
|
+
const ensure = (holds: boolean, message: string) => (holds ? Effect.void : violated(message));
|
|
39
|
+
|
|
40
|
+
const ok = (status: number) => status >= 200 && status < 300;
|
|
41
|
+
|
|
42
|
+
const signPut = (contentType = "image/png", contentLength = png.length) =>
|
|
43
|
+
Effect.flatMap(signing, (signer) =>
|
|
44
|
+
signer.signPut({
|
|
45
|
+
key,
|
|
46
|
+
contentType,
|
|
47
|
+
contentLength: ByteSize.bytes(contentLength),
|
|
48
|
+
cacheControl,
|
|
49
|
+
expiresIn,
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
/** Sends a signed request as a client would, with its headers and a body. */
|
|
54
|
+
const send = (
|
|
55
|
+
signed: AssetSigning.SignedRequest,
|
|
56
|
+
options: {
|
|
57
|
+
readonly body?: Uint8Array;
|
|
58
|
+
readonly headers?: Record<string, string>;
|
|
59
|
+
readonly url?: string;
|
|
60
|
+
} = {},
|
|
61
|
+
) =>
|
|
62
|
+
HttpClient.execute(
|
|
63
|
+
HttpClientRequest.make(signed.method)(options.url ?? signed.url).pipe(
|
|
64
|
+
HttpClientRequest.setHeaders({ ...signed.headers, ...options.headers }),
|
|
65
|
+
(request) =>
|
|
66
|
+
options.body === undefined
|
|
67
|
+
? request
|
|
68
|
+
: HttpClientRequest.bodyUint8Array(
|
|
69
|
+
request,
|
|
70
|
+
options.body,
|
|
71
|
+
options.headers?.["content-type"] ?? signed.headers["content-type"],
|
|
72
|
+
),
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// The value of the URL's signature parameter, such as S3's `X-Amz-Signature`.
|
|
77
|
+
const signatureOf = (url: string) =>
|
|
78
|
+
[...new URL(url, "http://localhost").searchParams].find(([name]) =>
|
|
79
|
+
/signature$/iu.test(name),
|
|
80
|
+
)?.[1] ?? "";
|
|
81
|
+
|
|
82
|
+
// Cases start without the objects they check for.
|
|
83
|
+
const clear = Effect.flatMap(ContractBucket, (bucket) =>
|
|
84
|
+
Effect.andThen(bucket.delete(key), bucket.delete("contract/other.png")),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const stored = Effect.flatMap(ContractBucket, (bucket) => bucket.head(key));
|
|
88
|
+
|
|
89
|
+
const expectRefused = (label: string, response: { readonly status: number }) =>
|
|
90
|
+
ok(response.status)
|
|
91
|
+
? violated(`${label} was accepted with ${response.status}`)
|
|
92
|
+
: Effect.flatMap(stored, (info) =>
|
|
93
|
+
Option.isSome(info) ? violated(`${label} stored an object`) : Effect.void,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
export const cases: ReadonlyArray<Case> = [
|
|
97
|
+
{
|
|
98
|
+
name: "stores a signed upload and serves it to a signed download",
|
|
99
|
+
run: Effect.gen(function* () {
|
|
100
|
+
yield* clear;
|
|
101
|
+
|
|
102
|
+
const put = yield* signPut();
|
|
103
|
+
const uploaded = yield* send(put, { body: png });
|
|
104
|
+
|
|
105
|
+
yield* ensure(ok(uploaded.status), `a signed upload failed with ${uploaded.status}`);
|
|
106
|
+
|
|
107
|
+
const info = yield* stored;
|
|
108
|
+
|
|
109
|
+
yield* ensure(
|
|
110
|
+
Option.isSome(info) &&
|
|
111
|
+
info.value.contentType === "image/png" &&
|
|
112
|
+
info.value.cacheControl === cacheControl,
|
|
113
|
+
"a signed upload was not stored with its content type and cache control",
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const get = yield* Effect.flatMap(signing, (signer) => signer.signGet({ key, expiresIn }));
|
|
117
|
+
const downloaded = yield* send(get);
|
|
118
|
+
const body = new Uint8Array(yield* downloaded.arrayBuffer);
|
|
119
|
+
|
|
120
|
+
yield* ensure(
|
|
121
|
+
downloaded.status === 200 && body.join() === png.join(),
|
|
122
|
+
"a signed download did not return the upload",
|
|
123
|
+
);
|
|
124
|
+
}).pipe(Effect.orDie),
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: "signs uploads with no headers but `uploadHeaders`, which CORS rules allow",
|
|
128
|
+
run: Effect.gen(function* () {
|
|
129
|
+
const put = yield* signPut();
|
|
130
|
+
const allowed: ReadonlySet<string> = new Set(AssetSigning.uploadHeaders);
|
|
131
|
+
const others = Object.keys(put.headers).filter((name) => !allowed.has(name));
|
|
132
|
+
|
|
133
|
+
yield* ensure(
|
|
134
|
+
others.length === 0,
|
|
135
|
+
`a signed upload needs other headers: ${others.join(", ")}`,
|
|
136
|
+
);
|
|
137
|
+
}).pipe(Effect.orDie),
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: "refuses an upload of another content type than signed",
|
|
141
|
+
run: Effect.gen(function* () {
|
|
142
|
+
yield* clear;
|
|
143
|
+
|
|
144
|
+
const put = yield* signPut("image/png");
|
|
145
|
+
|
|
146
|
+
yield* expectRefused(
|
|
147
|
+
"an upload as text/html",
|
|
148
|
+
yield* send(put, { body: png, headers: { "content-type": "text/html" } }),
|
|
149
|
+
);
|
|
150
|
+
}).pipe(Effect.orDie),
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: "refuses an upload with another cache control than signed",
|
|
154
|
+
run: Effect.gen(function* () {
|
|
155
|
+
yield* clear;
|
|
156
|
+
|
|
157
|
+
const put = yield* signPut();
|
|
158
|
+
|
|
159
|
+
yield* expectRefused(
|
|
160
|
+
"an upload cached for a year",
|
|
161
|
+
yield* send(put, {
|
|
162
|
+
body: png,
|
|
163
|
+
headers: { "cache-control": "public, max-age=31536000, immutable" },
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
}).pipe(Effect.orDie),
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: "refuses an upload of another length than signed",
|
|
170
|
+
run: Effect.gen(function* () {
|
|
171
|
+
yield* clear;
|
|
172
|
+
|
|
173
|
+
const put = yield* signPut("image/png", png.length);
|
|
174
|
+
|
|
175
|
+
yield* expectRefused(
|
|
176
|
+
"a longer upload",
|
|
177
|
+
yield* send(put, { body: new Uint8Array([...png, 0]) }),
|
|
178
|
+
);
|
|
179
|
+
yield* expectRefused("a shorter upload", yield* send(put, { body: png.slice(1) }));
|
|
180
|
+
}).pipe(Effect.orDie),
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
name: "refuses a signed URL used for another key or with its signature changed",
|
|
184
|
+
run: Effect.gen(function* () {
|
|
185
|
+
yield* clear;
|
|
186
|
+
|
|
187
|
+
const put = yield* signPut();
|
|
188
|
+
const signature = signatureOf(put.url);
|
|
189
|
+
|
|
190
|
+
yield* ensure(signature !== "", "a signed URL has no signature parameter");
|
|
191
|
+
|
|
192
|
+
const flipped = `${signature.slice(0, -1)}${signature.endsWith("0") ? "1" : "0"}`;
|
|
193
|
+
|
|
194
|
+
yield* expectRefused(
|
|
195
|
+
"an upload to another key",
|
|
196
|
+
yield* send(put, { body: png, url: put.url.replace("signed.png", "other.png") }),
|
|
197
|
+
);
|
|
198
|
+
yield* expectRefused(
|
|
199
|
+
"an upload with a changed signature",
|
|
200
|
+
yield* send(put, { body: png, url: put.url.replace(signature, flipped) }),
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const other = yield* Effect.flatMap(ContractBucket, (bucket) =>
|
|
204
|
+
bucket.head("contract/other.png"),
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
yield* ensure(Option.isNone(other), "an upload to another key was stored");
|
|
208
|
+
}).pipe(Effect.orDie),
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: "writes a signed upload once, however its URL is replayed",
|
|
212
|
+
run: Effect.gen(function* () {
|
|
213
|
+
yield* clear;
|
|
214
|
+
|
|
215
|
+
const put = yield* signPut();
|
|
216
|
+
|
|
217
|
+
yield* send(put, { body: png });
|
|
218
|
+
|
|
219
|
+
// Same length and type, so only the write-once condition stops it.
|
|
220
|
+
const other = png.map((byte) => 255 - byte);
|
|
221
|
+
|
|
222
|
+
const { "if-none-match": _, ...unconditional } = put.headers;
|
|
223
|
+
|
|
224
|
+
const replays = [
|
|
225
|
+
yield* send(put, { body: other }),
|
|
226
|
+
yield* send({ ...put, headers: unconditional }, { body: other }),
|
|
227
|
+
];
|
|
228
|
+
|
|
229
|
+
yield* ensure(
|
|
230
|
+
replays.every((response) => !ok(response.status)),
|
|
231
|
+
`a replayed upload was accepted with ${replays.map((response) => response.status).join(", ")}`,
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const get = yield* Effect.flatMap(signing, (signer) => signer.signGet({ key, expiresIn }));
|
|
235
|
+
const body = new Uint8Array(yield* (yield* send(get)).arrayBuffer);
|
|
236
|
+
|
|
237
|
+
yield* ensure(body.join() === png.join(), "a replayed upload replaced the object");
|
|
238
|
+
}).pipe(Effect.orDie),
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
name: "refuses a signed upload URL used to download",
|
|
242
|
+
run: Effect.gen(function* () {
|
|
243
|
+
yield* clear;
|
|
244
|
+
|
|
245
|
+
const put = yield* signPut();
|
|
246
|
+
|
|
247
|
+
yield* send(put, { body: png });
|
|
248
|
+
|
|
249
|
+
const downloaded = yield* send({ ...put, method: "GET" });
|
|
250
|
+
|
|
251
|
+
yield* ensure(!ok(downloaded.status), "an upload URL served a download");
|
|
252
|
+
}).pipe(Effect.orDie),
|
|
253
|
+
},
|
|
254
|
+
];
|