@reventlessdev/reventless-aws 3.0.0-alpha.395 → 3.0.0-alpha.397
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 +15 -0
- package/package.json +7 -7
- package/scripts/DeployApp.res +7 -1
- package/scripts/DeployApp.res.mjs +1 -1
- package/scripts/ProvisionAccounts.res +89 -4
- package/scripts/ProvisionAccounts.res.mjs +101 -5
- package/scripts/ProvisionIdentity.res +42 -18
- package/scripts/ProvisionIdentity.res.mjs +40 -26
- package/src/Platform.res +47 -10
- package/src/Platform.res.mjs +44 -10
- package/src/Platform_Stack.res +17 -0
- package/src/Platform_Stack.res.mjs +7 -1
- package/src/adapter/Auth/Auth_ActiveRoleTrigger.res +25 -9
- package/src/adapter/Auth/Auth_ActiveRoleTrigger.res.mjs +17 -4
- package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res +59 -9
- package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res.mjs +53 -6
- package/src/adapter/Auth/Auth_Cognito.res +13 -2
- package/src/adapter/Auth/Auth_Cognito.res.mjs +9 -2
- package/src/adapter/Auth/Auth_IdentityStore.res +91 -0
- package/src/adapter/Auth/Auth_IdentityStore.res.mjs +85 -0
- package/src/adapter/Auth/Auth_IdentityStore_Schema.res +39 -0
- package/src/adapter/Auth/Auth_IdentityStore_Schema.res.mjs +35 -0
- package/src/adapter/Identity/IdentityProvider_Cognito_Backend.res +251 -0
- package/src/adapter/Identity/IdentityProvider_Cognito_Backend.res.mjs +300 -0
- package/src/adapter/Runtime/AutomationSliceEntryPoint_Ops.res +7 -4
- package/src/adapter/Runtime/AutomationSliceEntryPoint_Ops.res.mjs +2 -2
- package/src/adapter/Runtime/AutomationSliceRuntime_Builder_Single.res +80 -0
- package/src/adapter/Runtime/AutomationSliceRuntime_Builder_Single.res.mjs +53 -2
- package/src/components/AutomationSlice_Builder.res.mjs +1 -1
- package/src/components/OutboundTranslationSlice_Builder.res +2 -0
- package/src/components/OutboundTranslationSlice_Builder.res.mjs +1 -1
- package/src/plugin/runtime/PluginRuntime_Builder.res +23 -0
- package/src/plugin/runtime/PluginRuntime_Builder.res.mjs +29 -0
- package/tests/Auth_ActiveRoleTrigger_OpsTest.res +40 -0
- package/tests/Auth_ActiveRoleTrigger_OpsTest.res.mjs +59 -8
- package/tests/Auth_CognitoTest.res +62 -0
- package/tests/Auth_CognitoTest.res.mjs +98 -0
- package/tests/Auth_IdentityStore_SchemaTest.res +35 -0
- package/tests/Auth_IdentityStore_SchemaTest.res.mjs +43 -0
- package/tests/IdentityProvider_Cognito_BackendTest.res +314 -0
- package/tests/IdentityProvider_Cognito_BackendTest.res.mjs +442 -0
- package/tests/ProvisionAccountsTest.res.mjs +1 -1
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
open JestGlobals
|
|
2
|
+
|
|
3
|
+
// The Cognito identity provider's sequencing, against fake pool calls: what is
|
|
4
|
+
// undone when a later step fails, which failures are verdicts, and that a new
|
|
5
|
+
// principal's domain id is minted into the store. The SDK calls themselves are
|
|
6
|
+
// one line each and are exercised by a deployed stack.
|
|
7
|
+
|
|
8
|
+
module Backend = IdentityProvider_Cognito_Backend
|
|
9
|
+
module IP = Reventless.IdentityProvider
|
|
10
|
+
|
|
11
|
+
@new external makeAwsError: string => exn = "Error"
|
|
12
|
+
@set external setName: (exn, string) => unit = "name"
|
|
13
|
+
|
|
14
|
+
let awsError = (code: string): exn => {
|
|
15
|
+
let e = makeAwsError(`${code}: from the fake pool`)
|
|
16
|
+
e->setName(code)
|
|
17
|
+
e
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type pool = {
|
|
21
|
+
users: dict<(string, array<string>)>,
|
|
22
|
+
rows: dict<string>,
|
|
23
|
+
calls: array<string>,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let fakeOps = (~pool: pool, ~failOn: option<(string, string)>=?): Backend.ops => {
|
|
27
|
+
let fail = step =>
|
|
28
|
+
switch failOn {
|
|
29
|
+
| Some((at, code)) if at == step => throw(awsError(code))
|
|
30
|
+
| _ => ()
|
|
31
|
+
}
|
|
32
|
+
let record = step => {
|
|
33
|
+
let _ = pool.calls->Array.push(step)
|
|
34
|
+
fail(step)
|
|
35
|
+
}
|
|
36
|
+
{
|
|
37
|
+
createUser: async (~username, ~attributes as _) => {
|
|
38
|
+
record("createUser")
|
|
39
|
+
if pool.users->Dict.has(username) {
|
|
40
|
+
throw(awsError("UsernameExistsException"))
|
|
41
|
+
}
|
|
42
|
+
let sub = `sub-${username}`
|
|
43
|
+
pool.users->Dict.set(username, (sub, []))
|
|
44
|
+
Some(sub)
|
|
45
|
+
},
|
|
46
|
+
subOf: async (~username) => {
|
|
47
|
+
record("subOf")
|
|
48
|
+
switch pool.users->Dict.get(username) {
|
|
49
|
+
| Some((sub, _)) => Some(sub)
|
|
50
|
+
| None => throw(awsError("UserNotFoundException"))
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
setPassword: async (~username as _, ~password as _) => record("setPassword"),
|
|
54
|
+
addToGroup: async (~username, ~group) => {
|
|
55
|
+
record("addToGroup")
|
|
56
|
+
switch pool.users->Dict.get(username) {
|
|
57
|
+
| Some((sub, groups)) => pool.users->Dict.set(username, (sub, Array.concat(groups, [group])))
|
|
58
|
+
| None => throw(awsError("UserNotFoundException"))
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
removeFromGroup: async (~username, ~group) => {
|
|
62
|
+
record("removeFromGroup")
|
|
63
|
+
switch pool.users->Dict.get(username) {
|
|
64
|
+
| Some((sub, groups)) =>
|
|
65
|
+
pool.users->Dict.set(username, (sub, groups->Array.filter(g => g != group)))
|
|
66
|
+
| None => throw(awsError("UserNotFoundException"))
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
deleteUser: async (~username) => {
|
|
70
|
+
record("deleteUser")
|
|
71
|
+
if !(pool.users->Dict.has(username)) {
|
|
72
|
+
throw(awsError("UserNotFoundException"))
|
|
73
|
+
}
|
|
74
|
+
pool.users->Dict.delete(username)
|
|
75
|
+
},
|
|
76
|
+
putUserId: async (~sub, ~userId) => {
|
|
77
|
+
record("putUserId")
|
|
78
|
+
pool.rows->Dict.set(sub, userId)
|
|
79
|
+
},
|
|
80
|
+
deleteUserId: async (~sub) => {
|
|
81
|
+
record("deleteUserId")
|
|
82
|
+
pool.rows->Dict.delete(sub)
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let emptyPool = () => {users: Dict.make(), rows: Dict.make(), calls: []}
|
|
88
|
+
let email = address => Reventless.Messaging.ToEmail(Reventless.Email.unsafe(address))
|
|
89
|
+
|
|
90
|
+
describe("IdentityProvider_Cognito_Backend", () => {
|
|
91
|
+
testPromise("creates the principal, sets its password and groups, and mints its id", async () => {
|
|
92
|
+
let pool = emptyPool()
|
|
93
|
+
let provider = Backend.withOps(fakeOps(~pool), ~hasStore=true)
|
|
94
|
+
let answer = await provider.createPrincipal(
|
|
95
|
+
~contact=email("new@example.com"),
|
|
96
|
+
~credential=Password("pw"),
|
|
97
|
+
~groups=["Shopper"],
|
|
98
|
+
)
|
|
99
|
+
expect(answer)->toEqual(Ok({IP.providerId: "new@example.com"}))
|
|
100
|
+
expect(pool.users->Dict.get("new@example.com"))->toEqual(
|
|
101
|
+
Some(("sub-new@example.com", ["Shopper"])),
|
|
102
|
+
)
|
|
103
|
+
expect(
|
|
104
|
+
pool.rows->Dict.get("sub-new@example.com")->Option.map(ReventlessCore.Auth_UserId.isMinted),
|
|
105
|
+
)->toEqual(Some(true))
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
testPromise("an existing account is a Conflict, not an outage", async () => {
|
|
109
|
+
let pool = emptyPool()
|
|
110
|
+
let provider = Backend.withOps(fakeOps(~pool), ~hasStore=true)
|
|
111
|
+
let create = () =>
|
|
112
|
+
provider.createPrincipal(
|
|
113
|
+
~contact=email("twice@example.com"),
|
|
114
|
+
~credential=NoCredential,
|
|
115
|
+
~groups=[],
|
|
116
|
+
)
|
|
117
|
+
let _ = await create()
|
|
118
|
+
expect(await create())->toEqual(Error(IP.Conflict))
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// The case the undo exists for: left in place, the retry would meet the
|
|
122
|
+
// account and answer Conflict to someone whose first attempt timed out.
|
|
123
|
+
testPromise("a later step failing undoes the account, so a retry starts clean", async () => {
|
|
124
|
+
let pool = emptyPool()
|
|
125
|
+
let failing = Backend.withOps(
|
|
126
|
+
fakeOps(~pool, ~failOn=("addToGroup", "TooManyRequestsException")),
|
|
127
|
+
~hasStore=true,
|
|
128
|
+
)
|
|
129
|
+
let first = await failing.createPrincipal(
|
|
130
|
+
~contact=email("retry@example.com"),
|
|
131
|
+
~credential=Password("pw"),
|
|
132
|
+
~groups=["Shopper"],
|
|
133
|
+
)
|
|
134
|
+
expect(
|
|
135
|
+
switch first {
|
|
136
|
+
| Error(Unavailable(_)) => true
|
|
137
|
+
| _ => false
|
|
138
|
+
},
|
|
139
|
+
)->toBe(true)
|
|
140
|
+
expect(pool.users->Dict.has("retry@example.com"))->toBe(false)
|
|
141
|
+
expect(pool.rows->Dict.keysToArray)->toEqual([])
|
|
142
|
+
|
|
143
|
+
let healthy = Backend.withOps(fakeOps(~pool), ~hasStore=true)
|
|
144
|
+
let second = await healthy.createPrincipal(
|
|
145
|
+
~contact=email("retry@example.com"),
|
|
146
|
+
~credential=Password("pw"),
|
|
147
|
+
~groups=["Shopper"],
|
|
148
|
+
)
|
|
149
|
+
expect(second)->toEqual(Ok({IP.providerId: "retry@example.com"}))
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
testPromise("a password the pool's policy rejects is Refused, and undone", async () => {
|
|
153
|
+
let pool = emptyPool()
|
|
154
|
+
let provider = Backend.withOps(
|
|
155
|
+
fakeOps(~pool, ~failOn=("setPassword", "InvalidPasswordException")),
|
|
156
|
+
~hasStore=true,
|
|
157
|
+
)
|
|
158
|
+
let answer = await provider.createPrincipal(
|
|
159
|
+
~contact=email("weak@example.com"),
|
|
160
|
+
~credential=Password("1"),
|
|
161
|
+
~groups=[],
|
|
162
|
+
)
|
|
163
|
+
expect(
|
|
164
|
+
switch answer {
|
|
165
|
+
| Error(Refused(_)) => true
|
|
166
|
+
| _ => false
|
|
167
|
+
},
|
|
168
|
+
)->toBe(true)
|
|
169
|
+
expect(pool.users->Dict.has("weak@example.com"))->toBe(false)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
testPromise("NoCredential sets no password", async () => {
|
|
173
|
+
let pool = emptyPool()
|
|
174
|
+
let provider = Backend.withOps(fakeOps(~pool), ~hasStore=true)
|
|
175
|
+
let _ = await provider.createPrincipal(
|
|
176
|
+
~contact=email("later@example.com"),
|
|
177
|
+
~credential=NoCredential,
|
|
178
|
+
~groups=[],
|
|
179
|
+
)
|
|
180
|
+
expect(pool.calls->Array.includes("setPassword"))->toBe(false)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// A pool provisioned before the identity store: principals are still made,
|
|
184
|
+
// and each keeps its `sub` as its id, as every account before the store did.
|
|
185
|
+
testPromise("without a store, no id is minted", async () => {
|
|
186
|
+
let pool = emptyPool()
|
|
187
|
+
let provider = Backend.withOps(fakeOps(~pool), ~hasStore=false)
|
|
188
|
+
let _ = await provider.createPrincipal(
|
|
189
|
+
~contact=email("old@example.com"),
|
|
190
|
+
~credential=NoCredential,
|
|
191
|
+
~groups=[],
|
|
192
|
+
)
|
|
193
|
+
expect(pool.calls->Array.includes("putUserId"))->toBe(false)
|
|
194
|
+
expect(pool.users->Dict.has("old@example.com"))->toBe(true)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
testPromise("a push address is refused before the pool is asked", async () => {
|
|
198
|
+
let pool = emptyPool()
|
|
199
|
+
let provider = Backend.withOps(fakeOps(~pool), ~hasStore=true)
|
|
200
|
+
let answer = await provider.createPrincipal(
|
|
201
|
+
~contact=ToPush(Fcm({registrationToken: "tok"})),
|
|
202
|
+
~credential=NoCredential,
|
|
203
|
+
~groups=[],
|
|
204
|
+
)
|
|
205
|
+
expect(
|
|
206
|
+
switch answer {
|
|
207
|
+
| Error(Refused(_)) => true
|
|
208
|
+
| _ => false
|
|
209
|
+
},
|
|
210
|
+
)->toBe(true)
|
|
211
|
+
expect(pool.calls)->toEqual([])
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
testPromise("groups change on the pool, and an unknown principal is Refused", async () => {
|
|
215
|
+
let pool = emptyPool()
|
|
216
|
+
let provider = Backend.withOps(fakeOps(~pool), ~hasStore=true)
|
|
217
|
+
let principal: IP.principal = {providerId: "grp@example.com"}
|
|
218
|
+
let _ = await provider.createPrincipal(
|
|
219
|
+
~contact=email("grp@example.com"),
|
|
220
|
+
~credential=NoCredential,
|
|
221
|
+
~groups=["Shopper"],
|
|
222
|
+
)
|
|
223
|
+
expect(await provider.addToGroup(~principal, ~group="Reviewer"))->toEqual(Ok())
|
|
224
|
+
expect(await provider.removeFromGroup(~principal, ~group="Shopper"))->toEqual(Ok())
|
|
225
|
+
expect(pool.users->Dict.get("grp@example.com"))->toEqual(
|
|
226
|
+
Some(("sub-grp@example.com", ["Reviewer"])),
|
|
227
|
+
)
|
|
228
|
+
expect(
|
|
229
|
+
switch await provider.addToGroup(~principal={providerId: "ghost"}, ~group="Shopper") {
|
|
230
|
+
| Error(Refused(_)) => true
|
|
231
|
+
| _ => false
|
|
232
|
+
},
|
|
233
|
+
)->toBe(true)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
testPromise("deleting removes the account and its row, and a second delete is Ok", async () => {
|
|
237
|
+
let pool = emptyPool()
|
|
238
|
+
let provider = Backend.withOps(fakeOps(~pool), ~hasStore=true)
|
|
239
|
+
let principal: IP.principal = {providerId: "gone@example.com"}
|
|
240
|
+
let _ = await provider.createPrincipal(
|
|
241
|
+
~contact=email("gone@example.com"),
|
|
242
|
+
~credential=NoCredential,
|
|
243
|
+
~groups=[],
|
|
244
|
+
)
|
|
245
|
+
expect(await provider.deletePrincipal(~principal))->toEqual(Ok())
|
|
246
|
+
expect(pool.users->Dict.has("gone@example.com"))->toBe(false)
|
|
247
|
+
expect(pool.rows->Dict.has("sub-gone@example.com"))->toBe(false)
|
|
248
|
+
expect(await provider.deletePrincipal(~principal))->toEqual(Ok())
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
testPromise("an outage while deleting is retried, not reported as done", async () => {
|
|
252
|
+
let pool = emptyPool()
|
|
253
|
+
let _ = await Backend.withOps(fakeOps(~pool), ~hasStore=true).createPrincipal(
|
|
254
|
+
~contact=email("busy@example.com"),
|
|
255
|
+
~credential=NoCredential,
|
|
256
|
+
~groups=[],
|
|
257
|
+
)
|
|
258
|
+
let failing = Backend.withOps(
|
|
259
|
+
fakeOps(~pool, ~failOn=("deleteUser", "TooManyRequestsException")),
|
|
260
|
+
~hasStore=true,
|
|
261
|
+
)
|
|
262
|
+
expect(
|
|
263
|
+
switch await failing.deletePrincipal(~principal={providerId: "busy@example.com"}) {
|
|
264
|
+
| Error(Unavailable(_)) => true
|
|
265
|
+
| _ => false
|
|
266
|
+
},
|
|
267
|
+
)->toBe(true)
|
|
268
|
+
expect(pool.users->Dict.has("busy@example.com"))->toBe(true)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
testSync("no pool configured answers Unavailable with no operations", () =>
|
|
272
|
+
expect(Backend.provider(~poolId="", ~identityStore="").operations)->toEqual([])
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
testSync("a configured pool publishes all four operations", () =>
|
|
276
|
+
expect(Backend.provider(~poolId="eu-west-1_X", ~identityStore="t").operations)->toEqual([
|
|
277
|
+
IP.CreatePrincipal,
|
|
278
|
+
AddToGroup,
|
|
279
|
+
RemoveFromGroup,
|
|
280
|
+
DeletePrincipal,
|
|
281
|
+
])
|
|
282
|
+
)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
describe("AutomationSliceRuntime_Builder_Single.declaresIdentityProvider", () => {
|
|
286
|
+
let info = (needs): AutomationSliceRuntime_Builder_Single.sliceInfo => {
|
|
287
|
+
specModulePath: "",
|
|
288
|
+
bodyModulePath: "",
|
|
289
|
+
callbackType: "outbound",
|
|
290
|
+
queryDbTableName: Pulumi.Output.make(""),
|
|
291
|
+
queryDbResources: [],
|
|
292
|
+
context: None,
|
|
293
|
+
sourceTopics: Dict.make(),
|
|
294
|
+
consumesDcbLog: true,
|
|
295
|
+
commandTargetName: None,
|
|
296
|
+
schedule: None,
|
|
297
|
+
schedulesResources: [],
|
|
298
|
+
capabilityNeeds: needs,
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// The grant that can create and delete accounts follows a declaration, not
|
|
302
|
+
// the pool merely existing, which on AWS it always does.
|
|
303
|
+
testSync("only a slice declaring the need earns the grant", () => {
|
|
304
|
+
expect(
|
|
305
|
+
AutomationSliceRuntime_Builder_Single.declaresIdentityProvider([info([Messaging]), info([])]),
|
|
306
|
+
)->toBe(false)
|
|
307
|
+
expect(
|
|
308
|
+
AutomationSliceRuntime_Builder_Single.declaresIdentityProvider([
|
|
309
|
+
info([Messaging]),
|
|
310
|
+
info([IdentityProvider]),
|
|
311
|
+
]),
|
|
312
|
+
)->toBe(true)
|
|
313
|
+
})
|
|
314
|
+
})
|