@solidxai/core 0.1.13-beta.20 → 0.1.13-beta.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 (57) hide show
  1. package/CHANGELOG.md +590 -0
  2. package/CLAUDE.md +5 -6
  3. package/dist/constants/media-file-types.d.ts.map +1 -1
  4. package/dist/constants/media-file-types.js +1 -0
  5. package/dist/constants/media-file-types.js.map +1 -1
  6. package/dist/dtos/create-field-metadata.dto.d.ts +1 -0
  7. package/dist/dtos/create-field-metadata.dto.d.ts.map +1 -1
  8. package/dist/dtos/create-field-metadata.dto.js +12 -1
  9. package/dist/dtos/create-field-metadata.dto.js.map +1 -1
  10. package/dist/entities/field-metadata.entity.d.ts +1 -0
  11. package/dist/entities/field-metadata.entity.d.ts.map +1 -1
  12. package/dist/entities/field-metadata.entity.js +5 -1
  13. package/dist/entities/field-metadata.entity.js.map +1 -1
  14. package/dist/helpers/field-crud-managers/MediaFieldCrudManager.d.ts +6 -0
  15. package/dist/helpers/field-crud-managers/MediaFieldCrudManager.d.ts.map +1 -1
  16. package/dist/helpers/field-crud-managers/MediaFieldCrudManager.js +54 -27
  17. package/dist/helpers/field-crud-managers/MediaFieldCrudManager.js.map +1 -1
  18. package/dist/services/chatter-message.service.d.ts +1 -0
  19. package/dist/services/chatter-message.service.d.ts.map +1 -1
  20. package/dist/services/chatter-message.service.js +44 -12
  21. package/dist/services/chatter-message.service.js.map +1 -1
  22. package/dist/services/crud.service.d.ts.map +1 -1
  23. package/dist/services/crud.service.js +1 -0
  24. package/dist/services/crud.service.js.map +1 -1
  25. package/dist/services/field-metadata.service.d.ts.map +1 -1
  26. package/dist/services/field-metadata.service.js +2 -0
  27. package/dist/services/field-metadata.service.js.map +1 -1
  28. package/dist/services/media.service.d.ts.map +1 -1
  29. package/dist/services/media.service.js +1 -0
  30. package/dist/services/media.service.js.map +1 -1
  31. package/dist/services/module-metadata.service.d.ts.map +1 -1
  32. package/dist/services/module-metadata.service.js +7 -0
  33. package/dist/services/module-metadata.service.js.map +1 -1
  34. package/dist/services/setting.service.d.ts.map +1 -1
  35. package/dist/services/setting.service.js +4 -0
  36. package/dist/services/setting.service.js.map +1 -1
  37. package/dist/services/settings/default-settings-provider.service.d.ts +20 -0
  38. package/dist/services/settings/default-settings-provider.service.d.ts.map +1 -1
  39. package/dist/services/settings/default-settings-provider.service.js +14 -0
  40. package/dist/services/settings/default-settings-provider.service.js.map +1 -1
  41. package/dist/solid-core.module.d.ts.map +1 -1
  42. package/dist/solid-core.module.js +6 -0
  43. package/dist/solid-core.module.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/constants/media-file-types.ts +1 -0
  46. package/src/dtos/create-field-metadata.dto.ts +14 -1
  47. package/src/entities/field-metadata.entity.ts +3 -0
  48. package/src/helpers/field-crud-managers/MediaFieldCrudManager.ts +73 -34
  49. package/src/services/chatter-message.service.ts +56 -14
  50. package/src/services/crud.service.ts +1 -0
  51. package/src/services/field-metadata.service.ts +2 -0
  52. package/src/services/media.service.ts +1 -0
  53. package/src/services/module-metadata.service.ts +7 -0
  54. package/src/services/setting.service.ts +5 -0
  55. package/src/services/settings/default-settings-provider.service.ts +14 -0
  56. package/src/solid-core.module.ts +13 -0
  57. package/postman/mpin.postman_collection.json +0 -1045
