@reventlessdev/reventless-seed 1.0.0-alpha.20 → 1.0.0-alpha.21
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 +12 -0
- package/lib/bs/.compiler.log +2 -2
- package/lib/bs/ReventlessSeed.cmi +0 -0
- package/lib/bs/ReventlessSeed.cmt +0 -0
- package/lib/bs/ReventlessSeed.mlmap +1 -0
- package/lib/bs/compiler-info.json +1 -1
- package/lib/bs/src/Seed-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed.ast +0 -0
- package/lib/bs/src/Seed.res +8 -2
- package/lib/bs/src/Seed_Client-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Client-ReventlessSeed.cmj +0 -0
- package/lib/bs/src/Seed_Client-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Client.ast +0 -0
- package/lib/bs/src/Seed_Client.res +169 -21
- package/lib/bs/src/Seed_Client.res.mjs +142 -7
- package/lib/bs/src/Seed_Connect-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Connect-ReventlessSeed.cmj +0 -0
- package/lib/bs/src/Seed_Connect-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Connect.ast +0 -0
- package/lib/bs/src/Seed_Connect.res +258 -19
- package/lib/bs/src/Seed_Connect.res.mjs +158 -8
- package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmj +0 -0
- package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Prompt.ast +0 -0
- package/lib/bs/src/Seed_Prompt.res +47 -23
- package/lib/bs/src/Seed_Prompt.res.mjs +32 -22
- package/lib/bs/src/Seed_Random-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Random-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Random.ast +0 -0
- package/lib/bs/src/Seed_Random.res +4 -2
- package/lib/bs/src/Seed_Runner-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Runner-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Runner.ast +0 -0
- package/lib/bs/src/Seed_Runner.res +7 -12
- package/lib/bs/src/Seed_Upload-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Upload-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Upload.ast +0 -0
- package/lib/bs/src/Seed_Upload.res +4 -2
- package/lib/bs/src/Seed_Users-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Users-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Users.ast +0 -0
- package/lib/bs/src/Seed_Users.res +15 -3
- package/lib/bs/src/Seed_Users.res.mjs +2 -1
- package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmi +0 -0
- package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmj +0 -0
- package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmt +0 -0
- package/lib/bs/tests/Seed_ClientTest.ast +0 -0
- package/lib/bs/tests/Seed_ClientTest.res +146 -16
- package/lib/bs/tests/Seed_ClientTest.res.mjs +102 -1
- package/lib/bs/tests/Seed_ConnectTest-ReventlessSeed.cmi +0 -0
- package/lib/bs/tests/Seed_ConnectTest-ReventlessSeed.cmj +0 -0
- package/lib/bs/tests/Seed_ConnectTest-ReventlessSeed.cmt +0 -0
- package/lib/bs/tests/Seed_ConnectTest.ast +0 -0
- package/lib/bs/tests/Seed_ConnectTest.res +64 -0
- package/lib/bs/tests/Seed_ConnectTest.res.mjs +56 -0
- package/lib/bs/tests/Seed_PromptTest-ReventlessSeed.cmi +0 -0
- package/lib/bs/tests/Seed_PromptTest-ReventlessSeed.cmt +0 -0
- package/lib/bs/tests/Seed_PromptTest.ast +0 -0
- package/lib/bs/tests/Seed_PromptTest.res +8 -12
- package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmi +0 -0
- package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmt +0 -0
- package/lib/bs/tests/Seed_RandomTest.ast +0 -0
- package/lib/bs/tests/Seed_RandomTest.res +130 -90
- package/lib/bs/tests/Seed_UsersTest-ReventlessSeed.cmi +0 -0
- package/lib/bs/tests/Seed_UsersTest-ReventlessSeed.cmt +0 -0
- package/lib/bs/tests/Seed_UsersTest.ast +0 -0
- package/lib/bs/tests/Seed_UsersTest.res +39 -2
- package/lib/bs/tests/Seed_UsersTest.res.mjs +20 -2
- package/lib/ocaml/.compiler.log +2 -2
- package/lib/ocaml/ReventlessSeed.cmi +0 -0
- package/lib/ocaml/ReventlessSeed.cmt +0 -0
- package/lib/ocaml/ReventlessSeed.mlmap +1 -0
- package/lib/ocaml/Seed-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed.ast +0 -0
- package/lib/ocaml/Seed.res +8 -2
- package/lib/ocaml/Seed_Client-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Client-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_Client-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Client.ast +0 -0
- package/lib/ocaml/Seed_Client.res +169 -21
- package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_ClientTest.ast +0 -0
- package/lib/ocaml/Seed_ClientTest.res +146 -16
- package/lib/ocaml/Seed_Connect-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Connect-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_Connect-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Connect.ast +0 -0
- package/lib/ocaml/Seed_Connect.res +258 -19
- package/lib/ocaml/Seed_ConnectTest-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_ConnectTest-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_ConnectTest-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_ConnectTest.ast +0 -0
- package/lib/ocaml/Seed_ConnectTest.res +64 -0
- package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Prompt.ast +0 -0
- package/lib/ocaml/Seed_Prompt.res +47 -23
- package/lib/ocaml/Seed_PromptTest-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_PromptTest-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_PromptTest.ast +0 -0
- package/lib/ocaml/Seed_PromptTest.res +8 -12
- package/lib/ocaml/Seed_Random-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Random-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Random.ast +0 -0
- package/lib/ocaml/Seed_Random.res +4 -2
- package/lib/ocaml/Seed_RandomTest-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_RandomTest-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_RandomTest.ast +0 -0
- package/lib/ocaml/Seed_RandomTest.res +130 -90
- package/lib/ocaml/Seed_Runner-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Runner-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Runner.ast +0 -0
- package/lib/ocaml/Seed_Runner.res +7 -12
- package/lib/ocaml/Seed_Upload-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Upload-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Upload.ast +0 -0
- package/lib/ocaml/Seed_Upload.res +4 -2
- package/lib/ocaml/Seed_Users-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Users-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Users.ast +0 -0
- package/lib/ocaml/Seed_Users.res +15 -3
- package/lib/ocaml/Seed_UsersTest-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_UsersTest-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_UsersTest.ast +0 -0
- package/lib/ocaml/Seed_UsersTest.res +39 -2
- package/package.json +2 -2
- package/src/Seed.res +8 -2
- package/src/Seed_Client.res +169 -21
- package/src/Seed_Client.res.mjs +142 -7
- package/src/Seed_Connect.res +258 -19
- package/src/Seed_Connect.res.mjs +158 -8
- package/src/Seed_Prompt.res +47 -23
- package/src/Seed_Prompt.res.mjs +32 -22
- package/src/Seed_Random.res +4 -2
- package/src/Seed_Runner.res +7 -12
- package/src/Seed_Upload.res +4 -2
- package/src/Seed_Users.res +15 -3
- package/src/Seed_Users.res.mjs +2 -1
- package/tests/Seed_ClientTest.res +146 -16
- package/tests/Seed_ClientTest.res.mjs +102 -1
- package/tests/Seed_ConnectTest.res +64 -0
- package/tests/Seed_ConnectTest.res.mjs +56 -0
- package/tests/Seed_PromptTest.res +8 -12
- package/tests/Seed_RandomTest.res +130 -90
- package/tests/Seed_UsersTest.res +39 -2
- package/tests/Seed_UsersTest.res.mjs +20 -2
|
@@ -21,7 +21,8 @@ type response
|
|
|
21
21
|
@val external setTimeout: (unit => unit, int) => unit = "setTimeout"
|
|
22
22
|
@scope("Date") @val external nowMs: unit => float = "now"
|
|
23
23
|
|
|
24
|
-
let sleep = (ms: int): promise<unit> =>
|
|
24
|
+
let sleep = (ms: int): promise<unit> =>
|
|
25
|
+
Promise.make((resolve, _) => setTimeout(() => resolve(), ms))
|
|
25
26
|
|
|
26
27
|
// ── Configuration ───────────────────────────────────────────────────────────
|
|
27
28
|
|
|
@@ -68,6 +69,12 @@ let availableRolesClaim = "availableRoles"
|
|
|
68
69
|
/** Group claim per provider, tried in order. */
|
|
69
70
|
let groupClaimNames = ["cognito:groups", "groups"]
|
|
70
71
|
|
|
72
|
+
/** Claim carrying the caller's id, per provider, tried in order. A Cognito JWT
|
|
73
|
+
says `sub`; the local dev token is a base64url `Identity.t`, whose field is
|
|
74
|
+
`userId`. Same shape as `groupClaimNames`, for the same reason: this harness
|
|
75
|
+
depends on no framework package and must not learn who signed the token. */
|
|
76
|
+
let callerIdClaimNames = ["sub", "userId"]
|
|
77
|
+
|
|
71
78
|
let decodeSegment = (segment: string): option<dict<JSON.t>> =>
|
|
72
79
|
try {
|
|
73
80
|
segment
|
|
@@ -79,10 +86,22 @@ let decodeSegment = (segment: string): option<dict<JSON.t>> =>
|
|
|
79
86
|
| _ => None
|
|
80
87
|
}
|
|
81
88
|
|
|
89
|
+
/**
|
|
90
|
+
* A JOSE header rather than a claims set.
|
|
91
|
+
*
|
|
92
|
+
* RFC 7515 requires `alg` on the header, and nothing puts it on a payload. The
|
|
93
|
+
* test matters because a JWT header is base64url JSON exactly as the payload is,
|
|
94
|
+
* so it decodes just as readily — and it comes first.
|
|
95
|
+
*/
|
|
96
|
+
let isJoseHeader = (obj: dict<JSON.t>): bool => obj->Dict.get("alg")->Option.isSome
|
|
97
|
+
|
|
82
98
|
/** The bearer's payload, from whichever segment carries it — a Cognito JWT puts
|
|
83
99
|
it second and a local dev token first, so trying each in turn saves this from
|
|
84
100
|
having to know which platform signed the token. A segment that is not a JSON
|
|
85
|
-
object (a signature) simply does not decode
|
|
101
|
+
object (a signature) simply does not decode, and a JOSE header is skipped:
|
|
102
|
+
it IS a decodable JSON object, so taking the first one that parsed read a
|
|
103
|
+
real Cognito token's `{kid, alg}` as its claims and found no groups and no
|
|
104
|
+
id in it — silently, since an unreadable token is not an error here. */
|
|
86
105
|
let claims = (t: t): option<dict<JSON.t>> =>
|
|
87
106
|
t.token->Option.flatMap(token =>
|
|
88
107
|
token
|
|
@@ -90,7 +109,11 @@ let claims = (t: t): option<dict<JSON.t>> =>
|
|
|
90
109
|
->Array.reduce(None, (found, segment) =>
|
|
91
110
|
switch found {
|
|
92
111
|
| Some(_) => found
|
|
93
|
-
| None =>
|
|
112
|
+
| None =>
|
|
113
|
+
switch decodeSegment(segment) {
|
|
114
|
+
| Some(obj) if obj->isJoseHeader => None
|
|
115
|
+
| decoded => decoded
|
|
116
|
+
}
|
|
94
117
|
}
|
|
95
118
|
)
|
|
96
119
|
)
|
|
@@ -118,6 +141,31 @@ let effectiveGroups = (t: t): option<array<string>> =>
|
|
|
118
141
|
}
|
|
119
142
|
)
|
|
120
143
|
|
|
144
|
+
/** The id the bearer presents — what an `@owner` field is stamped with, and so
|
|
145
|
+
the only id a seed can key owner-scoped rows to and have an account read them
|
|
146
|
+
back. `None` when the token is opaque to us or names no id. */
|
|
147
|
+
let callerId = (t: t): option<string> =>
|
|
148
|
+
callerIdClaimNames->Array.reduce(None, (found, name) =>
|
|
149
|
+
switch found {
|
|
150
|
+
| Some(_) => found
|
|
151
|
+
| None => t->claims->Option.flatMap(c => c->Dict.get(name))->Option.flatMap(JSON.Decode.string)
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The membership a narrowed token was reduced *from*, or `None` when the bearer
|
|
157
|
+
* is not narrowed.
|
|
158
|
+
*
|
|
159
|
+
* `availableRoles` is only written when a role was chosen, so its presence is
|
|
160
|
+
* the narrowing — the group claim alone cannot tell you, since a token carrying
|
|
161
|
+
* one group looks identical whether the account holds one or five.
|
|
162
|
+
*/
|
|
163
|
+
let narrowedFrom = (t: t): option<array<string>> =>
|
|
164
|
+
switch t->claimStrings(availableRolesClaim) {
|
|
165
|
+
| Some(available) if available->Array.length > 0 => Some(available)
|
|
166
|
+
| _ => None
|
|
167
|
+
}
|
|
168
|
+
|
|
121
169
|
/** One line naming the identity the bearer carries, and — when it was narrowed —
|
|
122
170
|
the membership it was narrowed from. The second half is the point: without it
|
|
123
171
|
a refusal looks like a misconfigured grant rather than a role switch. */
|
|
@@ -178,8 +226,9 @@ let login = async (t: t): unit => {
|
|
|
178
226
|
let detail = await res->responseText
|
|
179
227
|
throw(
|
|
180
228
|
Failed(
|
|
181
|
-
`login as "${username}" failed with HTTP ${
|
|
182
|
-
|
|
229
|
+
`login as "${username}" failed with HTTP ${res
|
|
230
|
+
->responseStatus
|
|
231
|
+
->Int.toString}: ${detail}`,
|
|
183
232
|
),
|
|
184
233
|
)
|
|
185
234
|
}
|
|
@@ -254,7 +303,19 @@ let attempts = 4
|
|
|
254
303
|
* fixed delay would have the whole batch retry in lockstep and rebuild the same
|
|
255
304
|
* burst that provoked the fault.
|
|
256
305
|
*/
|
|
257
|
-
|
|
306
|
+
/** What a document came back as. `Errors` carries both the raw array — so a
|
|
307
|
+
caller can classify it, e.g. as a refusal — and the message `gql` would have
|
|
308
|
+
thrown, so the two paths cannot describe the same failure differently. */
|
|
309
|
+
type failure = {errors: JSON.t, message: string}
|
|
310
|
+
type outcome = Data(JSON.t) | Errors(failure)
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Sends one document and reports what came back, resending a transient fault
|
|
314
|
+
* along the way. Returning the errors rather than throwing is what lets a caller
|
|
315
|
+
* treat a refusal as an *answer* — which the authorization probe needs and
|
|
316
|
+
* seeding must never do.
|
|
317
|
+
*/
|
|
318
|
+
let gqlOutcome = async (t: t, ~query: string, ~label: string): outcome => {
|
|
258
319
|
let headers = Dict.fromArray([("content-type", "application/json")])
|
|
259
320
|
switch t.token {
|
|
260
321
|
| Some(token) => headers->Dict.set("authorization", `Bearer ${token}`)
|
|
@@ -263,10 +324,9 @@ let gql = async (t: t, ~query: string, ~label: string): JSON.t => {
|
|
|
263
324
|
let body = JSON.stringify(
|
|
264
325
|
JSON.Encode.object(Dict.fromArray([("query", JSON.Encode.string(query))])),
|
|
265
326
|
)
|
|
266
|
-
let rec attempt = async (n: int):
|
|
327
|
+
let rec attempt = async (n: int): outcome => {
|
|
267
328
|
let res = try await fetch(t.config.endpoint, {method: "POST", headers, body}) catch {
|
|
268
|
-
| _ =>
|
|
269
|
-
throw(Failed(`${label}: cannot reach ${t.config.endpoint} — is the platform running?`))
|
|
329
|
+
| _ => throw(Failed(`${label}: cannot reach ${t.config.endpoint} — is the platform running?`))
|
|
270
330
|
}
|
|
271
331
|
let json = await res->responseJson
|
|
272
332
|
switch json->field("errors") {
|
|
@@ -283,20 +343,52 @@ let gql = async (t: t, ~query: string, ~label: string): JSON.t => {
|
|
|
283
343
|
| Some(summary) => `\n identity: ${summary}`
|
|
284
344
|
| None => ""
|
|
285
345
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
)
|
|
346
|
+
Errors({
|
|
347
|
+
errors,
|
|
348
|
+
message: `${label} failed\n query: ${query}\n response: ${JSON.stringify(
|
|
349
|
+
errors,
|
|
350
|
+
)}${identity}${tried}`,
|
|
351
|
+
})
|
|
293
352
|
}
|
|
294
|
-
| None => json->field("data")->Option.getOr(JSON.Encode.null)
|
|
353
|
+
| None => Data(json->field("data")->Option.getOr(JSON.Encode.null))
|
|
295
354
|
}
|
|
296
355
|
}
|
|
297
356
|
await attempt(1)
|
|
298
357
|
}
|
|
299
358
|
|
|
359
|
+
let gql = async (t: t, ~query: string, ~label: string): JSON.t =>
|
|
360
|
+
switch await gqlOutcome(t, ~query, ~label) {
|
|
361
|
+
| Data(data) => data
|
|
362
|
+
| Errors({message}) => throw(Failed(message))
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Whether the endpoint let this caller through, for a probe that means to find
|
|
367
|
+
* out rather than to succeed.
|
|
368
|
+
*
|
|
369
|
+
* Three outcomes and not two: "not refused" and "worked" are different claims,
|
|
370
|
+
* and collapsing them would let a misspelled field name read as a passing
|
|
371
|
+
* authorization check. `Granted` covers a domain rejection too — a
|
|
372
|
+
* `CommandRejected` arrives inside `data` with no GraphQL error, which is
|
|
373
|
+
* precisely how a probe confirms access without writing anything.
|
|
374
|
+
*/
|
|
375
|
+
type access =
|
|
376
|
+
| Granted
|
|
377
|
+
| Refused
|
|
378
|
+
| // Served, and carrying nothing. Its own case because the two platforms deny a
|
|
379
|
+
// read differently: AppSync answers `Unauthorized`, while the local resolver's
|
|
380
|
+
// interceptor returns an empty connection — so on local an unauthorized read
|
|
381
|
+
// and an empty view are the same response, and no caller can tell them apart.
|
|
382
|
+
// Naming it lets a probe say "consistent with a denial" instead of guessing.
|
|
383
|
+
Empty
|
|
384
|
+
| Broke(string)
|
|
385
|
+
|
|
386
|
+
let checkAccess = async (t: t, ~query: string, ~label: string): access =>
|
|
387
|
+
switch await gqlOutcome(t, ~query, ~label) {
|
|
388
|
+
| Data(_) => Granted
|
|
389
|
+
| Errors({errors, message}) => errors->isDenied ? Refused : Broke(message)
|
|
390
|
+
}
|
|
391
|
+
|
|
300
392
|
// ── Commands ────────────────────────────────────────────────────────────────
|
|
301
393
|
|
|
302
394
|
let commandResultSelection = `__typename
|
|
@@ -328,6 +420,61 @@ let send = async (t: t, m: mutation, ~tolerate: array<string>=[]): option<string
|
|
|
328
420
|
}
|
|
329
421
|
}
|
|
330
422
|
|
|
423
|
+
/**
|
|
424
|
+
* Whether this caller may issue one command — asked with the very document
|
|
425
|
+
* `send` would have sent, so what the probe tests is what the seed actually
|
|
426
|
+
* uses rather than a hand-built lookalike.
|
|
427
|
+
*
|
|
428
|
+
* Pair it with a payload the domain would refuse anyway. Then `Granted` arrives
|
|
429
|
+
* as a `CommandRejected` inside `data`, which appends nothing: the probe learns
|
|
430
|
+
* that the door opened without walking through it, and a regression that wrongly
|
|
431
|
+
* opens a door still writes no events.
|
|
432
|
+
*/
|
|
433
|
+
/** Refusal codes a command result carries. The two platforms answer a refused
|
|
434
|
+
command differently — AppSync rejects at the field and produces a GraphQL
|
|
435
|
+
`Unauthorized` error, while the local resolver runs the command handler and
|
|
436
|
+
answers `CommandRejected { errorCode: "Forbidden" }` inside `data`. Reading
|
|
437
|
+
only the first reports the second as allowed. */
|
|
438
|
+
let deniedCommandCodes = ["Forbidden", "Unauthorized", "AccessDenied"]
|
|
439
|
+
|
|
440
|
+
let isDeniedResult = (result: JSON.t): bool =>
|
|
441
|
+
switch (result->nodeString("errorCode"), result->nodeString("errorDetail")) {
|
|
442
|
+
| (Some(code), _) if deniedCommandCodes->Array.includes(code) => true
|
|
443
|
+
| (_, Some(detail)) => detail->String.toLowerCase->String.includes("not authorized")
|
|
444
|
+
| _ => false
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
let checkCommandAccess = async (t: t, m: mutation): access =>
|
|
448
|
+
switch await gqlOutcome(
|
|
449
|
+
t,
|
|
450
|
+
~query=`mutation { r: ${m.field}(${m.args->renderArgs}) { ${commandResultSelection} } }`,
|
|
451
|
+
~label=m->describe,
|
|
452
|
+
) {
|
|
453
|
+
| Errors({errors, message}) => errors->isDenied ? Refused : Broke(message)
|
|
454
|
+
// No GraphQL error is not yet a grant: the local platform's refusal lives in
|
|
455
|
+
// the command result, so the domain's own answer has to be read too. Any other
|
|
456
|
+
// rejection IS a grant — the door opened and the domain refused what came
|
|
457
|
+
// through it, which is exactly the outcome a non-writing probe wants.
|
|
458
|
+
| Data(data) =>
|
|
459
|
+
data->field("r")->Option.getOr(JSON.Encode.null)->isDeniedResult ? Refused : Granted
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Whether this caller may read one connection field. One row is enough: the
|
|
463
|
+
door is answered by the first request, not by the page behind it — but
|
|
464
|
+
whether a row came back is what separates a grant from a silent denial. */
|
|
465
|
+
let checkQueryAccess = async (t: t, ~field as fieldName: string): access => {
|
|
466
|
+
let query = `{ ${fieldName}(first: 1) { edges { node { id } } } }`
|
|
467
|
+
switch await gqlOutcome(t, ~query, ~label=fieldName) {
|
|
468
|
+
| Errors({errors, message}) => errors->isDenied ? Refused : Broke(message)
|
|
469
|
+
| Data(data) =>
|
|
470
|
+
let edges = switch data->field(fieldName)->Option.flatMap(c => c->field("edges")) {
|
|
471
|
+
| Some(Array(rows)) => rows->Array.length
|
|
472
|
+
| _ => 0
|
|
473
|
+
}
|
|
474
|
+
edges > 0 ? Granted : Empty
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
331
478
|
let sendAll = async (t: t, mutations: array<mutation>): unit =>
|
|
332
479
|
for i in 0 to mutations->Array.length - 1 {
|
|
333
480
|
switch mutations->Array.get(i) {
|
|
@@ -342,12 +489,12 @@ let sendAll = async (t: t, mutations: array<mutation>): unit =>
|
|
|
342
489
|
* Walks a Relay-style connection to the end. Connections page at 50 by default,
|
|
343
490
|
* so any count taken from a single request silently truncates.
|
|
344
491
|
*/
|
|
345
|
-
// `~args` is spliced into the connection's argument list — `"includeRetired: true"`
|
|
492
|
+
let // `~args` is spliced into the connection's argument list — `"includeRetired: true"`
|
|
346
493
|
// is the case it exists for, and the only way to read rows the resolvers withhold
|
|
347
494
|
// by default. Literal GraphQL rather than a typed argument list because a seed
|
|
348
495
|
// writes the query it means; the alternative is a builder that has to grow a case
|
|
349
496
|
// per argument the platform adds.
|
|
350
|
-
|
|
497
|
+
queryAllNodes = async (
|
|
351
498
|
t: t,
|
|
352
499
|
~field as fieldName: string,
|
|
353
500
|
~selection: string,
|
|
@@ -463,8 +610,9 @@ let waitForIds = async (
|
|
|
463
610
|
missing->Array.length > 10 ? ` (+${(missing->Array.length - 10)->Int.toString} more)` : ""
|
|
464
611
|
throw(
|
|
465
612
|
Failed(
|
|
466
|
-
`only ${
|
|
467
|
-
|
|
613
|
+
`only ${found->Array.length->Int.toString}/${ids
|
|
614
|
+
->Array.length
|
|
615
|
+
->Int.toString} ids reached ${fieldName} within ${timeoutMs->Int.toString}ms.\n missing: ${shown}${extra}`,
|
|
468
616
|
),
|
|
469
617
|
)
|
|
470
618
|
} else {
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -15,15 +15,16 @@ open JestGlobals
|
|
|
15
15
|
|
|
16
16
|
let errors = entries => JSON.Encode.array(entries->Array.map(JSON.Encode.object))
|
|
17
17
|
|
|
18
|
-
let errorOfType = errorType =>
|
|
19
|
-
Dict.fromArray([("errorType", JSON.Encode.string(errorType))])
|
|
18
|
+
let errorOfType = errorType => Dict.fromArray([("errorType", JSON.Encode.string(errorType))])
|
|
20
19
|
|
|
21
20
|
describe("Seed_Client.isTransient:", () => {
|
|
22
21
|
testSync("resends a fault on the endpoint's own side", () =>
|
|
23
|
-
[
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
[
|
|
23
|
+
"InternalFailure",
|
|
24
|
+
"ServiceUnavailable",
|
|
25
|
+
"Throttling",
|
|
26
|
+
"TooManyRequestsException",
|
|
27
|
+
]->Array.forEach(t => expect(Seed_Client.isTransient(errors([errorOfType(t)])))->toBe(true))
|
|
27
28
|
)
|
|
28
29
|
|
|
29
30
|
// The exact payload the seed aborted on.
|
|
@@ -33,8 +34,8 @@ describe("Seed_Client.isTransient:", () => {
|
|
|
33
34
|
})
|
|
34
35
|
|
|
35
36
|
testSync("fails fast on an error that is an answer", () =>
|
|
36
|
-
["ValidationException", "Unauthorized", "FieldUndefined"]->Array.forEach(
|
|
37
|
-
expect(Seed_Client.isTransient(errors([errorOfType(t)])))->toBe(false)
|
|
37
|
+
["ValidationException", "Unauthorized", "FieldUndefined"]->Array.forEach(
|
|
38
|
+
t => expect(Seed_Client.isTransient(errors([errorOfType(t)])))->toBe(false),
|
|
38
39
|
)
|
|
39
40
|
)
|
|
40
41
|
|
|
@@ -52,8 +53,9 @@ describe("Seed_Client.isTransient:", () => {
|
|
|
52
53
|
// endpoint said errors but named none" into an endless resend.
|
|
53
54
|
testSync("treats an empty or untyped error list as final", () => {
|
|
54
55
|
expect(Seed_Client.isTransient(errors([])))->toBe(false)
|
|
55
|
-
expect(
|
|
56
|
-
|
|
56
|
+
expect(
|
|
57
|
+
Seed_Client.isTransient(errors([Dict.fromArray([("message", JSON.Encode.string("boom"))])])),
|
|
58
|
+
)->toBe(false)
|
|
57
59
|
expect(Seed_Client.isTransient(JSON.Encode.null))->toBe(false)
|
|
58
60
|
})
|
|
59
61
|
})
|
|
@@ -70,8 +72,15 @@ let b64url = payload =>
|
|
|
70
72
|
->NodeBuffer.fromStringUtf8
|
|
71
73
|
->NodeBuffer.toStringBase64Url
|
|
72
74
|
|
|
75
|
+
// A REAL JOSE header, not the literal string "header". The difference is the
|
|
76
|
+
// whole bug: `{"kid":…,"alg":"RS256"}` is base64url JSON and decodes exactly as
|
|
77
|
+
// the payload does, so `claims` took it as the claims set and found no groups
|
|
78
|
+
// and no id in any Cognito token ever issued. A fake header that happened not to
|
|
79
|
+
// decode is what let every assertion below pass against broken code.
|
|
80
|
+
let joseHeader = b64url(`{"kid":"abc123","alg":"RS256"}`)
|
|
81
|
+
|
|
73
82
|
let jwt = claims =>
|
|
74
|
-
|
|
83
|
+
`${joseHeader}.${b64url(JSON.stringify(JSON.Encode.object(Dict.fromArray(claims))))}.signature`
|
|
75
84
|
|
|
76
85
|
let localToken = claims =>
|
|
77
86
|
`${b64url(JSON.stringify(JSON.Encode.object(Dict.fromArray(claims))))}.signature`
|
|
@@ -84,12 +93,41 @@ let clientWith = token => {
|
|
|
84
93
|
c
|
|
85
94
|
}
|
|
86
95
|
|
|
96
|
+
describe("Seed_Client.claims:", () => {
|
|
97
|
+
// The regression this file could not previously express. A JWT header is a
|
|
98
|
+
// decodable JSON object sitting in front of the payload, so "the first segment
|
|
99
|
+
// that parses" is the header on every real token — and the seed then reported
|
|
100
|
+
// no groups, printed no `Acting as:` line, and attached no identity to a
|
|
101
|
+
// refusal, which is exactly the diagnosis it exists to give.
|
|
102
|
+
testSync("skips the JOSE header and reads the payload behind it", () =>
|
|
103
|
+
expect(
|
|
104
|
+
clientWith(jwt([("sub", JSON.Encode.string("real-sub"))]))
|
|
105
|
+
->Seed_Client.claims
|
|
106
|
+
->Option.flatMap(c => c->Dict.get("sub"))
|
|
107
|
+
->Option.flatMap(JSON.Decode.string),
|
|
108
|
+
)->toEqual(Some("real-sub"))
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
// The header is identified by `alg`, not by position — a two-segment local dev
|
|
112
|
+
// token has no header at all, and its payload must not be mistaken for one.
|
|
113
|
+
testSync("takes the first segment when there is no header", () =>
|
|
114
|
+
expect(
|
|
115
|
+
clientWith(localToken([("userId", JSON.Encode.string("local-shopper"))]))
|
|
116
|
+
->Seed_Client.claims
|
|
117
|
+
->Option.flatMap(c => c->Dict.get("userId"))
|
|
118
|
+
->Option.flatMap(JSON.Decode.string),
|
|
119
|
+
)->toEqual(Some("local-shopper"))
|
|
120
|
+
)
|
|
121
|
+
})
|
|
122
|
+
|
|
87
123
|
describe("Seed_Client.effectiveGroups:", () => {
|
|
88
124
|
// A Cognito id token carries its payload in the second segment, a local dev
|
|
89
125
|
// token in the first. Neither platform should have to announce which it is.
|
|
90
126
|
testSync("reads the payload wherever the provider put it", () => {
|
|
91
127
|
expect(
|
|
92
|
-
clientWith(
|
|
128
|
+
clientWith(
|
|
129
|
+
jwt([("cognito:groups", strings(["Admin", "Shopper"]))]),
|
|
130
|
+
)->Seed_Client.effectiveGroups,
|
|
93
131
|
)->toEqual(Some(["Admin", "Shopper"]))
|
|
94
132
|
expect(
|
|
95
133
|
clientWith(localToken([("groups", strings(["Merchandiser"]))]))->Seed_Client.effectiveGroups,
|
|
@@ -101,12 +139,64 @@ describe("Seed_Client.effectiveGroups:", () => {
|
|
|
101
139
|
testSync("says nothing about a token it cannot read", () => {
|
|
102
140
|
expect(clientWith("not-a-token")->Seed_Client.effectiveGroups)->toEqual(None)
|
|
103
141
|
expect(
|
|
104
|
-
Seed_Client.make(
|
|
105
|
-
|
|
142
|
+
Seed_Client.make(
|
|
143
|
+
~config={endpoint: "http://example.invalid/graphql"},
|
|
144
|
+
)->Seed_Client.effectiveGroups,
|
|
106
145
|
)->toEqual(None)
|
|
107
146
|
})
|
|
108
147
|
})
|
|
109
148
|
|
|
149
|
+
// The id an `@owner` field is stamped with. A seed that cannot read it can only
|
|
150
|
+
// key owner-scoped rows to a literal, and a literal matches one platform's
|
|
151
|
+
// accounts at most — which is how a demo shopper came to be registered on a
|
|
152
|
+
// deployment where nobody can ever be that shopper.
|
|
153
|
+
|
|
154
|
+
describe("Seed_Client.callerId:", () => {
|
|
155
|
+
// A Cognito id token says `sub`; the local dev token is a base64url
|
|
156
|
+
// `Identity.t`, whose field is `userId`. Neither platform announces which.
|
|
157
|
+
testSync("reads the id under whichever name the provider gave it", () => {
|
|
158
|
+
expect(
|
|
159
|
+
clientWith(jwt([("sub", JSON.Encode.string("4275e4a4-00c1-70e1"))]))->Seed_Client.callerId,
|
|
160
|
+
)->toEqual(Some("4275e4a4-00c1-70e1"))
|
|
161
|
+
expect(
|
|
162
|
+
clientWith(
|
|
163
|
+
localToken([("userId", JSON.Encode.string("local-shopper"))]),
|
|
164
|
+
)->Seed_Client.callerId,
|
|
165
|
+
)->toEqual(Some("local-shopper"))
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// `sub` first, so a token carrying both is read the way the pool that minted
|
|
169
|
+
// it means it.
|
|
170
|
+
testSync("prefers sub when a token carries both names", () =>
|
|
171
|
+
expect(
|
|
172
|
+
clientWith(
|
|
173
|
+
jwt([
|
|
174
|
+
("sub", JSON.Encode.string("f215e4b4-30f1-700c")),
|
|
175
|
+
("userId", JSON.Encode.string("local-shopper")),
|
|
176
|
+
]),
|
|
177
|
+
)->Seed_Client.callerId,
|
|
178
|
+
)->toEqual(Some("f215e4b4-30f1-700c"))
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
// `None` is not an error — it means this cannot add anything, and the caller
|
|
182
|
+
// falls back to whatever the accounts file declared.
|
|
183
|
+
testSync("says nothing about a token it cannot read, or one naming no id", () => {
|
|
184
|
+
expect(clientWith("not-a-token")->Seed_Client.callerId)->toEqual(None)
|
|
185
|
+
expect(
|
|
186
|
+
clientWith(jwt([("cognito:groups", strings(["Admin"]))]))->Seed_Client.callerId,
|
|
187
|
+
)->toEqual(None)
|
|
188
|
+
expect(
|
|
189
|
+
Seed_Client.make(~config={endpoint: "http://example.invalid/graphql"})->Seed_Client.callerId,
|
|
190
|
+
)->toEqual(None)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
// A claim of the wrong shape is not an id. Taking the array's first element
|
|
194
|
+
// would be a guess, and a guessed owner id seeds rows nobody can read.
|
|
195
|
+
testSync("refuses a claim that is not a single string", () =>
|
|
196
|
+
expect(clientWith(jwt([("sub", strings(["a", "b"]))]))->Seed_Client.callerId)->toEqual(None)
|
|
197
|
+
)
|
|
198
|
+
})
|
|
199
|
+
|
|
110
200
|
describe("Seed_Client.identitySummary:", () => {
|
|
111
201
|
// The case that cost the debugging: eligible in the user list, refused by the
|
|
112
202
|
// token. Naming what it was narrowed FROM is what points at the role switch.
|
|
@@ -124,8 +214,9 @@ describe("Seed_Client.identitySummary:", () => {
|
|
|
124
214
|
// An ordinary login is not narrowed and has nothing to explain.
|
|
125
215
|
testSync("stays quiet about an unnarrowed token", () =>
|
|
126
216
|
expect(
|
|
127
|
-
clientWith(
|
|
128
|
-
|
|
217
|
+
clientWith(
|
|
218
|
+
jwt([("cognito:groups", strings(["Admin", "Shopper"]))]),
|
|
219
|
+
)->Seed_Client.identitySummary,
|
|
129
220
|
)->toEqual(Some("Admin, Shopper"))
|
|
130
221
|
)
|
|
131
222
|
|
|
@@ -137,6 +228,45 @@ describe("Seed_Client.identitySummary:", () => {
|
|
|
137
228
|
)
|
|
138
229
|
})
|
|
139
230
|
|
|
231
|
+
// Whether a token is narrowed cannot be read off the group claim: a bearer
|
|
232
|
+
// carrying one group looks identical whether the account holds one role or five.
|
|
233
|
+
// `availableRoles` is written only when a role was chosen, so its presence IS
|
|
234
|
+
// the narrowing — and that is what decides whether the seed has anything to ask.
|
|
235
|
+
|
|
236
|
+
describe("Seed_Client.narrowedFrom:", () => {
|
|
237
|
+
testSync("names the membership a narrowed token was reduced from", () =>
|
|
238
|
+
expect(
|
|
239
|
+
clientWith(
|
|
240
|
+
jwt([
|
|
241
|
+
("cognito:groups", strings(["Shopper"])),
|
|
242
|
+
("availableRoles", JSON.Encode.string("Admin,Shopper")),
|
|
243
|
+
]),
|
|
244
|
+
)->Seed_Client.narrowedFrom,
|
|
245
|
+
)->toEqual(Some(["Admin", "Shopper"]))
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
// The case that must NOT prompt: a single-role account is not narrowed, it is
|
|
249
|
+
// simply small. Asking it to choose would put a question in every run.
|
|
250
|
+
testSync("says nothing about an unnarrowed token", () => {
|
|
251
|
+
expect(
|
|
252
|
+
clientWith(jwt([("cognito:groups", strings(["Shopper"]))]))->Seed_Client.narrowedFrom,
|
|
253
|
+
)->toEqual(None)
|
|
254
|
+
expect(
|
|
255
|
+
clientWith(
|
|
256
|
+
jwt([("cognito:groups", strings(["Admin", "Shopper"]))]),
|
|
257
|
+
)->Seed_Client.narrowedFrom,
|
|
258
|
+
)->toEqual(None)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
// An empty claim is not a narrowing to nothing — it is a claim carrying no
|
|
262
|
+
// information, and offering a menu of no roles would be a dead end.
|
|
263
|
+
testSync("treats an empty availableRoles as not narrowed", () =>
|
|
264
|
+
expect(
|
|
265
|
+
clientWith(jwt([("availableRoles", JSON.Encode.string(""))]))->Seed_Client.narrowedFrom,
|
|
266
|
+
)->toEqual(None)
|
|
267
|
+
)
|
|
268
|
+
})
|
|
269
|
+
|
|
140
270
|
describe("Seed_Client.isDenied:", () => {
|
|
141
271
|
// AppSync types it; the local server carries it in the message.
|
|
142
272
|
testSync("recognises a refusal in either platform's vocabulary", () => {
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|