@reventlessdev/reventless-aws 3.0.0-alpha.250 → 3.0.0-alpha.252
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 +18 -0
- package/package.json +7 -7
- package/src/Platform.res +58 -68
- package/src/Platform.res.mjs +60 -102
- package/src/adapter/Geocoder/Geocoder_AwsLocation_Ops.res +13 -11
- package/src/adapter/Geocoder/Geocoder_AwsLocation_Ops.res.mjs +16 -21
- package/src/adapter/Runtime/DcbCommandTopicEntryPoint_Ops.res +5 -4
- package/src/adapter/Runtime/DcbCommandTopicEntryPoint_Ops.res.mjs +8 -12
- package/src/adapter/Upload/Upload_Presign_S3.res +179 -92
- package/src/adapter/Upload/Upload_Presign_S3.res.mjs +129 -39
- package/src/adapter/Upload/Upload_Presign_S3_Ops.res +227 -104
- package/src/adapter/Upload/Upload_Presign_S3_Ops.res.mjs +195 -75
- package/src/util/Util_DeadLetterQueue.res +26 -0
- package/src/util/Util_DeadLetterQueue.res.mjs +9 -0
- package/tests/Upload_ReleaseRuleTest.res +90 -0
- package/tests/Upload_ReleaseRuleTest.res.mjs +110 -0
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
4
4
|
import * as Nodecrypto from "node:crypto";
|
|
5
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
6
|
+
import * as Stdlib_Float from "@rescript/runtime/lib/es6/Stdlib_Float.js";
|
|
7
|
+
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
|
|
5
8
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
9
|
+
import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
6
10
|
import * as ClientS3 from "@aws-sdk/client-s3";
|
|
7
11
|
import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
|
|
8
12
|
import * as S3RequestPresigner from "@aws-sdk/s3-request-presigner";
|
|
@@ -14,107 +18,223 @@ function getEnv(k) {
|
|
|
14
18
|
}
|
|
15
19
|
}
|
|
16
20
|
|
|
17
|
-
function
|
|
21
|
+
function decodeStore(json) {
|
|
22
|
+
if (typeof json !== "object" || json === null || Array.isArray(json)) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
let match = Stdlib_Option.flatMap(json["bucket"], Stdlib_JSON.Decode.string);
|
|
26
|
+
let match$1 = Stdlib_Option.flatMap(json["prefix"], Stdlib_JSON.Decode.string);
|
|
27
|
+
if (match !== undefined && match$1 !== undefined) {
|
|
28
|
+
return {
|
|
29
|
+
bucket: match,
|
|
30
|
+
prefix: match$1
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function loadStores() {
|
|
36
|
+
let match = Stdlib_Option.map(getEnv("UPLOAD_STORES"), s => JSON.parse(s));
|
|
37
|
+
if (match !== undefined) {
|
|
38
|
+
if (typeof match === "object" && match !== null && !Array.isArray(match)) {
|
|
39
|
+
return Object.fromEntries(Stdlib_Array.filterMap(Object.entries(match), param => {
|
|
40
|
+
let k = param[0];
|
|
41
|
+
return Stdlib_Option.map(decodeStore(param[1]), c => [
|
|
42
|
+
k,
|
|
43
|
+
c
|
|
44
|
+
]);
|
|
45
|
+
}));
|
|
46
|
+
} else {
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function windowMs() {
|
|
55
|
+
return Stdlib_Option.getOr(Stdlib_Option.flatMap(getEnv("RELEASE_WINDOW_SECONDS"), Stdlib_Float.fromString), 900) * 1000;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function scopeCheck(key, sub, servedPrefix) {
|
|
59
|
+
if (sub === "") {
|
|
60
|
+
return {
|
|
61
|
+
TAG: "Error",
|
|
62
|
+
_0: "unauthenticated"
|
|
63
|
+
};
|
|
64
|
+
} else if (key.startsWith(servedPrefix + `/`)) {
|
|
65
|
+
if (key.startsWith(servedPrefix + `/` + sub + `/`)) {
|
|
66
|
+
return {
|
|
67
|
+
TAG: "Ok",
|
|
68
|
+
_0: undefined
|
|
69
|
+
};
|
|
70
|
+
} else {
|
|
71
|
+
return {
|
|
72
|
+
TAG: "Error",
|
|
73
|
+
_0: "not_yours"
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
return {
|
|
78
|
+
TAG: "Error",
|
|
79
|
+
_0: "not_in_store"
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function ageOk(lastModifiedMs, nowMs, windowMs) {
|
|
85
|
+
if (lastModifiedMs !== undefined) {
|
|
86
|
+
return nowMs - lastModifiedMs <= windowMs;
|
|
87
|
+
} else {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function decideRelease(key, sub, servedPrefix, lastModifiedMs, nowMs, windowMs) {
|
|
93
|
+
let reason = scopeCheck(key, sub, servedPrefix);
|
|
94
|
+
if (reason.TAG === "Ok") {
|
|
95
|
+
if (ageOk(lastModifiedMs, nowMs, windowMs)) {
|
|
96
|
+
return "Released";
|
|
97
|
+
} else {
|
|
98
|
+
return {
|
|
99
|
+
TAG: "Refused",
|
|
100
|
+
_0: "too_old"
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
return {
|
|
105
|
+
TAG: "Refused",
|
|
106
|
+
_0: reason._0
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function ticket(uploadUrl, storageRef) {
|
|
18
112
|
return Object.fromEntries([
|
|
19
113
|
[
|
|
20
|
-
"
|
|
21
|
-
|
|
114
|
+
"uploadUrl",
|
|
115
|
+
uploadUrl
|
|
22
116
|
],
|
|
23
117
|
[
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
]
|
|
118
|
+
"storageRef",
|
|
119
|
+
storageRef
|
|
120
|
+
]
|
|
121
|
+
]);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function releaseResult(released, reason) {
|
|
125
|
+
return Object.fromEntries([
|
|
27
126
|
[
|
|
28
|
-
"
|
|
29
|
-
|
|
127
|
+
"released",
|
|
128
|
+
released
|
|
30
129
|
],
|
|
31
130
|
[
|
|
32
|
-
"
|
|
33
|
-
|
|
131
|
+
"reason",
|
|
132
|
+
Stdlib_Option.mapOr(reason, null, prim => prim)
|
|
34
133
|
]
|
|
35
134
|
]);
|
|
36
135
|
}
|
|
37
136
|
|
|
38
|
-
function
|
|
137
|
+
function keyOfRef(storageRef) {
|
|
138
|
+
if (storageRef.startsWith("/")) {
|
|
139
|
+
return storageRef.slice(1, storageRef.length);
|
|
140
|
+
} else {
|
|
141
|
+
return storageRef;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function headLastModifiedMs(client, bucket, key) {
|
|
39
146
|
try {
|
|
40
|
-
let
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
let
|
|
47
|
-
|
|
48
|
-
|
|
147
|
+
let out = await client.send(new ClientS3.HeadObjectCommand({
|
|
148
|
+
Bucket: bucket,
|
|
149
|
+
Key: key
|
|
150
|
+
}));
|
|
151
|
+
return Stdlib_Option.map(out.LastModified, d => d.getTime());
|
|
152
|
+
} catch (raw_exn) {
|
|
153
|
+
let exn = Primitive_exceptions.internalToException(raw_exn);
|
|
154
|
+
let match = Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.name);
|
|
155
|
+
if (match !== undefined) {
|
|
156
|
+
switch (match) {
|
|
157
|
+
case "NoSuchKey" :
|
|
158
|
+
case "NotFound" :
|
|
159
|
+
return;
|
|
160
|
+
default:
|
|
161
|
+
throw exn;
|
|
162
|
+
}
|
|
49
163
|
} else {
|
|
50
|
-
|
|
164
|
+
throw exn;
|
|
51
165
|
}
|
|
52
|
-
} catch (exn) {
|
|
53
|
-
return;
|
|
54
166
|
}
|
|
55
167
|
}
|
|
56
168
|
|
|
57
|
-
function
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
169
|
+
async function handlePresign(client, bucket, servedPrefix, sub, args) {
|
|
170
|
+
if (sub === "") {
|
|
171
|
+
Stdlib_JsError.throwWithMessage("unauthenticated");
|
|
172
|
+
}
|
|
173
|
+
let fileName = Stdlib_Option.getOr(args.fileName, "upload");
|
|
174
|
+
let key = servedPrefix + `/` + sub + `/` + Nodecrypto.randomUUID() + `/` + fileName;
|
|
175
|
+
let command = new ClientS3.PutObjectCommand({
|
|
176
|
+
Bucket: bucket,
|
|
177
|
+
Key: key,
|
|
178
|
+
ContentType: args.contentType
|
|
179
|
+
});
|
|
180
|
+
let uploadUrl = await S3RequestPresigner.getSignedUrl(client, command, {
|
|
181
|
+
expiresIn: 300
|
|
182
|
+
});
|
|
183
|
+
return ticket(uploadUrl, `/` + key);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function handleRelease(client, bucket, servedPrefix, sub, args) {
|
|
187
|
+
let key = keyOfRef(Stdlib_Option.getOr(args.storageRef, ""));
|
|
188
|
+
let reason = scopeCheck(key, sub, servedPrefix);
|
|
189
|
+
if (reason.TAG !== "Ok") {
|
|
190
|
+
return releaseResult(false, reason._0);
|
|
191
|
+
}
|
|
192
|
+
let lastModifiedMs = await headLastModifiedMs(client, bucket, key);
|
|
193
|
+
let reason$1 = decideRelease(key, sub, servedPrefix, lastModifiedMs, Date.now(), windowMs());
|
|
194
|
+
if (typeof reason$1 === "object") {
|
|
195
|
+
return releaseResult(false, reason$1._0);
|
|
64
196
|
}
|
|
197
|
+
await client.send(new ClientS3.DeleteObjectCommand({
|
|
198
|
+
Bucket: bucket,
|
|
199
|
+
Key: key
|
|
200
|
+
}));
|
|
201
|
+
return releaseResult(true, undefined);
|
|
65
202
|
}
|
|
66
203
|
|
|
67
204
|
async function handler(event) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
let storageRef = `/` + key;
|
|
85
|
-
return {
|
|
86
|
-
statusCode: 200,
|
|
87
|
-
headers: corsHeaders(),
|
|
88
|
-
body: JSON.stringify(Object.fromEntries([
|
|
89
|
-
[
|
|
90
|
-
"uploadUrl",
|
|
91
|
-
uploadUrl
|
|
92
|
-
],
|
|
93
|
-
[
|
|
94
|
-
"storageRef",
|
|
95
|
-
storageRef
|
|
96
|
-
]
|
|
97
|
-
]))
|
|
98
|
-
};
|
|
99
|
-
} catch (raw_exn) {
|
|
100
|
-
let exn = Primitive_exceptions.internalToException(raw_exn);
|
|
101
|
-
console.error("UploadPresign: presign failed", exn);
|
|
102
|
-
return {
|
|
103
|
-
statusCode: 400,
|
|
104
|
-
headers: corsHeaders(),
|
|
105
|
-
body: JSON.stringify(Object.fromEntries([[
|
|
106
|
-
"error",
|
|
107
|
-
"presign_failed"
|
|
108
|
-
]]))
|
|
109
|
-
};
|
|
205
|
+
let stores = loadStores();
|
|
206
|
+
let args = Stdlib_Option.getOr(event.arguments, {});
|
|
207
|
+
let sub = Stdlib_Option.getOr(Stdlib_Option.flatMap(event.identity, i => i.sub), "");
|
|
208
|
+
let storeKey = Stdlib_Option.getOr(args.store, "");
|
|
209
|
+
let match = stores[storeKey];
|
|
210
|
+
if (match === undefined) {
|
|
211
|
+
return Stdlib_JsError.throwWithMessage("unknown_store");
|
|
212
|
+
}
|
|
213
|
+
let prefix = match.prefix;
|
|
214
|
+
let bucket = match.bucket;
|
|
215
|
+
let client = new ClientS3.S3Client();
|
|
216
|
+
let match$1 = Stdlib_Option.getOr(event.operation, "presign");
|
|
217
|
+
if (match$1 === "release") {
|
|
218
|
+
return await handleRelease(client, bucket, prefix, sub, args);
|
|
219
|
+
} else {
|
|
220
|
+
return await handlePresign(client, bucket, prefix, sub, args);
|
|
110
221
|
}
|
|
111
222
|
}
|
|
112
223
|
|
|
113
224
|
export {
|
|
114
225
|
getEnv,
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
226
|
+
decodeStore,
|
|
227
|
+
loadStores,
|
|
228
|
+
windowMs,
|
|
229
|
+
scopeCheck,
|
|
230
|
+
ageOk,
|
|
231
|
+
decideRelease,
|
|
232
|
+
ticket,
|
|
233
|
+
releaseResult,
|
|
234
|
+
keyOfRef,
|
|
235
|
+
headLastModifiedMs,
|
|
236
|
+
handlePresign,
|
|
237
|
+
handleRelease,
|
|
118
238
|
handler,
|
|
119
239
|
}
|
|
120
240
|
/* node:crypto Not a pure module */
|
|
@@ -12,10 +12,16 @@ let queueTags = queueName =>
|
|
|
12
12
|
~scope=Plugin,
|
|
13
13
|
)
|
|
14
14
|
|
|
15
|
+
// 14 days, the SQS maximum, stated rather than inherited. A dead letter is the
|
|
16
|
+
// only surviving evidence of a message the system could not process, and the
|
|
17
|
+
// 4-day default can expire it over a long weekend before anyone reads it.
|
|
18
|
+
let retentionSeconds = 14 * 24 * 60 * 60
|
|
19
|
+
|
|
15
20
|
let queue = SQS.Queue.make(
|
|
16
21
|
~name,
|
|
17
22
|
~args={
|
|
18
23
|
SQS.Queue.visibilityTimeoutSeconds: 180->Pulumi.Input.make,
|
|
24
|
+
messageRetentionSeconds: retentionSeconds->Pulumi.Input.make,
|
|
19
25
|
sqsManagedSseEnabled: false->Pulumi.Input.make,
|
|
20
26
|
tags: queueTags(name),
|
|
21
27
|
},
|
|
@@ -27,6 +33,7 @@ let fifoQueue = SQS.Queue.make(
|
|
|
27
33
|
SQS.Queue.fifoQueue: true->Pulumi.Input.make,
|
|
28
34
|
contentBasedDeduplication: true->Pulumi.Input.make,
|
|
29
35
|
visibilityTimeoutSeconds: 180->Pulumi.Input.make,
|
|
36
|
+
messageRetentionSeconds: retentionSeconds->Pulumi.Input.make,
|
|
30
37
|
sqsManagedSseEnabled: false->Pulumi.Input.make,
|
|
31
38
|
tags: queueTags(nameFifo),
|
|
32
39
|
},
|
|
@@ -45,8 +52,27 @@ let lambdaRole = IAM.Role.makeWithDefaultPolicy(
|
|
|
45
52
|
~opts,
|
|
46
53
|
)
|
|
47
54
|
|
|
55
|
+
// Logs what arrived, then FAILS the invocation on purpose.
|
|
56
|
+
//
|
|
57
|
+
// Returning success here would let SQS delete the message, which is what this
|
|
58
|
+
// handler used to do — and it made a dead letter unobservable twice over: the
|
|
59
|
+
// queue's depth returned to 0, so a depth alarm could never fire, and the
|
|
60
|
+
// function's `Errors` metric stayed at 0, so neither could an error alarm. A
|
|
61
|
+
// plugin whose commands failed every 5 minutes for two days produced 217 dead
|
|
62
|
+
// letters and no signal at all; it was found by a person noticing a stale UI.
|
|
63
|
+
//
|
|
64
|
+
// Failing instead keeps the message on the queue (this queue has no redrive
|
|
65
|
+
// target of its own, so it stays until retention expires) and keeps `Errors`
|
|
66
|
+
// non-zero for as long as the condition lasts. Both are conventional alarm
|
|
67
|
+
// subjects, and a monitoring backend attached through the `DeadLetterSink` seam
|
|
68
|
+
// below now has something to attach to. Re-delivery re-logs the payload; on a
|
|
69
|
+
// queue that is empty in normal operation, that repetition is the alert.
|
|
48
70
|
let entryPointCode = `export const handler = async (event) => {
|
|
49
71
|
console.error("DEAD LETTER ITEM:", JSON.stringify(event));
|
|
72
|
+
throw new Error(
|
|
73
|
+
"Dead-lettered " + (event?.Records?.length ?? 0) +
|
|
74
|
+
" message(s); see DEAD LETTER ITEM above. Failing so the messages are retained and Errors is non-zero."
|
|
75
|
+
);
|
|
50
76
|
};`
|
|
51
77
|
|
|
52
78
|
let archiveContents: dict<Pulumi.Archive.assetOrArchive> = Dict.make()
|
|
@@ -22,6 +22,7 @@ function queueTags(queueName) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
let queue = new (Aws.sqs.Queue)(name, {
|
|
25
|
+
messageRetentionSeconds: 1209600,
|
|
25
26
|
tags: queueTags(name),
|
|
26
27
|
visibilityTimeoutSeconds: 180,
|
|
27
28
|
sqsManagedSseEnabled: false
|
|
@@ -30,6 +31,7 @@ let queue = new (Aws.sqs.Queue)(name, {
|
|
|
30
31
|
let fifoQueue = new (Aws.sqs.Queue)(nameFifo, {
|
|
31
32
|
contentBasedDeduplication: true,
|
|
32
33
|
fifoQueue: true,
|
|
34
|
+
messageRetentionSeconds: 1209600,
|
|
33
35
|
tags: queueTags(nameFifo),
|
|
34
36
|
visibilityTimeoutSeconds: 180,
|
|
35
37
|
sqsManagedSseEnabled: false
|
|
@@ -45,6 +47,10 @@ let lambdaRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(name, Pulumi.output(AW
|
|
|
45
47
|
|
|
46
48
|
let entryPointCode = `export const handler = async (event) => {
|
|
47
49
|
console.error("DEAD LETTER ITEM:", JSON.stringify(event));
|
|
50
|
+
throw new Error(
|
|
51
|
+
"Dead-lettered " + (event?.Records?.length ?? 0) +
|
|
52
|
+
" message(s); see DEAD LETTER ITEM above. Failing so the messages are retained and Errors is non-zero."
|
|
53
|
+
);
|
|
48
54
|
};`;
|
|
49
55
|
|
|
50
56
|
let archiveContents = {};
|
|
@@ -150,10 +156,13 @@ Pulumi.all([
|
|
|
150
156
|
}, opts);
|
|
151
157
|
});
|
|
152
158
|
|
|
159
|
+
let retentionSeconds = 1209600;
|
|
160
|
+
|
|
153
161
|
export {
|
|
154
162
|
name,
|
|
155
163
|
nameFifo,
|
|
156
164
|
queueTags,
|
|
165
|
+
retentionSeconds,
|
|
157
166
|
queue,
|
|
158
167
|
fifoQueue,
|
|
159
168
|
opts,
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// The release rule as a pure decision — no S3, no AppSync (plan Step 4).
|
|
2
|
+
//
|
|
3
|
+
// `Upload_Presign_S3_Ops` splits the rule into `scopeCheck` (the string-checkable
|
|
4
|
+
// half: authenticated, in-store, owned) and `ageOk` (the clock half), composed by
|
|
5
|
+
// `decideRelease`. These tests pin every allow/deny reason so a release that should
|
|
6
|
+
// refuse can never silently succeed — the failure mode the plan exists to remove.
|
|
7
|
+
|
|
8
|
+
open JestGlobals
|
|
9
|
+
|
|
10
|
+
module Ops = Upload_Presign_S3_Ops
|
|
11
|
+
|
|
12
|
+
let prefix = "uploads"
|
|
13
|
+
let sub = "user-1"
|
|
14
|
+
let ownKey = "uploads/user-1/abc/file.png"
|
|
15
|
+
let otherKey = "uploads/user-2/abc/file.png"
|
|
16
|
+
let outsideKey = "other/user-1/abc/file.png"
|
|
17
|
+
|
|
18
|
+
let nowMs = 1_000_000.0
|
|
19
|
+
let windowMs = 900_000.0 // 15 minutes
|
|
20
|
+
let fresh = Some(nowMs -. 1_000.0) // 1s old
|
|
21
|
+
let stale = Some(nowMs -. 1_000_000.0) // ~16.6min old, beyond the window
|
|
22
|
+
|
|
23
|
+
describe("Upload_Presign_S3_Ops.scopeCheck", () => {
|
|
24
|
+
testSync("rejects an empty sub as unauthenticated", () =>
|
|
25
|
+
expect(Ops.scopeCheck(~key=ownKey, ~sub="", ~servedPrefix=prefix))->toEqual(
|
|
26
|
+
Error("unauthenticated"),
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
testSync("rejects a key outside the store prefix as not_in_store", () =>
|
|
31
|
+
expect(Ops.scopeCheck(~key=outsideKey, ~sub, ~servedPrefix=prefix))->toEqual(
|
|
32
|
+
Error("not_in_store"),
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
testSync("rejects another identity's key as not_yours", () =>
|
|
37
|
+
expect(Ops.scopeCheck(~key=otherKey, ~sub, ~servedPrefix=prefix))->toEqual(Error("not_yours"))
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
testSync("allows the caller's own in-store key", () =>
|
|
41
|
+
expect(Ops.scopeCheck(~key=ownKey, ~sub, ~servedPrefix=prefix))->toEqual(Ok())
|
|
42
|
+
)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe("Upload_Presign_S3_Ops.ageOk", () => {
|
|
46
|
+
testSync("treats an absent object as releasable (idempotent)", () =>
|
|
47
|
+
expect(Ops.ageOk(~lastModifiedMs=None, ~nowMs, ~windowMs))->toBe(true)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
testSync("allows an object younger than the window", () =>
|
|
51
|
+
expect(Ops.ageOk(~lastModifiedMs=fresh, ~nowMs, ~windowMs))->toBe(true)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
testSync("refuses an object older than the window", () =>
|
|
55
|
+
expect(Ops.ageOk(~lastModifiedMs=stale, ~nowMs, ~windowMs))->toBe(false)
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe("Upload_Presign_S3_Ops.decideRelease", () => {
|
|
60
|
+
let decide = (~key, ~sub, ~lastModifiedMs) =>
|
|
61
|
+
Ops.decideRelease(~key, ~sub, ~servedPrefix=prefix, ~lastModifiedMs, ~nowMs, ~windowMs)
|
|
62
|
+
|
|
63
|
+
testSync("releases the caller's own fresh object", () =>
|
|
64
|
+
expect(decide(~key=ownKey, ~sub, ~lastModifiedMs=fresh))->toEqual(Ops.Released)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
testSync("releases an absent object (idempotent retry)", () =>
|
|
68
|
+
expect(decide(~key=ownKey, ~sub, ~lastModifiedMs=None))->toEqual(Ops.Released)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
testSync("refuses another identity's object with not_yours", () =>
|
|
72
|
+
expect(decide(~key=otherKey, ~sub, ~lastModifiedMs=fresh))->toEqual(Ops.Refused("not_yours"))
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
testSync("refuses an object older than the window with too_old", () =>
|
|
76
|
+
expect(decide(~key=ownKey, ~sub, ~lastModifiedMs=stale))->toEqual(Ops.Refused("too_old"))
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
testSync("refuses a key outside the store with not_in_store", () =>
|
|
80
|
+
expect(decide(~key=outsideKey, ~sub, ~lastModifiedMs=fresh))->toEqual(
|
|
81
|
+
Ops.Refused("not_in_store"),
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
testSync("refuses an unauthenticated caller before any age check", () =>
|
|
86
|
+
expect(decide(~key=ownKey, ~sub="", ~lastModifiedMs=stale))->toEqual(
|
|
87
|
+
Ops.Refused("unauthenticated"),
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Upload_Presign_S3_Ops$ReventlessAws from "../src/adapter/Upload/Upload_Presign_S3_Ops.res.mjs";
|
|
4
|
+
|
|
5
|
+
let prefix = "uploads";
|
|
6
|
+
|
|
7
|
+
let sub = "user-1";
|
|
8
|
+
|
|
9
|
+
let ownKey = "uploads/user-1/abc/file.png";
|
|
10
|
+
|
|
11
|
+
let otherKey = "uploads/user-2/abc/file.png";
|
|
12
|
+
|
|
13
|
+
let outsideKey = "other/user-1/abc/file.png";
|
|
14
|
+
|
|
15
|
+
let fresh = 1000000.0 - 1000.0;
|
|
16
|
+
|
|
17
|
+
let stale = 1000000.0 - 1000000.0;
|
|
18
|
+
|
|
19
|
+
globalThis.describe("Upload_Presign_S3_Ops.scopeCheck", () => {
|
|
20
|
+
globalThis.test("rejects an empty sub as unauthenticated", () => {
|
|
21
|
+
globalThis.expect(Upload_Presign_S3_Ops$ReventlessAws.scopeCheck(ownKey, "", prefix)).toEqual({
|
|
22
|
+
TAG: "Error",
|
|
23
|
+
_0: "unauthenticated"
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
globalThis.test("rejects a key outside the store prefix as not_in_store", () => {
|
|
27
|
+
globalThis.expect(Upload_Presign_S3_Ops$ReventlessAws.scopeCheck(outsideKey, sub, prefix)).toEqual({
|
|
28
|
+
TAG: "Error",
|
|
29
|
+
_0: "not_in_store"
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
globalThis.test("rejects another identity's key as not_yours", () => {
|
|
33
|
+
globalThis.expect(Upload_Presign_S3_Ops$ReventlessAws.scopeCheck(otherKey, sub, prefix)).toEqual({
|
|
34
|
+
TAG: "Error",
|
|
35
|
+
_0: "not_yours"
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
globalThis.test("allows the caller's own in-store key", () => {
|
|
39
|
+
globalThis.expect(Upload_Presign_S3_Ops$ReventlessAws.scopeCheck(ownKey, sub, prefix)).toEqual({
|
|
40
|
+
TAG: "Ok",
|
|
41
|
+
_0: undefined
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
globalThis.describe("Upload_Presign_S3_Ops.ageOk", () => {
|
|
47
|
+
globalThis.test("treats an absent object as releasable (idempotent)", () => {
|
|
48
|
+
globalThis.expect(Upload_Presign_S3_Ops$ReventlessAws.ageOk(undefined, 1000000.0, 900000.0)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
globalThis.test("allows an object younger than the window", () => {
|
|
51
|
+
globalThis.expect(Upload_Presign_S3_Ops$ReventlessAws.ageOk(fresh, 1000000.0, 900000.0)).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
globalThis.test("refuses an object older than the window", () => {
|
|
54
|
+
globalThis.expect(Upload_Presign_S3_Ops$ReventlessAws.ageOk(stale, 1000000.0, 900000.0)).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
globalThis.describe("Upload_Presign_S3_Ops.decideRelease", () => {
|
|
59
|
+
let decide = (key, sub, lastModifiedMs) => Upload_Presign_S3_Ops$ReventlessAws.decideRelease(key, sub, prefix, lastModifiedMs, 1000000.0, 900000.0);
|
|
60
|
+
globalThis.test("releases the caller's own fresh object", () => {
|
|
61
|
+
globalThis.expect(decide(ownKey, sub, fresh)).toEqual("Released");
|
|
62
|
+
});
|
|
63
|
+
globalThis.test("releases an absent object (idempotent retry)", () => {
|
|
64
|
+
globalThis.expect(decide(ownKey, sub, undefined)).toEqual("Released");
|
|
65
|
+
});
|
|
66
|
+
globalThis.test("refuses another identity's object with not_yours", () => {
|
|
67
|
+
globalThis.expect(decide(otherKey, sub, fresh)).toEqual({
|
|
68
|
+
TAG: "Refused",
|
|
69
|
+
_0: "not_yours"
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
globalThis.test("refuses an object older than the window with too_old", () => {
|
|
73
|
+
globalThis.expect(decide(ownKey, sub, stale)).toEqual({
|
|
74
|
+
TAG: "Refused",
|
|
75
|
+
_0: "too_old"
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
globalThis.test("refuses a key outside the store with not_in_store", () => {
|
|
79
|
+
globalThis.expect(decide(outsideKey, sub, fresh)).toEqual({
|
|
80
|
+
TAG: "Refused",
|
|
81
|
+
_0: "not_in_store"
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
globalThis.test("refuses an unauthenticated caller before any age check", () => {
|
|
85
|
+
globalThis.expect(decide(ownKey, "", stale)).toEqual({
|
|
86
|
+
TAG: "Refused",
|
|
87
|
+
_0: "unauthenticated"
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
let Ops;
|
|
93
|
+
|
|
94
|
+
let nowMs = 1000000.0;
|
|
95
|
+
|
|
96
|
+
let windowMs = 900000.0;
|
|
97
|
+
|
|
98
|
+
export {
|
|
99
|
+
Ops,
|
|
100
|
+
prefix,
|
|
101
|
+
sub,
|
|
102
|
+
ownKey,
|
|
103
|
+
otherKey,
|
|
104
|
+
outsideKey,
|
|
105
|
+
nowMs,
|
|
106
|
+
windowMs,
|
|
107
|
+
fresh,
|
|
108
|
+
stale,
|
|
109
|
+
}
|
|
110
|
+
/* Not a pure module */
|