@@ -1,1045 +0,0 @@
1
- {
2
- "info": {
3
- "name": "SolidX — MPIN",
4
- "_postman_id": "b7c1e2a4-5d38-4f61-9a2b-0c7d5e91f3aa",
5
- "description": "End-to-end scenarios for MPIN sign-in (Deliverable C).\n\n## Running it\n\n1. Set `baseUrl`, `username` and `password` in the collection variables.\n2. Ensure `mpinEnabled = true` in settings.\n3. **Set `mpinLockoutDuration` to 5 (seconds)** before running folder 4 — at the default 900s the ladder cannot complete in one pass.\n4. Run the collection top to bottom. Requests chain through collection variables, so no manual copying is needed.\n\nFolders 8 and 9 need settings changed mid-run and are marked manual; they are skipped by a Collection Run.\n\n## Response shapes\n\nSuccess is wrapped by `WrapResponseInterceptor`:\n```\n{ \"statusCode\": 200, \"message\": [], \"error\": \"\", \"data\": { ... } }\n```\nErrors come from `HttpExceptionFilter` and carry a canonical code — the three MPIN failures are all HTTP 401 and are distinguished **only** by `errorCode`:\n```\n{ \"statusCode\": 401, \"errorCode\": \"solidx-mpin-invalid\", \"message\": \"...\" }\n```",
6
- "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
7
- },
8
- "variable": [
9
- { "key": "baseUrl", "value": "http://localhost:3000/api", "type": "string" },
10
- { "key": "username", "value": "sa", "type": "string" },
11
- { "key": "password", "value": "", "type": "string" },
12
- { "key": "mpin", "value": "482915", "type": "string" },
13
- { "key": "newMpin", "value": "739104", "type": "string" },
14
- { "key": "wrongMpin", "value": "999919", "type": "string" },
15
- { "key": "accessToken", "value": "", "type": "string" },
16
- { "key": "refreshToken", "value": "", "type": "string" },
17
- { "key": "deviceId", "value": "", "type": "string" },
18
- { "key": "credentialRef", "value": "", "type": "string" },
19
- { "key": "credentialId", "value": "", "type": "string" },
20
- { "key": "lockDeviceId", "value": "", "type": "string" },
21
- { "key": "lockCredentialRef", "value": "", "type": "string" },
22
- { "key": "changeDeviceId", "value": "", "type": "string" },
23
- { "key": "changeCredentialRef", "value": "", "type": "string" },
24
- { "key": "revokeDeviceId", "value": "", "type": "string" },
25
- { "key": "revokeCredentialRef", "value": "", "type": "string" },
26
- { "key": "invalidBody", "value": "", "type": "string" },
27
- { "key": "capIteration", "value": "0", "type": "string" },
28
- { "key": "capDeviceId", "value": "", "type": "string" },
29
- { "key": "mpinMaxDevicesPerUser", "value": "5", "type": "string" }
30
- ],
31
- "event": [
32
- {
33
- "listen": "test",
34
- "script": {
35
- "type": "text/javascript",
36
- "exec": [
37
- "// Shared helpers, available to every request in the collection.",
38
- "pm.expectCode = (expectedStatus, expectedErrorCode) => {",
39
- " pm.test(`HTTP ${expectedStatus}`, () => pm.response.to.have.status(expectedStatus));",
40
- " if (expectedErrorCode) {",
41
- " pm.test(`errorCode = ${expectedErrorCode}`, () => {",
42
- " pm.expect(pm.response.json().errorCode).to.eql(expectedErrorCode);",
43
- " });",
44
- " }",
45
- "};"
46
- ]
47
- }
48
- }
49
- ],
50
- "item": [
51
- {
52
- "name": "0 · Session",
53
- "description": "Obtain a normal session. Everything in folder 1 is bearer-authenticated.",
54
- "item": [
55
- {
56
- "name": "Login (password)",
57
- "event": [
58
- {
59
- "listen": "test",
60
- "script": {
61
- "type": "text/javascript",
62
- "exec": [
63
- "pm.test('HTTP 200', () => pm.response.to.have.status(200));",
64
- "const body = pm.response.json();",
65
- "const d = body.data || body;",
66
- "const access = d.accessToken || d.access_token;",
67
- "const refresh = d.refreshToken || d.refresh_token;",
68
- "pm.test('access token returned', () => pm.expect(access).to.be.a('string').and.not.empty);",
69
- "pm.collectionVariables.set('accessToken', access);",
70
- "pm.collectionVariables.set('refreshToken', refresh);",
71
- "// One deviceId for the whole run, as a real app would generate once and reuse.",
72
- "pm.collectionVariables.set('deviceId', pm.variables.replaceIn('{{$guid}}'));"
73
- ]
74
- }
75
- }
76
- ],
77
- "request": {
78
- "method": "POST",
79
- "header": [{ "key": "Content-Type", "value": "application/json" }],
80
- "body": {
81
- "mode": "raw",
82
- "raw": "{\n \"username\": \"{{username}}\",\n \"password\": \"{{password}}\"\n}"
83
- },
84
- "url": { "raw": "{{baseUrl}}/iam/authenticate", "host": ["{{baseUrl}}"], "path": ["iam", "authenticate"] }
85
- }
86
- }
87
- ]
88
- },
89
-
90
- {
91
- "name": "1 · Setup — happy path and validation",
92
- "item": [
93
- {
94
- "name": "Setup MPIN",
95
- "event": [
96
- {
97
- "listen": "test",
98
- "script": {
99
- "type": "text/javascript",
100
- "exec": [
101
- "pm.test('HTTP 200', () => pm.response.to.have.status(200));",
102
- "const d = pm.response.json().data;",
103
- "pm.test('credentialRef is 64 hex chars', () => pm.expect(d.credentialRef).to.match(/^[0-9a-f]{64}$/));",
104
- "pm.test('the MPIN is never echoed back', () => pm.expect(JSON.stringify(d)).to.not.include(pm.collectionVariables.get('mpin')));",
105
- "pm.collectionVariables.set('credentialRef', d.credentialRef);"
106
- ]
107
- }
108
- }
109
- ],
110
- "request": {
111
- "method": "POST",
112
- "header": [
113
- { "key": "Content-Type", "value": "application/json" },
114
- { "key": "Authorization", "value": "Bearer {{accessToken}}" }
115
- ],
116
- "body": {
117
- "mode": "raw",
118
- "raw": "{\n \"deviceId\": \"{{deviceId}}\",\n \"mpin\": \"{{mpin}}\",\n \"deviceName\": \"Postman Device\",\n \"platform\": \"ios\"\n}"
119
- },
120
- "url": { "raw": "{{baseUrl}}/iam/mpin/setup", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "setup"] },
121
- "description": "The handle is returned **once**. Only its SHA-256 is stored, so it can never be re-issued."
122
- }
123
- },
124
- {
125
- "name": "Reject · too short",
126
- "event": [{ "listen": "test", "script": { "type": "text/javascript", "exec": ["pm.expectCode(400, 'solidx-mpin-format-invalid');"] } }],
127
- "request": {
128
- "method": "POST",
129
- "header": [
130
- { "key": "Content-Type", "value": "application/json" },
131
- { "key": "Authorization", "value": "Bearer {{accessToken}}" }
132
- ],
133
- "body": { "mode": "raw", "raw": "{\n \"deviceId\": \"{{$guid}}\",\n \"mpin\": \"123\"\n}" },
134
- "url": { "raw": "{{baseUrl}}/iam/mpin/setup", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "setup"] }
135
- }
136
- },
137
- {
138
- "name": "Reject · non-digits",
139
- "event": [{ "listen": "test", "script": { "type": "text/javascript", "exec": ["pm.expectCode(400, 'solidx-mpin-format-invalid');"] } }],
140
- "request": {
141
- "method": "POST",
142
- "header": [
143
- { "key": "Content-Type", "value": "application/json" },
144
- { "key": "Authorization", "value": "Bearer {{accessToken}}" }
145
- ],
146
- "body": { "mode": "raw", "raw": "{\n \"deviceId\": \"{{$guid}}\",\n \"mpin\": \"abcd12\"\n}" },
147
- "url": { "raw": "{{baseUrl}}/iam/mpin/setup", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "setup"] }
148
- }
149
- },
150
- {
151
- "name": "Reject · all identical (111111)",
152
- "event": [{ "listen": "test", "script": { "type": "text/javascript", "exec": ["pm.expectCode(400, 'solidx-mpin-too-predictable');"] } }],
153
- "request": {
154
- "method": "POST",
155
- "header": [
156
- { "key": "Content-Type", "value": "application/json" },
157
- { "key": "Authorization", "value": "Bearer {{accessToken}}" }
158
- ],
159
- "body": { "mode": "raw", "raw": "{\n \"deviceId\": \"{{$guid}}\",\n \"mpin\": \"111111\"\n}" },
160
- "url": { "raw": "{{baseUrl}}/iam/mpin/setup", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "setup"] }
161
- }
162
- },
163
- {
164
- "name": "Reject · sequential (3456)",
165
- "event": [{ "listen": "test", "script": { "type": "text/javascript", "exec": ["pm.expectCode(400, 'solidx-mpin-too-predictable');"] } }],
166
- "request": {
167
- "method": "POST",
168
- "header": [
169
- { "key": "Content-Type", "value": "application/json" },
170
- { "key": "Authorization", "value": "Bearer {{accessToken}}" }
171
- ],
172
- "body": { "mode": "raw", "raw": "{\n \"deviceId\": \"{{$guid}}\",\n \"mpin\": \"3456\"\n}" },
173
- "url": { "raw": "{{baseUrl}}/iam/mpin/setup", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "setup"] }
174
- }
175
- },
176
- {
177
- "name": "Reject · denylisted (2580)",
178
- "event": [{ "listen": "test", "script": { "type": "text/javascript", "exec": ["pm.expectCode(400, 'solidx-mpin-too-predictable');"] } }],
179
- "request": {
180
- "method": "POST",
181
- "header": [
182
- { "key": "Content-Type", "value": "application/json" },
183
- { "key": "Authorization", "value": "Bearer {{accessToken}}" }
184
- ],
185
- "body": { "mode": "raw", "raw": "{\n \"deviceId\": \"{{$guid}}\",\n \"mpin\": \"2580\"\n}" },
186
- "url": { "raw": "{{baseUrl}}/iam/mpin/setup", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "setup"] },
187
- "description": "The keypad-column PIN. Caught by the denylist rather than by the identical/sequential rules."
188
- }
189
- },
190
- {
191
- "name": "Reject · ANDROID_ID as deviceId",
192
- "event": [
193
- {
194
- "listen": "test",
195
- "script": {
196
- "type": "text/javascript",
197
- "exec": [
198
- "pm.test('HTTP 400', () => pm.response.to.have.status(400));",
199
- "pm.test('rejected by class-validator, not by MpinService', () => {",
200
- " // No MPIN-specific errorCode: this never reaches the service.",
201
- " pm.expect(pm.response.json().errorCode).to.not.match(/^solidx-mpin-/);",
202
- "});"
203
- ]
204
- }
205
- }
206
- ],
207
- "request": {
208
- "method": "POST",
209
- "header": [
210
- { "key": "Content-Type", "value": "application/json" },
211
- { "key": "Authorization", "value": "Bearer {{accessToken}}" }
212
- ],
213
- "body": { "mode": "raw", "raw": "{\n \"deviceId\": \"9774d56d682e549c\",\n \"mpin\": \"{{mpin}}\"\n}" },
214
- "url": { "raw": "{{baseUrl}}/iam/mpin/setup", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "setup"] },
215
- "description": "Android's Settings.Secure.ANDROID_ID is 16 hex chars, not a UUID. Clients must generate their own UUID v4."
216
- }
217
- },
218
- {
219
- "name": "Numeric MPIN — documents the coercion trap",
220
- "event": [
221
- {
222
- "listen": "test",
223
- "script": {
224
- "type": "text/javascript",
225
- "exec": [
226
- "// enableImplicitConversion on the global ValidationPipe coerces a JSON",
227
- "// number to a string, so @IsString() does NOT reject this. A client",
228
- "// sending 012345 numerically would emit 12345 and silently store a",
229
- "// SHORTER, DIFFERENT MPIN. Always send a quoted string.",
230
- "pm.test('numeric payload is accepted (coerced) — hence the client rule', () => {",
231
- " pm.expect(pm.response.code).to.be.oneOf([200, 400]);",
232
- "});",
233
- "if (pm.response.code === 200) {",
234
- " pm.test('coerced to a string, proving the trap is real', () => {",
235
- " pm.expect(pm.response.json().data.credentialRef).to.be.a('string');",
236
- " });",
237
- "}"
238
- ]
239
- }
240
- }
241
- ],
242
- "request": {
243
- "method": "POST",
244
- "header": [
245
- { "key": "Content-Type", "value": "application/json" },
246
- { "key": "Authorization", "value": "Bearer {{accessToken}}" }
247
- ],
248
- "body": { "mode": "raw", "raw": "{\n \"deviceId\": \"{{$guid}}\",\n \"mpin\": 482915\n}" },
249
- "url": { "raw": "{{baseUrl}}/iam/mpin/setup", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "setup"] }
250
- }
251
- },
252
- {
253
- "name": "Re-setup on the same device rotates the handle",
254
- "event": [
255
- {
256
- "listen": "test",
257
- "script": {
258
- "type": "text/javascript",
259
- "exec": [
260
- "pm.test('HTTP 200', () => pm.response.to.have.status(200));",
261
- "const fresh = pm.response.json().data.credentialRef;",
262
- "pm.test('a NEW handle is issued', () => pm.expect(fresh).to.not.eql(pm.collectionVariables.get('credentialRef')));",
263
- "pm.collectionVariables.set('credentialRef', fresh);"
264
- ]
265
- }
266
- }
267
- ],
268
- "request": {
269
- "method": "POST",
270
- "header": [
271
- { "key": "Content-Type", "value": "application/json" },
272
- { "key": "Authorization", "value": "Bearer {{accessToken}}" }
273
- ],
274
- "body": {
275
- "mode": "raw",
276
- "raw": "{\n \"deviceId\": \"{{deviceId}}\",\n \"mpin\": \"{{mpin}}\",\n \"deviceName\": \"Postman Device\",\n \"platform\": \"ios\"\n}"
277
- },
278
- "url": { "raw": "{{baseUrl}}/iam/mpin/setup", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "setup"] },
279
- "description": "Same deviceId upserts the existing row rather than adding a second one. The previous handle is dead from here."
280
- }
281
- }
282
- ]
283
- },
284
-
285
- {
286
- "name": "2 · Login",
287
- "item": [
288
- {
289
- "name": "Login with MPIN",
290
- "event": [
291
- {
292
- "listen": "test",
293
- "script": {
294
- "type": "text/javascript",
295
- "exec": [
296
- "pm.test('HTTP 200', () => pm.response.to.have.status(200));",
297
- "const d = pm.response.json().data;",
298
- "pm.test('a fresh token pair is minted', () => {",
299
- " pm.expect(d.accessToken).to.be.a('string').and.not.empty;",
300
- " pm.expect(d.refreshToken).to.be.a('string').and.not.empty;",
301
- "});",
302
- "pm.test('refreshToken differs from the session one — not a replay', () => {",
303
- " pm.expect(d.refreshToken).to.not.eql(pm.collectionVariables.get('refreshToken'));",
304
- "});",
305
- "pm.test('no request body identifier was needed', () => {",
306
- " pm.expect(pm.request.body.raw).to.not.include('username');",
307
- " pm.expect(pm.request.body.raw).to.not.include('mobile');",
308
- "});",
309
- "pm.collectionVariables.set('refreshToken', d.refreshToken);",
310
- "pm.collectionVariables.set('accessToken', d.accessToken);"
311
- ]
312
- }
313
- }
314
- ],
315
- "request": {
316
- "method": "POST",
317
- "header": [{ "key": "Content-Type", "value": "application/json" }],
318
- "body": { "mode": "raw", "raw": "{\n \"credentialRef\": \"{{credentialRef}}\",\n \"mpin\": \"{{mpin}}\"\n}" },
319
- "url": { "raw": "{{baseUrl}}/iam/mpin/login", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "login"] },
320
- "description": "Public route — no Authorization header. The handle identifies the account, so no mobile/email is sent and there is no enumeration surface."
321
- }
322
- },
323
- {
324
- "name": "Wrong MPIN",
325
- "event": [
326
- {
327
- "listen": "test",
328
- "script": {
329
- "type": "text/javascript",
330
- "exec": [
331
- "pm.expectCode(401, 'solidx-mpin-invalid');",
332
- "// Stored so the next request can prove the two are indistinguishable.",
333
- "const b = pm.response.json();",
334
- "pm.collectionVariables.set('invalidBody', JSON.stringify({ statusCode: b.statusCode, errorCode: b.errorCode, message: b.message }));"
335
- ]
336
- }
337
- }
338
- ],
339
- "request": {
340
- "method": "POST",
341
- "header": [{ "key": "Content-Type", "value": "application/json" }],
342
- "body": { "mode": "raw", "raw": "{\n \"credentialRef\": \"{{credentialRef}}\",\n \"mpin\": \"{{wrongMpin}}\"\n}" },
343
- "url": { "raw": "{{baseUrl}}/iam/mpin/login", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "login"] }
344
- }
345
- },
346
- {
347
- "name": "Unknown handle — must be indistinguishable",
348
- "event": [
349
- {
350
- "listen": "test",
351
- "script": {
352
- "type": "text/javascript",
353
- "exec": [
354
- "pm.expectCode(401, 'solidx-mpin-invalid');",
355
- "const b = pm.response.json();",
356
- "const now = JSON.stringify({ statusCode: b.statusCode, errorCode: b.errorCode, message: b.message });",
357
- "pm.test('identical to a wrong MPIN — no enumeration oracle', () => {",
358
- " pm.expect(now).to.eql(pm.collectionVariables.get('invalidBody'));",
359
- "});"
360
- ]
361
- }
362
- }
363
- ],
364
- "request": {
365
- "method": "POST",
366
- "header": [{ "key": "Content-Type", "value": "application/json" }],
367
- "body": { "mode": "raw", "raw": "{\n \"credentialRef\": \"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\",\n \"mpin\": \"{{mpin}}\"\n}" },
368
- "url": { "raw": "{{baseUrl}}/iam/mpin/login", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "login"] }
369
- }
370
- }
371
- ]
372
- },
373
-
374
- {
375
- "name": "3 · The bug that started this",
376
- "description": "MPIN must survive logout and refresh-token expiry, because it mints a fresh pair rather than replaying a stored token.",
377
- "item": [
378
- {
379
- "name": "Logout",
380
- "event": [{ "listen": "test", "script": { "type": "text/javascript", "exec": ["pm.test('HTTP 200', () => pm.response.to.have.status(200));"] } }],
381
- "request": {
382
- "method": "POST",
383
- "header": [{ "key": "Content-Type", "value": "application/json" }],
384
- "body": { "mode": "raw", "raw": "{\n \"refreshToken\": \"{{refreshToken}}\"\n}" },
385
- "url": { "raw": "{{baseUrl}}/iam/logout", "host": ["{{baseUrl}}"], "path": ["iam", "logout"] }
386
- }
387
- },
388
- {
389
- "name": "Refresh with the logged-out token fails",
390
- "event": [{ "listen": "test", "script": { "type": "text/javascript", "exec": ["pm.test('HTTP 401 — the session really is gone', () => pm.response.to.have.status(401));"] } }],
391
- "request": {
392
- "method": "POST",
393
- "header": [{ "key": "Content-Type", "value": "application/json" }],
394
- "body": { "mode": "raw", "raw": "{\n \"refreshToken\": \"{{refreshToken}}\"\n}" },
395
- "url": { "raw": "{{baseUrl}}/iam/refresh-tokens", "host": ["{{baseUrl}}"], "path": ["iam", "refresh-tokens"] }
396
- }
397
- },
398
- {
399
- "name": "MPIN login STILL works after logout",
400
- "event": [
401
- {
402
- "listen": "test",
403
- "script": {
404
- "type": "text/javascript",
405
- "exec": [
406
- "pm.test('HTTP 200 — the original design failed here', () => pm.response.to.have.status(200));",
407
- "const d = pm.response.json().data;",
408
- "pm.collectionVariables.set('accessToken', d.accessToken);",
409
- "pm.collectionVariables.set('refreshToken', d.refreshToken);"
410
- ]
411
- }
412
- }
413
- ],
414
- "request": {
415
- "method": "POST",
416
- "header": [{ "key": "Content-Type", "value": "application/json" }],
417
- "body": { "mode": "raw", "raw": "{\n \"credentialRef\": \"{{credentialRef}}\",\n \"mpin\": \"{{mpin}}\"\n}" },
418
- "url": { "raw": "{{baseUrl}}/iam/mpin/login", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "login"] }
419
- }
420
- },
421
- {
422
- "name": "Logout · expired/garbage token is idempotent",
423
- "event": [
424
- {
425
- "listen": "test",
426
- "script": {
427
- "type": "text/javascript",
428
- "exec": [
429
- "pm.test('HTTP 200 — logout must never 500', () => pm.response.to.have.status(200));",
430
- "pm.test('not a 500 LOGOUT_FAILED', () => pm.expect(pm.response.code).to.not.eql(500));"
431
- ]
432
- }
433
- }
434
- ],
435
- "request": {
436
- "method": "POST",
437
- "header": [{ "key": "Content-Type", "value": "application/json" }],
438
- "body": { "mode": "raw", "raw": "{\n \"refreshToken\": \"not-a-real-token\"\n}" },
439
- "url": { "raw": "{{baseUrl}}/iam/logout", "host": ["{{baseUrl}}"], "path": ["iam", "logout"] },
440
- "description": "Deliverable A: verifyAsync uses ignoreExpiration, and an unverifiable token returns success rather than falling into the generic 500."
441
- }
442
- }
443
- ]
444
- },
445
-
446
- {
447
- "name": "4 · Lockout ladder (destructive)",
448
- "description": "Permanently deactivates its credential, so it uses a throwaway device.\n\n**Set `mpinLockoutDuration` to 5 seconds first** — the default 900s makes this unrunnable in one pass.",
449
- "item": [
450
- {
451
- "name": "Setup a throwaway device",
452
- "event": [
453
- {
454
- "listen": "prerequest",
455
- "script": { "type": "text/javascript", "exec": ["pm.collectionVariables.set('lockDeviceId', pm.variables.replaceIn('{{$guid}}'));"] }
456
- },
457
- {
458
- "listen": "test",
459
- "script": {
460
- "type": "text/javascript",
461
- "exec": [
462
- "pm.test('HTTP 200', () => pm.response.to.have.status(200));",
463
- "pm.collectionVariables.set('lockCredentialRef', pm.response.json().data.credentialRef);"
464
- ]
465
- }
466
- }
467
- ],
468
- "request": {
469
- "method": "POST",
470
- "header": [
471
- { "key": "Content-Type", "value": "application/json" },
472
- { "key": "Authorization", "value": "Bearer {{accessToken}}" }
473
- ],
474
- "body": { "mode": "raw", "raw": "{\n \"deviceId\": \"{{lockDeviceId}}\",\n \"mpin\": \"{{mpin}}\",\n \"deviceName\": \"Lockout Test\"\n}" },
475
- "url": { "raw": "{{baseUrl}}/iam/mpin/setup", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "setup"] }
476
- }
477
- },
478
- {
479
- "name": "Attempts 1–4 · invalid",
480
- "event": [
481
- {
482
- "listen": "prerequest",
483
- "script": {
484
- "type": "text/javascript",
485
- "exec": [
486
- "let i = parseInt(pm.collectionVariables.get('capIteration') || '0');",
487
- "// reuse the counter slot for this loop",
488
- "if (!pm.collectionVariables.get('ladderCount')) pm.collectionVariables.set('ladderCount', '0');"
489
- ]
490
- }
491
- },
492
- {
493
- "listen": "test",
494
- "script": {
495
- "type": "text/javascript",
496
- "exec": [
497
- "let n = parseInt(pm.collectionVariables.get('ladderCount')) + 1;",
498
- "pm.collectionVariables.set('ladderCount', String(n));",
499
- "pm.expectCode(401, 'solidx-mpin-invalid');",
500
- "if (n < 4) { postman.setNextRequest('Attempts 1–4 · invalid'); }"
501
- ]
502
- }
503
- }
504
- ],
505
- "request": {
506
- "method": "POST",
507
- "header": [{ "key": "Content-Type", "value": "application/json" }],
508
- "body": { "mode": "raw", "raw": "{\n \"credentialRef\": \"{{lockCredentialRef}}\",\n \"mpin\": \"{{wrongMpin}}\"\n}" },
509
- "url": { "raw": "{{baseUrl}}/iam/mpin/login", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "login"] },
510
- "description": "Loops four times via setNextRequest."
511
- }
512
- },
513
- {
514
- "name": "Attempt 5 · locked",
515
- "event": [{ "listen": "test", "script": { "type": "text/javascript", "exec": ["pm.expectCode(401, 'solidx-mpin-locked');"] } }],
516
- "request": {
517
- "method": "POST",
518
- "header": [{ "key": "Content-Type", "value": "application/json" }],
519
- "body": { "mode": "raw", "raw": "{\n \"credentialRef\": \"{{lockCredentialRef}}\",\n \"mpin\": \"{{wrongMpin}}\"\n}" },
520
- "url": { "raw": "{{baseUrl}}/iam/mpin/login", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "login"] }
521
- }
522
- },
523
- {
524
- "name": "Correct MPIN during lockout still fails",
525
- "event": [
526
- {
527
- "listen": "test",
528
- "script": {
529
- "type": "text/javascript",
530
- "exec": [
531
- "pm.expectCode(401, 'solidx-mpin-locked');",
532
- "pm.test('locked, not invalid — the PIN was right', () => {",
533
- " pm.expect(pm.response.json().errorCode).to.not.eql('solidx-mpin-invalid');",
534
- "});"
535
- ]
536
- }
537
- }
538
- ],
539
- "request": {
540
- "method": "POST",
541
- "header": [{ "key": "Content-Type", "value": "application/json" }],
542
- "body": { "mode": "raw", "raw": "{\n \"credentialRef\": \"{{lockCredentialRef}}\",\n \"mpin\": \"{{mpin}}\"\n}" },
543
- "url": { "raw": "{{baseUrl}}/iam/mpin/login", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "login"] },
544
- "description": "Rejected before the hash comparison, so the lockout window is not consumed and no bcrypt work is done."
545
- }
546
- },
547
- {
548
- "name": "Wait for the lockout to expire",
549
- "event": [
550
- {
551
- "listen": "prerequest",
552
- "script": {
553
- "type": "text/javascript",
554
- "exec": [
555
- "// Blocking sleep — assumes mpinLockoutDuration was lowered to ~5s.",
556
- "const until = Date.now() + 7000;",
557
- "while (Date.now() < until) { /* wait */ }"
558
- ]
559
- }
560
- },
561
- { "listen": "test", "script": { "type": "text/javascript", "exec": ["pm.test('lockout window elapsed', () => true);"] } }
562
- ],
563
- "request": {
564
- "method": "GET",
565
- "header": [],
566
- "url": { "raw": "{{baseUrl}}/iam/mpin/devices", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "devices"] },
567
- "description": "Any cheap call; the point is the pre-request delay."
568
- }
569
- },
570
- {
571
- "name": "Attempts 6–9 · counter did NOT reset",
572
- "event": [
573
- {
574
- "listen": "test",
575
- "script": {
576
- "type": "text/javascript",
577
- "exec": [
578
- "let n = parseInt(pm.collectionVariables.get('ladderCount2') || '0') + 1;",
579
- "pm.collectionVariables.set('ladderCount2', String(n));",
580
- "pm.expectCode(401, 'solidx-mpin-invalid');",
581
- "if (n < 4) { postman.setNextRequest('Attempts 6–9 · counter did NOT reset'); }"
582
- ]
583
- }
584
- }
585
- ],
586
- "request": {
587
- "method": "POST",
588
- "header": [{ "key": "Content-Type", "value": "application/json" }],
589
- "body": { "mode": "raw", "raw": "{\n \"credentialRef\": \"{{lockCredentialRef}}\",\n \"mpin\": \"{{wrongMpin}}\"\n}" },
590
- "url": { "raw": "{{baseUrl}}/iam/mpin/login", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "login"] },
591
- "description": "failedAttempts is cumulative and resets only on success — never when a lockout expires."
592
- }
593
- },
594
- {
595
- "name": "Attempt 10 · terminal, revoked",
596
- "event": [
597
- {
598
- "listen": "test",
599
- "script": {
600
- "type": "text/javascript",
601
- "exec": [
602
- "pm.expectCode(401, 'solidx-mpin-revoked');",
603
- "pm.test('the attempt that kills it says so — the client must stop retrying', () => {",
604
- " pm.expect(pm.response.json().errorCode).to.eql('solidx-mpin-revoked');",
605
- "});"
606
- ]
607
- }
608
- }
609
- ],
610
- "request": {
611
- "method": "POST",
612
- "header": [{ "key": "Content-Type", "value": "application/json" }],
613
- "body": { "mode": "raw", "raw": "{\n \"credentialRef\": \"{{lockCredentialRef}}\",\n \"mpin\": \"{{wrongMpin}}\"\n}" },
614
- "url": { "raw": "{{baseUrl}}/iam/mpin/login", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "login"] }
615
- }
616
- },
617
- {
618
- "name": "Attempt 11 · still revoked (no asymmetry)",
619
- "event": [{ "listen": "test", "script": { "type": "text/javascript", "exec": ["pm.expectCode(401, 'solidx-mpin-revoked');"] } }],
620
- "request": {
621
- "method": "POST",
622
- "header": [{ "key": "Content-Type", "value": "application/json" }],
623
- "body": { "mode": "raw", "raw": "{\n \"credentialRef\": \"{{lockCredentialRef}}\",\n \"mpin\": \"{{wrongMpin}}\"\n}" },
624
- "url": { "raw": "{{baseUrl}}/iam/mpin/login", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "login"] },
625
- "description": "A dead credential reports as dead regardless of the MPIN supplied."
626
- }
627
- },
628
- {
629
- "name": "Normal login still works — the ACCOUNT is not locked",
630
- "event": [
631
- {
632
- "listen": "test",
633
- "script": {
634
- "type": "text/javascript",
635
- "exec": [
636
- "pm.test('HTTP 200 — only the credential died, never the account', () => pm.response.to.have.status(200));",
637
- "const d = pm.response.json().data || {};",
638
- "if (d.accessToken) pm.collectionVariables.set('accessToken', d.accessToken);"
639
- ]
640
- }
641
- }
642
- ],
643
- "request": {
644
- "method": "POST",
645
- "header": [{ "key": "Content-Type", "value": "application/json" }],
646
- "body": { "mode": "raw", "raw": "{\n \"username\": \"{{username}}\",\n \"password\": \"{{password}}\"\n}" },
647
- "url": { "raw": "{{baseUrl}}/iam/authenticate", "host": ["{{baseUrl}}"], "path": ["iam", "authenticate"] }
648
- }
649
- }
650
- ]
651
- },
652
-
653
- {
654
- "name": "5 · Change MPIN",
655
- "item": [
656
- {
657
- "name": "Setup a device for change tests",
658
- "event": [
659
- { "listen": "prerequest", "script": { "type": "text/javascript", "exec": ["pm.collectionVariables.set('changeDeviceId', pm.variables.replaceIn('{{$guid}}'));"] } },
660
- { "listen": "test", "script": { "type": "text/javascript", "exec": ["pm.test('HTTP 200', () => pm.response.to.have.status(200));", "pm.collectionVariables.set('changeCredentialRef', pm.response.json().data.credentialRef);"] } }
661
- ],
662
- "request": {
663
- "method": "POST",
664
- "header": [
665
- { "key": "Content-Type", "value": "application/json" },
666
- { "key": "Authorization", "value": "Bearer {{accessToken}}" }
667
- ],
668
- "body": { "mode": "raw", "raw": "{\n \"deviceId\": \"{{changeDeviceId}}\",\n \"mpin\": \"{{mpin}}\",\n \"deviceName\": \"Change Test\"\n}" },
669
- "url": { "raw": "{{baseUrl}}/iam/mpin/setup", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "setup"] }
670
- }
671
- },
672
- {
673
- "name": "Change MPIN — returns a NEW handle",
674
- "event": [
675
- {
676
- "listen": "test",
677
- "script": {
678
- "type": "text/javascript",
679
- "exec": [
680
- "pm.test('HTTP 200', () => pm.response.to.have.status(200));",
681
- "const fresh = pm.response.json().data.credentialRef;",
682
- "pm.test('handle rotated — the client MUST store this', () => {",
683
- " pm.expect(fresh).to.match(/^[0-9a-f]{64}$/);",
684
- " pm.expect(fresh).to.not.eql(pm.collectionVariables.get('changeCredentialRef'));",
685
- "});",
686
- "pm.collectionVariables.set('oldChangeRef', pm.collectionVariables.get('changeCredentialRef'));",
687
- "pm.collectionVariables.set('changeCredentialRef', fresh);"
688
- ]
689
- }
690
- }
691
- ],
692
- "request": {
693
- "method": "POST",
694
- "header": [
695
- { "key": "Content-Type", "value": "application/json" },
696
- { "key": "Authorization", "value": "Bearer {{accessToken}}" }
697
- ],
698
- "body": { "mode": "raw", "raw": "{\n \"credentialRef\": \"{{changeCredentialRef}}\",\n \"currentMpin\": \"{{mpin}}\",\n \"newMpin\": \"{{newMpin}}\"\n}" },
699
- "url": { "raw": "{{baseUrl}}/iam/mpin/change", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "change"] },
700
- "description": "Discarding this response leaves the app holding a dead handle and MPIN silently broken."
701
- }
702
- },
703
- {
704
- "name": "Old handle is dead",
705
- "event": [{ "listen": "test", "script": { "type": "text/javascript", "exec": ["pm.expectCode(401, 'solidx-mpin-invalid');"] } }],
706
- "request": {
707
- "method": "POST",
708
- "header": [{ "key": "Content-Type", "value": "application/json" }],
709
- "body": { "mode": "raw", "raw": "{\n \"credentialRef\": \"{{oldChangeRef}}\",\n \"mpin\": \"{{newMpin}}\"\n}" },
710
- "url": { "raw": "{{baseUrl}}/iam/mpin/login", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "login"] }
711
- }
712
- },
713
- {
714
- "name": "New handle + new MPIN works",
715
- "event": [{ "listen": "test", "script": { "type": "text/javascript", "exec": ["pm.test('HTTP 200', () => pm.response.to.have.status(200));"] } }],
716
- "request": {
717
- "method": "POST",
718
- "header": [{ "key": "Content-Type", "value": "application/json" }],
719
- "body": { "mode": "raw", "raw": "{\n \"credentialRef\": \"{{changeCredentialRef}}\",\n \"mpin\": \"{{newMpin}}\"\n}" },
720
- "url": { "raw": "{{baseUrl}}/iam/mpin/login", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "login"] }
721
- }
722
- },
723
- {
724
- "name": "Change with wrong currentMpin",
725
- "event": [{ "listen": "test", "script": { "type": "text/javascript", "exec": ["pm.expectCode(401, 'solidx-mpin-invalid');"] } }],
726
- "request": {
727
- "method": "POST",
728
- "header": [
729
- { "key": "Content-Type", "value": "application/json" },
730
- { "key": "Authorization", "value": "Bearer {{accessToken}}" }
731
- ],
732
- "body": { "mode": "raw", "raw": "{\n \"credentialRef\": \"{{changeCredentialRef}}\",\n \"currentMpin\": \"{{wrongMpin}}\",\n \"newMpin\": \"135791\"\n}" },
733
- "url": { "raw": "{{baseUrl}}/iam/mpin/change", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "change"] },
734
- "description": "Failures here count towards the same lockout ladder as login."
735
- }
736
- }
737
- ]
738
- },
739
-
740
- {
741
- "name": "6 · Devices and revocation",
742
- "item": [
743
- {
744
- "name": "List my devices",
745
- "event": [
746
- {
747
- "listen": "test",
748
- "script": {
749
- "type": "text/javascript",
750
- "exec": [
751
- "pm.test('HTTP 200', () => pm.response.to.have.status(200));",
752
- "const rows = pm.response.json().data;",
753
- "pm.test('an array is returned', () => pm.expect(rows).to.be.an('array'));",
754
- "pm.test('NO secrets leak in the list', () => {",
755
- " const raw = JSON.stringify(rows);",
756
- " pm.expect(raw).to.not.include('hashedMpin');",
757
- " pm.expect(raw).to.not.include('hashedCredentialRef');",
758
- " pm.expect(raw).to.not.include('$2b$');",
759
- "});",
760
- "pm.test('revoked credentials are not listed', () => {",
761
- " rows.forEach(r => pm.expect(r.isActive).to.eql(true));",
762
- "});",
763
- "if (rows.length) pm.collectionVariables.set('credentialId', rows[0].id);"
764
- ]
765
- }
766
- }
767
- ],
768
- "request": {
769
- "method": "GET",
770
- "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }],
771
- "url": { "raw": "{{baseUrl}}/iam/mpin/devices", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "devices"] }
772
- }
773
- },
774
- {
775
- "name": "Setup a device to revoke",
776
- "event": [
777
- { "listen": "prerequest", "script": { "type": "text/javascript", "exec": ["pm.collectionVariables.set('revokeDeviceId', pm.variables.replaceIn('{{$guid}}'));"] } },
778
- {
779
- "listen": "test",
780
- "script": {
781
- "type": "text/javascript",
782
- "exec": [
783
- "pm.test('HTTP 200', () => pm.response.to.have.status(200));",
784
- "pm.collectionVariables.set('revokeCredentialRef', pm.response.json().data.credentialRef);"
785
- ]
786
- }
787
- }
788
- ],
789
- "request": {
790
- "method": "POST",
791
- "header": [
792
- { "key": "Content-Type", "value": "application/json" },
793
- { "key": "Authorization", "value": "Bearer {{accessToken}}" }
794
- ],
795
- "body": { "mode": "raw", "raw": "{\n \"deviceId\": \"{{revokeDeviceId}}\",\n \"mpin\": \"{{mpin}}\",\n \"deviceName\": \"To Be Revoked\"\n}" },
796
- "url": { "raw": "{{baseUrl}}/iam/mpin/setup", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "setup"] }
797
- }
798
- },
799
- {
800
- "name": "Find its row id",
801
- "event": [
802
- {
803
- "listen": "test",
804
- "script": {
805
- "type": "text/javascript",
806
- "exec": [
807
- "const rows = pm.response.json().data;",
808
- "const row = rows.find(r => r.deviceId === pm.collectionVariables.get('revokeDeviceId'));",
809
- "pm.test('the new device appears in the list', () => pm.expect(row).to.be.an('object'));",
810
- "pm.collectionVariables.set('credentialId', row.id);"
811
- ]
812
- }
813
- }
814
- ],
815
- "request": {
816
- "method": "GET",
817
- "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }],
818
- "url": { "raw": "{{baseUrl}}/iam/mpin/devices", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "devices"] }
819
- }
820
- },
821
- {
822
- "name": "Revoke it",
823
- "event": [{ "listen": "test", "script": { "type": "text/javascript", "exec": ["pm.test('HTTP 200', () => pm.response.to.have.status(200));"] } }],
824
- "request": {
825
- "method": "DELETE",
826
- "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }],
827
- "url": { "raw": "{{baseUrl}}/iam/mpin/devices/{{credentialId}}", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "devices", "{{credentialId}}"] },
828
- "description": "A soft delete — the row survives with isActive=false so a login can be answered with MPIN_REVOKED rather than a baffling MPIN_INVALID."
829
- }
830
- },
831
- {
832
- "name": "Login with the correct MPIN · revoked",
833
- "event": [{ "listen": "test", "script": { "type": "text/javascript", "exec": ["pm.expectCode(401, 'solidx-mpin-revoked');"] } }],
834
- "request": {
835
- "method": "POST",
836
- "header": [{ "key": "Content-Type", "value": "application/json" }],
837
- "body": { "mode": "raw", "raw": "{\n \"credentialRef\": \"{{revokeCredentialRef}}\",\n \"mpin\": \"{{mpin}}\"\n}" },
838
- "url": { "raw": "{{baseUrl}}/iam/mpin/login", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "login"] }
839
- }
840
- },
841
- {
842
- "name": "Login with a wrong MPIN · also revoked",
843
- "event": [{ "listen": "test", "script": { "type": "text/javascript", "exec": ["pm.expectCode(401, 'solidx-mpin-revoked');"] } }],
844
- "request": {
845
- "method": "POST",
846
- "header": [{ "key": "Content-Type", "value": "application/json" }],
847
- "body": { "mode": "raw", "raw": "{\n \"credentialRef\": \"{{revokeCredentialRef}}\",\n \"mpin\": \"{{wrongMpin}}\"\n}" },
848
- "url": { "raw": "{{baseUrl}}/iam/mpin/login", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "login"] },
849
- "description": "The isActive check runs before the hash comparison, so a dead credential reports consistently. No asymmetry between the attempt that kills it and the next one."
850
- }
851
- },
852
- {
853
- "name": "Revoked device no longer listed",
854
- "event": [
855
- {
856
- "listen": "test",
857
- "script": {
858
- "type": "text/javascript",
859
- "exec": [
860
- "const rows = pm.response.json().data;",
861
- "const still = rows.find(r => r.deviceId === pm.collectionVariables.get('revokeDeviceId'));",
862
- "pm.test('gone from the active list', () => pm.expect(still).to.be.undefined);"
863
- ]
864
- }
865
- }
866
- ],
867
- "request": {
868
- "method": "GET",
869
- "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }],
870
- "url": { "raw": "{{baseUrl}}/iam/mpin/devices", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "devices"] }
871
- }
872
- }
873
- ]
874
- },
875
-
876
- {
877
- "name": "7 · Device cap evicts, never rejects",
878
- "description": "Simulates repeated reinstalls: every setup uses a FRESH deviceId, as a reinstalled app would. All must succeed; the active count must hold at the cap.",
879
- "item": [
880
- {
881
- "name": "Cap · setup on a fresh device",
882
- "event": [
883
- {
884
- "listen": "prerequest",
885
- "script": {
886
- "type": "text/javascript",
887
- "exec": [
888
- "let i = parseInt(pm.collectionVariables.get('capIteration') || '0');",
889
- "pm.collectionVariables.set('capIteration', String(i + 1));",
890
- "pm.collectionVariables.set('capDeviceId', pm.variables.replaceIn('{{$guid}}'));"
891
- ]
892
- }
893
- },
894
- {
895
- "listen": "test",
896
- "script": {
897
- "type": "text/javascript",
898
- "exec": [
899
- "const i = parseInt(pm.collectionVariables.get('capIteration'));",
900
- "const cap = parseInt(pm.collectionVariables.get('mpinMaxDevicesPerUser'));",
901
- "pm.test(`reinstall #${i} succeeds — the cap must evict, not reject`, () => pm.response.to.have.status(200));",
902
- "if (i < cap + 2) { postman.setNextRequest('Cap · setup on a fresh device'); }"
903
- ]
904
- }
905
- }
906
- ],
907
- "request": {
908
- "method": "POST",
909
- "header": [
910
- { "key": "Content-Type", "value": "application/json" },
911
- { "key": "Authorization", "value": "Bearer {{accessToken}}" }
912
- ],
913
- "body": { "mode": "raw", "raw": "{\n \"deviceId\": \"{{capDeviceId}}\",\n \"mpin\": \"{{mpin}}\",\n \"deviceName\": \"Reinstall {{capIteration}}\"\n}" },
914
- "url": { "raw": "{{baseUrl}}/iam/mpin/setup", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "setup"] },
915
- "description": "Loops cap+2 times. Rejecting at the cap would mean a user who reinstalls a few times could never set MPIN up again."
916
- }
917
- },
918
- {
919
- "name": "Active devices held at the cap",
920
- "event": [
921
- {
922
- "listen": "test",
923
- "script": {
924
- "type": "text/javascript",
925
- "exec": [
926
- "const rows = pm.response.json().data;",
927
- "const cap = parseInt(pm.collectionVariables.get('mpinMaxDevicesPerUser'));",
928
- "pm.test(`active count <= cap (${cap})`, () => pm.expect(rows.length).to.be.at.most(cap));",
929
- "pm.test('oldest were evicted, newest survive', () => {",
930
- " pm.expect(rows.some(r => (r.deviceName || '').startsWith('Reinstall'))).to.be.true;",
931
- "});"
932
- ]
933
- }
934
- }
935
- ],
936
- "request": {
937
- "method": "GET",
938
- "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }],
939
- "url": { "raw": "{{baseUrl}}/iam/mpin/devices", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "devices"] }
940
- }
941
- }
942
- ]
943
- },
944
-
945
- {
946
- "name": "8 · Feature gate (manual)",
947
- "description": "Set `mpinEnabled = false` in settings, then run these. Skipped by an automated run because the setting must change mid-pass.",
948
- "item": [
949
- {
950
- "name": "Login 404s when disabled",
951
- "event": [
952
- {
953
- "listen": "test",
954
- "script": {
955
- "type": "text/javascript",
956
- "exec": [
957
- "pm.test('HTTP 404 — a disabled feature should look absent', () => pm.response.to.have.status(404));",
958
- "pm.test('not 401/403, which would imply it exists', () => pm.expect(pm.response.code).to.not.be.oneOf([401, 403]));"
959
- ]
960
- }
961
- }
962
- ],
963
- "request": {
964
- "method": "POST",
965
- "header": [{ "key": "Content-Type", "value": "application/json" }],
966
- "body": { "mode": "raw", "raw": "{\n \"credentialRef\": \"{{credentialRef}}\",\n \"mpin\": \"{{mpin}}\"\n}" },
967
- "url": { "raw": "{{baseUrl}}/iam/mpin/login", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "login"] }
968
- }
969
- },
970
- {
971
- "name": "Setup 404s when disabled",
972
- "event": [{ "listen": "test", "script": { "type": "text/javascript", "exec": ["pm.test('HTTP 404', () => pm.response.to.have.status(404));"] } }],
973
- "request": {
974
- "method": "POST",
975
- "header": [
976
- { "key": "Content-Type", "value": "application/json" },
977
- { "key": "Authorization", "value": "Bearer {{accessToken}}" }
978
- ],
979
- "body": { "mode": "raw", "raw": "{\n \"deviceId\": \"{{$guid}}\",\n \"mpin\": \"{{mpin}}\"\n}" },
980
- "url": { "raw": "{{baseUrl}}/iam/mpin/setup", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "setup"] }
981
- }
982
- }
983
- ]
984
- },
985
-
986
- {
987
- "name": "9 · Session interaction (manual)",
988
- "description": "Requires `preventConcurrentLogins = false` (the default). Proves Deliverable B: an MPIN session and a web session coexist.",
989
- "item": [
990
- {
991
- "name": "MPIN login",
992
- "event": [
993
- {
994
- "listen": "test",
995
- "script": {
996
- "type": "text/javascript",
997
- "exec": [
998
- "pm.test('HTTP 200', () => pm.response.to.have.status(200));",
999
- "pm.collectionVariables.set('mpinRefreshToken', pm.response.json().data.refreshToken);"
1000
- ]
1001
- }
1002
- }
1003
- ],
1004
- "request": {
1005
- "method": "POST",
1006
- "header": [{ "key": "Content-Type", "value": "application/json" }],
1007
- "body": { "mode": "raw", "raw": "{\n \"credentialRef\": \"{{credentialRef}}\",\n \"mpin\": \"{{mpin}}\"\n}" },
1008
- "url": { "raw": "{{baseUrl}}/iam/mpin/login", "host": ["{{baseUrl}}"], "path": ["iam", "mpin", "login"] }
1009
- }
1010
- },
1011
- {
1012
- "name": "Password login as a second 'device'",
1013
- "event": [{ "listen": "test", "script": { "type": "text/javascript", "exec": ["pm.test('HTTP 200', () => pm.response.to.have.status(200));"] } }],
1014
- "request": {
1015
- "method": "POST",
1016
- "header": [{ "key": "Content-Type", "value": "application/json" }],
1017
- "body": { "mode": "raw", "raw": "{\n \"username\": \"{{username}}\",\n \"password\": \"{{password}}\"\n}" },
1018
- "url": { "raw": "{{baseUrl}}/iam/authenticate", "host": ["{{baseUrl}}"], "path": ["iam", "authenticate"] }
1019
- }
1020
- },
1021
- {
1022
- "name": "MPIN session still refreshes — not evicted",
1023
- "event": [
1024
- {
1025
- "listen": "test",
1026
- "script": {
1027
- "type": "text/javascript",
1028
- "exec": [
1029
- "pm.test('HTTP 200 — this is the per-device bucket fix', () => pm.response.to.have.status(200));",
1030
- "pm.test('before Deliverable B this returned 401', () => pm.expect(pm.response.code).to.eql(200));"
1031
- ]
1032
- }
1033
- }
1034
- ],
1035
- "request": {
1036
- "method": "POST",
1037
- "header": [{ "key": "Content-Type", "value": "application/json" }],
1038
- "body": { "mode": "raw", "raw": "{\n \"refreshToken\": \"{{mpinRefreshToken}}\"\n}" },
1039
- "url": { "raw": "{{baseUrl}}/iam/refresh-tokens", "host": ["{{baseUrl}}"], "path": ["iam", "refresh-tokens"] }
1040
- }
1041
- }
1042
- ]
1043
- }
1044
- ]
1045
- }