@reventlessdev/reventless-seed 1.0.0-alpha.2 → 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.
Files changed (178) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/lib/bs/.compiler.log +2 -2
  3. package/lib/bs/.sourcedirs.json +1 -0
  4. package/lib/bs/ReventlessSeed.cmi +0 -0
  5. package/lib/bs/ReventlessSeed.cmt +0 -0
  6. package/lib/bs/ReventlessSeed.mlmap +6 -0
  7. package/lib/bs/compiler-info.json +2 -2
  8. package/lib/bs/src/Seed-ReventlessSeed.cmi +0 -0
  9. package/lib/bs/src/Seed-ReventlessSeed.cmt +0 -0
  10. package/lib/bs/src/Seed.ast +0 -0
  11. package/lib/bs/src/Seed.res +12 -3
  12. package/lib/bs/src/Seed.res.mjs +3 -0
  13. package/lib/bs/src/Seed_Client-ReventlessSeed.cmi +0 -0
  14. package/lib/bs/src/Seed_Client-ReventlessSeed.cmj +0 -0
  15. package/lib/bs/src/Seed_Client-ReventlessSeed.cmt +0 -0
  16. package/lib/bs/src/Seed_Client.ast +0 -0
  17. package/lib/bs/src/Seed_Client.res +382 -21
  18. package/lib/bs/src/Seed_Client.res.mjs +332 -24
  19. package/lib/bs/src/Seed_Connect-ReventlessSeed.cmi +0 -0
  20. package/lib/bs/src/Seed_Connect-ReventlessSeed.cmj +0 -0
  21. package/lib/bs/src/Seed_Connect-ReventlessSeed.cmt +0 -0
  22. package/lib/bs/src/Seed_Connect.ast +0 -0
  23. package/lib/bs/src/Seed_Connect.res +281 -33
  24. package/lib/bs/src/Seed_Connect.res.mjs +162 -8
  25. package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmi +0 -0
  26. package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmj +0 -0
  27. package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmt +0 -0
  28. package/lib/bs/src/Seed_Prompt.ast +0 -0
  29. package/lib/bs/src/Seed_Prompt.res +140 -41
  30. package/lib/bs/src/Seed_Prompt.res.mjs +88 -13
  31. package/lib/bs/src/Seed_Random-ReventlessSeed.cmi +0 -0
  32. package/lib/bs/src/Seed_Random-ReventlessSeed.cmt +0 -0
  33. package/lib/bs/src/Seed_Random.ast +0 -0
  34. package/lib/bs/src/Seed_Random.res +4 -2
  35. package/lib/bs/src/Seed_Runner-ReventlessSeed.cmi +0 -0
  36. package/lib/bs/src/Seed_Runner-ReventlessSeed.cmj +0 -0
  37. package/lib/bs/src/Seed_Runner-ReventlessSeed.cmt +0 -0
  38. package/lib/bs/src/Seed_Runner.ast +0 -0
  39. package/lib/bs/src/Seed_Runner.res +45 -19
  40. package/lib/bs/src/Seed_Runner.res.mjs +19 -1
  41. package/lib/bs/src/Seed_Types-ReventlessSeed.cmt +0 -0
  42. package/lib/bs/src/Seed_Upload-ReventlessSeed.cmi +0 -0
  43. package/lib/bs/src/Seed_Upload-ReventlessSeed.cmj +0 -0
  44. package/lib/bs/src/Seed_Upload-ReventlessSeed.cmt +0 -0
  45. package/lib/bs/src/Seed_Upload.ast +0 -0
  46. package/lib/bs/src/Seed_Upload.res +43 -43
  47. package/lib/bs/src/Seed_Upload.res.mjs +65 -69
  48. package/lib/bs/src/Seed_Users-ReventlessSeed.cmi +0 -0
  49. package/lib/bs/src/Seed_Users-ReventlessSeed.cmj +0 -0
  50. package/lib/bs/src/Seed_Users-ReventlessSeed.cmt +0 -0
  51. package/lib/bs/src/Seed_Users.ast +0 -0
  52. package/lib/bs/src/Seed_Users.res +92 -0
  53. package/lib/bs/src/Seed_Users.res.mjs +91 -0
  54. package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmi +0 -0
  55. package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmj +0 -0
  56. package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmt +0 -0
  57. package/lib/bs/tests/Seed_ClientTest.ast +0 -0
  58. package/lib/bs/tests/Seed_ClientTest.res +287 -0
  59. package/lib/bs/tests/Seed_ClientTest.res.mjs +268 -0
  60. package/lib/bs/tests/Seed_ConnectTest-ReventlessSeed.cmi +0 -0
  61. package/lib/bs/tests/Seed_ConnectTest-ReventlessSeed.cmj +0 -0
  62. package/lib/bs/tests/Seed_ConnectTest-ReventlessSeed.cmt +0 -0
  63. package/lib/bs/tests/Seed_ConnectTest.ast +0 -0
  64. package/lib/bs/tests/Seed_ConnectTest.res +64 -0
  65. package/lib/bs/tests/Seed_ConnectTest.res.mjs +56 -0
  66. package/lib/bs/tests/Seed_PromptTest-ReventlessSeed.cmi +0 -0
  67. package/lib/bs/tests/Seed_PromptTest-ReventlessSeed.cmj +0 -0
  68. package/lib/bs/tests/Seed_PromptTest-ReventlessSeed.cmt +0 -0
  69. package/lib/bs/tests/Seed_PromptTest.ast +0 -0
  70. package/lib/bs/tests/Seed_PromptTest.res +55 -0
  71. package/lib/bs/tests/Seed_PromptTest.res.mjs +63 -0
  72. package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmi +0 -0
  73. package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmj +0 -0
  74. package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmt +0 -0
  75. package/lib/bs/tests/Seed_RandomTest.ast +0 -0
  76. package/lib/bs/tests/Seed_RandomTest.res +200 -0
  77. package/lib/bs/tests/Seed_RandomTest.res.mjs +208 -0
  78. package/lib/bs/tests/Seed_UsersTest-ReventlessSeed.cmi +0 -0
  79. package/lib/bs/tests/Seed_UsersTest-ReventlessSeed.cmj +0 -0
  80. package/lib/bs/tests/Seed_UsersTest-ReventlessSeed.cmt +0 -0
  81. package/lib/bs/tests/Seed_UsersTest.ast +0 -0
  82. package/lib/bs/tests/Seed_UsersTest.res +129 -0
  83. package/lib/bs/tests/Seed_UsersTest.res.mjs +113 -0
  84. package/lib/ocaml/.compiler.log +2 -2
  85. package/lib/ocaml/ReventlessSeed.cmi +0 -0
  86. package/lib/ocaml/ReventlessSeed.cmt +0 -0
  87. package/lib/ocaml/ReventlessSeed.mlmap +6 -0
  88. package/lib/ocaml/Seed-ReventlessSeed.cmi +0 -0
  89. package/lib/ocaml/Seed-ReventlessSeed.cmt +0 -0
  90. package/lib/ocaml/Seed.ast +0 -0
  91. package/lib/ocaml/Seed.res +12 -3
  92. package/lib/ocaml/Seed_Client-ReventlessSeed.cmi +0 -0
  93. package/lib/ocaml/Seed_Client-ReventlessSeed.cmj +0 -0
  94. package/lib/ocaml/Seed_Client-ReventlessSeed.cmt +0 -0
  95. package/lib/ocaml/Seed_Client.ast +0 -0
  96. package/lib/ocaml/Seed_Client.res +382 -21
  97. package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmi +0 -0
  98. package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmj +0 -0
  99. package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmt +0 -0
  100. package/lib/ocaml/Seed_ClientTest.ast +0 -0
  101. package/lib/ocaml/Seed_ClientTest.res +287 -0
  102. package/lib/ocaml/Seed_Connect-ReventlessSeed.cmi +0 -0
  103. package/lib/ocaml/Seed_Connect-ReventlessSeed.cmj +0 -0
  104. package/lib/ocaml/Seed_Connect-ReventlessSeed.cmt +0 -0
  105. package/lib/ocaml/Seed_Connect.ast +0 -0
  106. package/lib/ocaml/Seed_Connect.res +281 -33
  107. package/lib/ocaml/Seed_ConnectTest-ReventlessSeed.cmi +0 -0
  108. package/lib/ocaml/Seed_ConnectTest-ReventlessSeed.cmj +0 -0
  109. package/lib/ocaml/Seed_ConnectTest-ReventlessSeed.cmt +0 -0
  110. package/lib/ocaml/Seed_ConnectTest.ast +0 -0
  111. package/lib/ocaml/Seed_ConnectTest.res +64 -0
  112. package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmi +0 -0
  113. package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmj +0 -0
  114. package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmt +0 -0
  115. package/lib/ocaml/Seed_Prompt.ast +0 -0
  116. package/lib/ocaml/Seed_Prompt.res +140 -41
  117. package/lib/ocaml/Seed_PromptTest-ReventlessSeed.cmi +0 -0
  118. package/lib/ocaml/Seed_PromptTest-ReventlessSeed.cmj +0 -0
  119. package/lib/ocaml/Seed_PromptTest-ReventlessSeed.cmt +0 -0
  120. package/lib/ocaml/Seed_PromptTest.ast +0 -0
  121. package/lib/ocaml/Seed_PromptTest.res +55 -0
  122. package/lib/ocaml/Seed_Random-ReventlessSeed.cmi +0 -0
  123. package/lib/ocaml/Seed_Random-ReventlessSeed.cmt +0 -0
  124. package/lib/ocaml/Seed_Random.ast +0 -0
  125. package/lib/ocaml/Seed_Random.res +4 -2
  126. package/lib/ocaml/Seed_RandomTest-ReventlessSeed.cmi +0 -0
  127. package/lib/ocaml/Seed_RandomTest-ReventlessSeed.cmj +0 -0
  128. package/lib/ocaml/Seed_RandomTest-ReventlessSeed.cmt +0 -0
  129. package/lib/ocaml/Seed_RandomTest.ast +0 -0
  130. package/lib/ocaml/Seed_RandomTest.res +200 -0
  131. package/lib/ocaml/Seed_Runner-ReventlessSeed.cmi +0 -0
  132. package/lib/ocaml/Seed_Runner-ReventlessSeed.cmj +0 -0
  133. package/lib/ocaml/Seed_Runner-ReventlessSeed.cmt +0 -0
  134. package/lib/ocaml/Seed_Runner.ast +0 -0
  135. package/lib/ocaml/Seed_Runner.res +45 -19
  136. package/lib/ocaml/Seed_Types-ReventlessSeed.cmt +0 -0
  137. package/lib/ocaml/Seed_Upload-ReventlessSeed.cmi +0 -0
  138. package/lib/ocaml/Seed_Upload-ReventlessSeed.cmj +0 -0
  139. package/lib/ocaml/Seed_Upload-ReventlessSeed.cmt +0 -0
  140. package/lib/ocaml/Seed_Upload.ast +0 -0
  141. package/lib/ocaml/Seed_Upload.res +43 -43
  142. package/lib/ocaml/Seed_Users-ReventlessSeed.cmi +0 -0
  143. package/lib/ocaml/Seed_Users-ReventlessSeed.cmj +0 -0
  144. package/lib/ocaml/Seed_Users-ReventlessSeed.cmt +0 -0
  145. package/lib/ocaml/Seed_Users.ast +0 -0
  146. package/lib/ocaml/Seed_Users.res +92 -0
  147. package/lib/ocaml/Seed_UsersTest-ReventlessSeed.cmi +0 -0
  148. package/lib/ocaml/Seed_UsersTest-ReventlessSeed.cmj +0 -0
  149. package/lib/ocaml/Seed_UsersTest-ReventlessSeed.cmt +0 -0
  150. package/lib/ocaml/Seed_UsersTest.ast +0 -0
  151. package/lib/ocaml/Seed_UsersTest.res +129 -0
  152. package/package.json +7 -2
  153. package/rescript.json +9 -1
  154. package/src/Seed.res +12 -3
  155. package/src/Seed.res.mjs +3 -0
  156. package/src/Seed_Client.res +382 -21
  157. package/src/Seed_Client.res.mjs +332 -24
  158. package/src/Seed_Connect.res +281 -33
  159. package/src/Seed_Connect.res.mjs +162 -8
  160. package/src/Seed_Prompt.res +140 -41
  161. package/src/Seed_Prompt.res.mjs +88 -13
  162. package/src/Seed_Random.res +4 -2
  163. package/src/Seed_Runner.res +45 -19
  164. package/src/Seed_Runner.res.mjs +19 -1
  165. package/src/Seed_Upload.res +43 -43
  166. package/src/Seed_Upload.res.mjs +65 -69
  167. package/src/Seed_Users.res +92 -0
  168. package/src/Seed_Users.res.mjs +91 -0
  169. package/tests/Seed_ClientTest.res +287 -0
  170. package/tests/Seed_ClientTest.res.mjs +268 -0
  171. package/tests/Seed_ConnectTest.res +64 -0
  172. package/tests/Seed_ConnectTest.res.mjs +56 -0
  173. package/tests/Seed_PromptTest.res +55 -0
  174. package/tests/Seed_PromptTest.res.mjs +63 -0
  175. package/tests/Seed_RandomTest.res +200 -0
  176. package/tests/Seed_RandomTest.res.mjs +208 -0
  177. package/tests/Seed_UsersTest.res +129 -0
  178. package/tests/Seed_UsersTest.res.mjs +113 -0
@@ -0,0 +1,287 @@
1
+ open JestGlobals
2
+
3
+ // A seed issues hundreds of commands against runtimes a preceding reset left
4
+ // cold, so the endpoint faulting on one of them is a normal event rather than an
5
+ // exceptional one. Before the retry, a single `InternalFailure` in that batch
6
+ // aborted the run and left a half-seeded store that could only be recovered by a
7
+ // full wipe and a re-run — for a request that would have succeeded on being
8
+ // asked again.
9
+ //
10
+ // What makes the retry safe is the classifier, so that is what these pin. A
11
+ // fault on the endpoint's side carries no information about the document and is
12
+ // worth resending; anything that is an *answer* — a validation error, an unknown
13
+ // field, a refused token — comes back identical however many times it is asked,
14
+ // and resending it only delays the failure the operator needs to see.
15
+
16
+ let errors = entries => JSON.Encode.array(entries->Array.map(JSON.Encode.object))
17
+
18
+ let errorOfType = errorType => Dict.fromArray([("errorType", JSON.Encode.string(errorType))])
19
+
20
+ describe("Seed_Client.isTransient:", () => {
21
+ testSync("resends a fault on the endpoint's own side", () =>
22
+ [
23
+ "InternalFailure",
24
+ "ServiceUnavailable",
25
+ "Throttling",
26
+ "TooManyRequestsException",
27
+ ]->Array.forEach(t => expect(Seed_Client.isTransient(errors([errorOfType(t)])))->toBe(true))
28
+ )
29
+
30
+ // The exact payload the seed aborted on.
31
+ testSync("resends the shape AppSync actually returned", () => {
32
+ let payload = JSON.parseOrThrow(`[{"errorType":"InternalFailure","message":"An internal failure occurred."}]`)
33
+ expect(Seed_Client.isTransient(payload))->toBe(true)
34
+ })
35
+
36
+ testSync("fails fast on an error that is an answer", () =>
37
+ ["ValidationException", "Unauthorized", "FieldUndefined"]->Array.forEach(
38
+ t => expect(Seed_Client.isTransient(errors([errorOfType(t)])))->toBe(false),
39
+ )
40
+ )
41
+
42
+ // A real error sitting alongside a transient one still means the request was
43
+ // wrong, and resending it cannot make it right.
44
+ testSync("refuses to resend a batch that mixes a real error in", () =>
45
+ expect(
46
+ Seed_Client.isTransient(
47
+ errors([errorOfType("InternalFailure"), errorOfType("ValidationException")]),
48
+ ),
49
+ )->toBe(false)
50
+ )
51
+
52
+ // `Array.every` is vacuously true on an empty array, which would turn "the
53
+ // endpoint said errors but named none" into an endless resend.
54
+ testSync("treats an empty or untyped error list as final", () => {
55
+ expect(Seed_Client.isTransient(errors([])))->toBe(false)
56
+ expect(
57
+ Seed_Client.isTransient(errors([Dict.fromArray([("message", JSON.Encode.string("boom"))])])),
58
+ )->toBe(false)
59
+ expect(Seed_Client.isTransient(JSON.Encode.null))->toBe(false)
60
+ })
61
+ })
62
+
63
+ // An account's listed groups are not what authorizes it — the token's are. Both
64
+ // platforms narrow a token to the single role the caller last chose, so a seed
65
+ // can log in as an account the user list shows as `[Admin, Shopper]` and present
66
+ // a token carrying `Shopper` alone. The refusal that follows names the field and
67
+ // not the caller, and the account list cannot explain it either. These pin the
68
+ // reading-back that turns that into a sentence.
69
+
70
+ let b64url = payload =>
71
+ payload
72
+ ->NodeBuffer.fromStringUtf8
73
+ ->NodeBuffer.toStringBase64Url
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
+
82
+ let jwt = claims =>
83
+ `${joseHeader}.${b64url(JSON.stringify(JSON.Encode.object(Dict.fromArray(claims))))}.signature`
84
+
85
+ let localToken = claims =>
86
+ `${b64url(JSON.stringify(JSON.Encode.object(Dict.fromArray(claims))))}.signature`
87
+
88
+ let strings = values => JSON.Encode.array(values->Array.map(JSON.Encode.string))
89
+
90
+ let clientWith = token => {
91
+ let c = Seed_Client.make(~config={endpoint: "http://example.invalid/graphql"})
92
+ c->Seed_Client.useToken(token)
93
+ c
94
+ }
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
+
123
+ describe("Seed_Client.effectiveGroups:", () => {
124
+ // A Cognito id token carries its payload in the second segment, a local dev
125
+ // token in the first. Neither platform should have to announce which it is.
126
+ testSync("reads the payload wherever the provider put it", () => {
127
+ expect(
128
+ clientWith(
129
+ jwt([("cognito:groups", strings(["Admin", "Shopper"]))]),
130
+ )->Seed_Client.effectiveGroups,
131
+ )->toEqual(Some(["Admin", "Shopper"]))
132
+ expect(
133
+ clientWith(localToken([("groups", strings(["Merchandiser"]))]))->Seed_Client.effectiveGroups,
134
+ )->toEqual(Some(["Merchandiser"]))
135
+ })
136
+
137
+ // An opaque bearer is not an error — it just means this can add nothing, and
138
+ // the failure reports what it always did.
139
+ testSync("says nothing about a token it cannot read", () => {
140
+ expect(clientWith("not-a-token")->Seed_Client.effectiveGroups)->toEqual(None)
141
+ expect(
142
+ Seed_Client.make(
143
+ ~config={endpoint: "http://example.invalid/graphql"},
144
+ )->Seed_Client.effectiveGroups,
145
+ )->toEqual(None)
146
+ })
147
+ })
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
+
200
+ describe("Seed_Client.identitySummary:", () => {
201
+ // The case that cost the debugging: eligible in the user list, refused by the
202
+ // token. Naming what it was narrowed FROM is what points at the role switch.
203
+ testSync("names the membership a narrowed token gave up", () =>
204
+ expect(
205
+ clientWith(
206
+ jwt([
207
+ ("cognito:groups", strings(["Shopper"])),
208
+ ("availableRoles", JSON.Encode.string("Admin,Shopper")),
209
+ ]),
210
+ )->Seed_Client.identitySummary,
211
+ )->toEqual(Some("Shopper — narrowed to this role from Admin, Shopper"))
212
+ )
213
+
214
+ // An ordinary login is not narrowed and has nothing to explain.
215
+ testSync("stays quiet about an unnarrowed token", () =>
216
+ expect(
217
+ clientWith(
218
+ jwt([("cognito:groups", strings(["Admin", "Shopper"]))]),
219
+ )->Seed_Client.identitySummary,
220
+ )->toEqual(Some("Admin, Shopper"))
221
+ )
222
+
223
+ // A token granting nothing reads as a sentence rather than an empty string.
224
+ testSync("says so when the token carries no groups at all", () =>
225
+ expect(
226
+ clientWith(jwt([("cognito:groups", strings([]))]))->Seed_Client.identitySummary,
227
+ )->toEqual(Some("no groups"))
228
+ )
229
+ })
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
+
270
+ describe("Seed_Client.isDenied:", () => {
271
+ // AppSync types it; the local server carries it in the message.
272
+ testSync("recognises a refusal in either platform's vocabulary", () => {
273
+ expect(Seed_Client.isDenied(errors([errorOfType("Unauthorized")])))->toBe(true)
274
+ expect(
275
+ Seed_Client.isDenied(
276
+ errors([Dict.fromArray([("message", JSON.Encode.string("Not Authorized to access X"))])]),
277
+ ),
278
+ )->toBe(true)
279
+ })
280
+
281
+ // Attaching an identity to an unrelated failure would point the reader at the
282
+ // wrong thing.
283
+ testSync("leaves every other failure alone", () => {
284
+ expect(Seed_Client.isDenied(errors([errorOfType("ValidationException")])))->toBe(false)
285
+ expect(Seed_Client.isDenied(errors([])))->toBe(false)
286
+ })
287
+ })
@@ -0,0 +1,268 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as Seed_Client$ReventlessSeed from "../src/Seed_Client.res.mjs";
6
+
7
+ function errors(entries) {
8
+ return entries.map(prim => prim);
9
+ }
10
+
11
+ function errorOfType(errorType) {
12
+ return Object.fromEntries([[
13
+ "errorType",
14
+ errorType
15
+ ]]);
16
+ }
17
+
18
+ globalThis.describe("Seed_Client.isTransient:", () => {
19
+ globalThis.test("resends a fault on the endpoint's own side", () => {
20
+ [
21
+ "InternalFailure",
22
+ "ServiceUnavailable",
23
+ "Throttling",
24
+ "TooManyRequestsException"
25
+ ].forEach(t => {
26
+ let entries = [errorOfType(t)];
27
+ globalThis.expect(Seed_Client$ReventlessSeed.isTransient(entries.map(prim => prim))).toBe(true);
28
+ });
29
+ });
30
+ globalThis.test("resends the shape AppSync actually returned", () => {
31
+ let payload = JSON.parse(`[{"errorType":"InternalFailure","message":"An internal failure occurred."}]`);
32
+ globalThis.expect(Seed_Client$ReventlessSeed.isTransient(payload)).toBe(true);
33
+ });
34
+ globalThis.test("fails fast on an error that is an answer", () => {
35
+ [
36
+ "ValidationException",
37
+ "Unauthorized",
38
+ "FieldUndefined"
39
+ ].forEach(t => {
40
+ let entries = [errorOfType(t)];
41
+ globalThis.expect(Seed_Client$ReventlessSeed.isTransient(entries.map(prim => prim))).toBe(false);
42
+ });
43
+ });
44
+ globalThis.test("refuses to resend a batch that mixes a real error in", () => {
45
+ let entries = [
46
+ errorOfType("InternalFailure"),
47
+ errorOfType("ValidationException")
48
+ ];
49
+ globalThis.expect(Seed_Client$ReventlessSeed.isTransient(entries.map(prim => prim))).toBe(false);
50
+ });
51
+ globalThis.test("treats an empty or untyped error list as final", () => {
52
+ globalThis.expect(Seed_Client$ReventlessSeed.isTransient([].map(prim => prim))).toBe(false);
53
+ let entries = [Object.fromEntries([[
54
+ "message",
55
+ "boom"
56
+ ]])];
57
+ globalThis.expect(Seed_Client$ReventlessSeed.isTransient(entries.map(prim => prim))).toBe(false);
58
+ globalThis.expect(Seed_Client$ReventlessSeed.isTransient(null)).toBe(false);
59
+ });
60
+ });
61
+
62
+ function b64url(payload) {
63
+ return Buffer.from(payload, "utf8").toString("base64url");
64
+ }
65
+
66
+ let joseHeader = Buffer.from(`{"kid":"abc123","alg":"RS256"}`, "utf8").toString("base64url");
67
+
68
+ function jwt(claims) {
69
+ let payload = JSON.stringify(Object.fromEntries(claims));
70
+ return joseHeader + `.` + Buffer.from(payload, "utf8").toString("base64url") + `.signature`;
71
+ }
72
+
73
+ function localToken(claims) {
74
+ let payload = JSON.stringify(Object.fromEntries(claims));
75
+ return Buffer.from(payload, "utf8").toString("base64url") + `.signature`;
76
+ }
77
+
78
+ function strings(values) {
79
+ return values.map(prim => prim);
80
+ }
81
+
82
+ function clientWith(token) {
83
+ let c = Seed_Client$ReventlessSeed.make({
84
+ endpoint: "http://example.invalid/graphql"
85
+ });
86
+ Seed_Client$ReventlessSeed.useToken(c, token);
87
+ return c;
88
+ }
89
+
90
+ globalThis.describe("Seed_Client.claims:", () => {
91
+ globalThis.test("skips the JOSE header and reads the payload behind it", () => {
92
+ globalThis.expect(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Seed_Client$ReventlessSeed.claims(clientWith(jwt([[
93
+ "sub",
94
+ "real-sub"
95
+ ]]))), c => c["sub"]), Stdlib_JSON.Decode.string)).toEqual("real-sub");
96
+ });
97
+ globalThis.test("takes the first segment when there is no header", () => {
98
+ globalThis.expect(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Seed_Client$ReventlessSeed.claims(clientWith(localToken([[
99
+ "userId",
100
+ "local-shopper"
101
+ ]]))), c => c["userId"]), Stdlib_JSON.Decode.string)).toEqual("local-shopper");
102
+ });
103
+ });
104
+
105
+ globalThis.describe("Seed_Client.effectiveGroups:", () => {
106
+ globalThis.test("reads the payload wherever the provider put it", () => {
107
+ globalThis.expect(Seed_Client$ReventlessSeed.effectiveGroups(clientWith(jwt([[
108
+ "cognito:groups",
109
+ [
110
+ "Admin",
111
+ "Shopper"
112
+ ].map(prim => prim)
113
+ ]])))).toEqual([
114
+ "Admin",
115
+ "Shopper"
116
+ ]);
117
+ globalThis.expect(Seed_Client$ReventlessSeed.effectiveGroups(clientWith(localToken([[
118
+ "groups",
119
+ ["Merchandiser"].map(prim => prim)
120
+ ]])))).toEqual(["Merchandiser"]);
121
+ });
122
+ globalThis.test("says nothing about a token it cannot read", () => {
123
+ globalThis.expect(Seed_Client$ReventlessSeed.effectiveGroups(clientWith("not-a-token"))).toEqual(undefined);
124
+ globalThis.expect(Seed_Client$ReventlessSeed.effectiveGroups(Seed_Client$ReventlessSeed.make({
125
+ endpoint: "http://example.invalid/graphql"
126
+ }))).toEqual(undefined);
127
+ });
128
+ });
129
+
130
+ globalThis.describe("Seed_Client.callerId:", () => {
131
+ globalThis.test("reads the id under whichever name the provider gave it", () => {
132
+ globalThis.expect(Seed_Client$ReventlessSeed.callerId(clientWith(jwt([[
133
+ "sub",
134
+ "4275e4a4-00c1-70e1"
135
+ ]])))).toEqual("4275e4a4-00c1-70e1");
136
+ globalThis.expect(Seed_Client$ReventlessSeed.callerId(clientWith(localToken([[
137
+ "userId",
138
+ "local-shopper"
139
+ ]])))).toEqual("local-shopper");
140
+ });
141
+ globalThis.test("prefers sub when a token carries both names", () => {
142
+ globalThis.expect(Seed_Client$ReventlessSeed.callerId(clientWith(jwt([
143
+ [
144
+ "sub",
145
+ "f215e4b4-30f1-700c"
146
+ ],
147
+ [
148
+ "userId",
149
+ "local-shopper"
150
+ ]
151
+ ])))).toEqual("f215e4b4-30f1-700c");
152
+ });
153
+ globalThis.test("says nothing about a token it cannot read, or one naming no id", () => {
154
+ globalThis.expect(Seed_Client$ReventlessSeed.callerId(clientWith("not-a-token"))).toEqual(undefined);
155
+ globalThis.expect(Seed_Client$ReventlessSeed.callerId(clientWith(jwt([[
156
+ "cognito:groups",
157
+ ["Admin"].map(prim => prim)
158
+ ]])))).toEqual(undefined);
159
+ globalThis.expect(Seed_Client$ReventlessSeed.callerId(Seed_Client$ReventlessSeed.make({
160
+ endpoint: "http://example.invalid/graphql"
161
+ }))).toEqual(undefined);
162
+ });
163
+ globalThis.test("refuses a claim that is not a single string", () => {
164
+ globalThis.expect(Seed_Client$ReventlessSeed.callerId(clientWith(jwt([[
165
+ "sub",
166
+ [
167
+ "a",
168
+ "b"
169
+ ].map(prim => prim)
170
+ ]])))).toEqual(undefined);
171
+ });
172
+ });
173
+
174
+ globalThis.describe("Seed_Client.identitySummary:", () => {
175
+ globalThis.test("names the membership a narrowed token gave up", () => {
176
+ globalThis.expect(Seed_Client$ReventlessSeed.identitySummary(clientWith(jwt([
177
+ [
178
+ "cognito:groups",
179
+ ["Shopper"].map(prim => prim)
180
+ ],
181
+ [
182
+ "availableRoles",
183
+ "Admin,Shopper"
184
+ ]
185
+ ])))).toEqual("Shopper — narrowed to this role from Admin, Shopper");
186
+ });
187
+ globalThis.test("stays quiet about an unnarrowed token", () => {
188
+ globalThis.expect(Seed_Client$ReventlessSeed.identitySummary(clientWith(jwt([[
189
+ "cognito:groups",
190
+ [
191
+ "Admin",
192
+ "Shopper"
193
+ ].map(prim => prim)
194
+ ]])))).toEqual("Admin, Shopper");
195
+ });
196
+ globalThis.test("says so when the token carries no groups at all", () => {
197
+ globalThis.expect(Seed_Client$ReventlessSeed.identitySummary(clientWith(jwt([[
198
+ "cognito:groups",
199
+ [].map(prim => prim)
200
+ ]])))).toEqual("no groups");
201
+ });
202
+ });
203
+
204
+ globalThis.describe("Seed_Client.narrowedFrom:", () => {
205
+ globalThis.test("names the membership a narrowed token was reduced from", () => {
206
+ globalThis.expect(Seed_Client$ReventlessSeed.narrowedFrom(clientWith(jwt([
207
+ [
208
+ "cognito:groups",
209
+ ["Shopper"].map(prim => prim)
210
+ ],
211
+ [
212
+ "availableRoles",
213
+ "Admin,Shopper"
214
+ ]
215
+ ])))).toEqual([
216
+ "Admin",
217
+ "Shopper"
218
+ ]);
219
+ });
220
+ globalThis.test("says nothing about an unnarrowed token", () => {
221
+ globalThis.expect(Seed_Client$ReventlessSeed.narrowedFrom(clientWith(jwt([[
222
+ "cognito:groups",
223
+ ["Shopper"].map(prim => prim)
224
+ ]])))).toEqual(undefined);
225
+ globalThis.expect(Seed_Client$ReventlessSeed.narrowedFrom(clientWith(jwt([[
226
+ "cognito:groups",
227
+ [
228
+ "Admin",
229
+ "Shopper"
230
+ ].map(prim => prim)
231
+ ]])))).toEqual(undefined);
232
+ });
233
+ globalThis.test("treats an empty availableRoles as not narrowed", () => {
234
+ globalThis.expect(Seed_Client$ReventlessSeed.narrowedFrom(clientWith(jwt([[
235
+ "availableRoles",
236
+ ""
237
+ ]])))).toEqual(undefined);
238
+ });
239
+ });
240
+
241
+ globalThis.describe("Seed_Client.isDenied:", () => {
242
+ globalThis.test("recognises a refusal in either platform's vocabulary", () => {
243
+ let entries = [errorOfType("Unauthorized")];
244
+ globalThis.expect(Seed_Client$ReventlessSeed.isDenied(entries.map(prim => prim))).toBe(true);
245
+ let entries$1 = [Object.fromEntries([[
246
+ "message",
247
+ "Not Authorized to access X"
248
+ ]])];
249
+ globalThis.expect(Seed_Client$ReventlessSeed.isDenied(entries$1.map(prim => prim))).toBe(true);
250
+ });
251
+ globalThis.test("leaves every other failure alone", () => {
252
+ let entries = [errorOfType("ValidationException")];
253
+ globalThis.expect(Seed_Client$ReventlessSeed.isDenied(entries.map(prim => prim))).toBe(false);
254
+ globalThis.expect(Seed_Client$ReventlessSeed.isDenied([].map(prim => prim))).toBe(false);
255
+ });
256
+ });
257
+
258
+ export {
259
+ errors,
260
+ errorOfType,
261
+ b64url,
262
+ joseHeader,
263
+ jwt,
264
+ localToken,
265
+ strings,
266
+ clientWith,
267
+ }
268
+ /* Not a pure module */
@@ -0,0 +1,64 @@
1
+ open JestGlobals
2
+
3
+ // `SEED_ROLE` is how a run says what to act as when nobody can answer a prompt —
4
+ // a CI run wedged by a stored narrowing, or a deliberate restricted run.
5
+ //
6
+ // It carries two different intentions in one variable, which is why the parsing
7
+ // is worth pinning: naming a role narrows to it, and the "give me everything"
8
+ // answer has several spellings because an operator setting it by hand will write
9
+ // whichever one comes to mind. Getting that wrong is not a typo — `SEED_ROLE=full`
10
+ // read as a role named "full" would ask the platform to act as a group nobody
11
+ // holds, and be refused.
12
+
13
+ let withEnv = (value: option<string>, f: unit => unit) => {
14
+ let key = "SEED_ROLE"
15
+ let previous = NodeProcess.env->Dict.get(key)
16
+ switch value {
17
+ | Some(v) => NodeProcess.env->Dict.set(key, v)
18
+ | None => NodeProcess.env->Dict.delete(key)
19
+ }
20
+ f()
21
+ switch previous {
22
+ | Some(v) => NodeProcess.env->Dict.set(key, v)
23
+ | None => NodeProcess.env->Dict.delete(key)
24
+ }
25
+ }
26
+
27
+ describe("Seed_Connect.roleFromEnv:", () => {
28
+ testSync("is absent when nothing asked for a role", () =>
29
+ withEnv(None, () => expect(Seed_Connect.roleFromEnv())->Expect.toEqual(None))
30
+ )
31
+
32
+ testSync("reads a named role as a narrowing to it", () =>
33
+ withEnv(
34
+ Some("Shopper"),
35
+ () =>
36
+ expect(Seed_Connect.roleFromEnv())->Expect.toEqual(Some(Seed_Connect.Narrowed("Shopper"))),
37
+ )
38
+ )
39
+
40
+ // Every spelling of "everything" an operator might reach for. They all mean
41
+ // clear the stored choice, which is what seeding wants.
42
+ testSync("takes each spelling of full membership as a clear", () =>
43
+ ["full", "all", "none", "clear", "FULL", "All"]->Array.forEach(
44
+ v =>
45
+ withEnv(
46
+ Some(v),
47
+ () => expect(Seed_Connect.roleFromEnv())->Expect.toEqual(Some(Seed_Connect.Full)),
48
+ ),
49
+ )
50
+ )
51
+
52
+ // Role names are case-sensitive to the platform (`mayActAs` compares exactly),
53
+ // so a named role is passed through untouched — only the clear vocabulary is
54
+ // case-folded.
55
+ testSync("passes a role name through with its case intact", () =>
56
+ withEnv(
57
+ Some("Merchandiser"),
58
+ () =>
59
+ expect(Seed_Connect.roleFromEnv())->Expect.toEqual(
60
+ Some(Seed_Connect.Narrowed("Merchandiser")),
61
+ ),
62
+ )
63
+ )
64
+ })
@@ -0,0 +1,56 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
4
+ import * as Seed_Connect$ReventlessSeed from "../src/Seed_Connect.res.mjs";
5
+
6
+ function withEnv(value, f) {
7
+ let key = "SEED_ROLE";
8
+ let previous = process.env[key];
9
+ if (value !== undefined) {
10
+ process.env[key] = value;
11
+ } else {
12
+ Stdlib_Dict.$$delete(process.env, key);
13
+ }
14
+ f();
15
+ if (previous !== undefined) {
16
+ process.env[key] = previous;
17
+ return;
18
+ } else {
19
+ return Stdlib_Dict.$$delete(process.env, key);
20
+ }
21
+ }
22
+
23
+ globalThis.describe("Seed_Connect.roleFromEnv:", () => {
24
+ globalThis.test("is absent when nothing asked for a role", () => withEnv(undefined, () => {
25
+ globalThis.expect(Seed_Connect$ReventlessSeed.roleFromEnv()).toEqual(undefined);
26
+ }));
27
+ globalThis.test("reads a named role as a narrowing to it", () => withEnv("Shopper", () => {
28
+ globalThis.expect(Seed_Connect$ReventlessSeed.roleFromEnv()).toEqual({
29
+ TAG: "Narrowed",
30
+ _0: "Shopper"
31
+ });
32
+ }));
33
+ globalThis.test("takes each spelling of full membership as a clear", () => {
34
+ [
35
+ "full",
36
+ "all",
37
+ "none",
38
+ "clear",
39
+ "FULL",
40
+ "All"
41
+ ].forEach(v => withEnv(v, () => {
42
+ globalThis.expect(Seed_Connect$ReventlessSeed.roleFromEnv()).toEqual("Full");
43
+ }));
44
+ });
45
+ globalThis.test("passes a role name through with its case intact", () => withEnv("Merchandiser", () => {
46
+ globalThis.expect(Seed_Connect$ReventlessSeed.roleFromEnv()).toEqual({
47
+ TAG: "Narrowed",
48
+ _0: "Merchandiser"
49
+ });
50
+ }));
51
+ });
52
+
53
+ export {
54
+ withEnv,
55
+ }
56
+ /* Not a pure module */