|
19 | 19 | * answer is the one a succeeding send gets; |
20 | 20 | * - the server-side reset call (no `Request` — the identity import's) still |
21 | 21 | * awaits its send, because it reads the delivery receipt the send settles; |
| 22 | + * - the phone-number reset request (`plugins.phoneNumber` with an SMS |
| 23 | + * service) answers without waiting for the SMS send, as the emailed reset |
| 24 | + * does: a number with an account and a number without one get the same |
| 25 | + * answer while the reset text's send is still open, and the text still |
| 26 | + * goes out after the response; a text whose send fails is logged on the |
| 27 | + * server at error, without its code, and the answer is the one a sent text |
| 28 | + * gets; the server-side phone reset call (no `Request`) still awaits its |
| 29 | + * send; |
22 | 30 | * - `runAfterResponse` hands a task's synchronous throw to `onFailure` and |
23 | 31 | * survives an `onFailure` that throws. |
24 | 32 | */ |
@@ -177,6 +185,118 @@ describe('a reset request answers without waiting on the reset mail', () => { |
177 | 185 | }, 60_000); |
178 | 186 | }); |
179 | 187 |
|
| 188 | +describe('the phone-number reset request answers without waiting on the reset text', () => { |
| 189 | + const PHONE = '+15550161'; |
| 190 | + const NO_ACCOUNT_PHONE = '+15550162'; |
| 191 | + const SYS = { context: { isSystem: true } } as never; |
| 192 | + |
| 193 | + type Text = { to: string; templateParams?: { code?: string } }; |
| 194 | + |
| 195 | + /** A promise the case settles by hand. */ |
| 196 | + function deferred(): { promise: Promise<void>; resolve: () => void } { |
| 197 | + let resolve!: () => void; |
| 198 | + const promise = new Promise<void>((r) => { |
| 199 | + resolve = r; |
| 200 | + }); |
| 201 | + return { promise, resolve }; |
| 202 | + } |
| 203 | + |
| 204 | + /** |
| 205 | + * A deployment with phone-number sign-in and one account holding `PHONE`, |
| 206 | + * whose SMS service hands each send to `answer`, and records every text it |
| 207 | + * was handed (`texts`) and the number of every send that has settled |
| 208 | + * (`settled`). |
| 209 | + */ |
| 210 | + async function phoneDeployment(answer: () => Promise<unknown>) { |
| 211 | + vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 212 | + const engine = await engines.take(); |
| 213 | + const logged: string[] = []; |
| 214 | + const manager = new AuthManager({ |
| 215 | + secret: SECRET, |
| 216 | + baseUrl: BASE, |
| 217 | + dataEngine: engine as never, |
| 218 | + plugins: { phoneNumber: true }, |
| 219 | + logger: { warn: () => {}, error: (message: string) => logged.push(message) }, |
| 220 | + } as never); |
| 221 | + manager.setEmailService({ sendTemplate: async () => ({ status: 'sent' }) } as never); |
| 222 | + const texts: Text[] = []; |
| 223 | + const settled: string[] = []; |
| 224 | + manager.setSmsService({ |
| 225 | + async send(input: Text) { |
| 226 | + texts.push(input); |
| 227 | + try { |
| 228 | + return await answer(); |
| 229 | + } finally { |
| 230 | + settled.push(input.to); |
| 231 | + } |
| 232 | + }, |
| 233 | + isConfigured: () => true, |
| 234 | + } as never); |
| 235 | + const signedUp = await post(manager, '/sign-up/email', { email: EMAIL, password: PASSWORD, name: 'Reset' }); |
| 236 | + expect(signedUp.status, await signedUp.clone().text()).toBe(200); |
| 237 | + const [user] = (await engine.find('sys_user', { where: { email: EMAIL }, limit: 1 }, SYS)) as Array<{ id: string }>; |
| 238 | + await engine.update('sys_user', { id: user.id, phone_number: PHONE, phone_number_verified: true }, SYS); |
| 239 | + return { manager, texts, settled, logged }; |
| 240 | + } |
| 241 | + |
| 242 | + async function requestPhoneReset(manager: AuthManager, phoneNumber: string): Promise<{ status: number; body: string }> { |
| 243 | + const res = await post(manager, '/phone-number/request-password-reset', { phoneNumber }); |
| 244 | + return { status: res.status, body: await res.text() }; |
| 245 | + } |
| 246 | + |
| 247 | + it('answers a number with an account and a number without one alike, while the reset text is still being sent', async () => { |
| 248 | + const send = deferred(); |
| 249 | + const d = await phoneDeployment(async () => { |
| 250 | + await send.promise; |
| 251 | + return { id: 'sms1', status: 'sent' }; |
| 252 | + }); |
| 253 | + |
| 254 | + const forAccount = await requestPhoneReset(d.manager, PHONE); |
| 255 | + const forNobody = await requestPhoneReset(d.manager, NO_ACCOUNT_PHONE); |
| 256 | + |
| 257 | + expect(forAccount.status).toBe(200); |
| 258 | + expect(forAccount).toEqual(forNobody); |
| 259 | + // Both answers arrived while the text's send was still open. |
| 260 | + expect(d.settled).toEqual([]); |
| 261 | + |
| 262 | + // The control: the text still goes to the number, after the response. |
| 263 | + send.resolve(); |
| 264 | + await vi.waitFor(() => expect(d.settled).toEqual([PHONE])); |
| 265 | + expect(d.texts.map((t) => t.to)).toEqual([PHONE]); |
| 266 | + expect(d.logged).toEqual([]); |
| 267 | + }, 60_000); |
| 268 | + |
| 269 | + it('logs a reset text whose send fails on the server, without its code, and answers as for a text that is sent', async () => { |
| 270 | + const failing = await phoneDeployment(async () => ({ status: 'failed', error: 'carrier unavailable' })); |
| 271 | + const sending = await phoneDeployment(async () => ({ id: 'sms1', status: 'sent' })); |
| 272 | + |
| 273 | + const throughFailing = await requestPhoneReset(failing.manager, PHONE); |
| 274 | + const throughSending = await requestPhoneReset(sending.manager, PHONE); |
| 275 | + |
| 276 | + expect(throughFailing).toEqual(throughSending); |
| 277 | + await vi.waitFor(() => expect(failing.logged).toHaveLength(1)); |
| 278 | + expect(failing.logged[0]).toContain('phone password-reset text was not delivered'); |
| 279 | + expect(failing.logged[0]).toContain('carrier unavailable'); |
| 280 | + const code = String(failing.texts[0]?.templateParams?.code ?? ''); |
| 281 | + expect(code, 'the text carries its code').not.toBe(''); |
| 282 | + expect(failing.logged[0]).not.toContain(code); |
| 283 | + }, 60_000); |
| 284 | + |
| 285 | + it('the server-side phone reset call (no Request) still awaits its send', async () => { |
| 286 | + const d = await phoneDeployment(async () => { |
| 287 | + await new Promise((resolve) => setImmediate(resolve)); |
| 288 | + return { id: 'sms1', status: 'sent' }; |
| 289 | + }); |
| 290 | + const api = (await d.manager.getApi()) as unknown as { |
| 291 | + requestPasswordResetPhoneNumber(input: { body: { phoneNumber: string } }): Promise<unknown>; |
| 292 | + }; |
| 293 | + |
| 294 | + await api.requestPasswordResetPhoneNumber({ body: { phoneNumber: PHONE } }); |
| 295 | + |
| 296 | + expect(d.settled).toEqual([PHONE]); |
| 297 | + }, 60_000); |
| 298 | +}); |
| 299 | + |
180 | 300 | describe('runAfterResponse', () => { |
181 | 301 | it("hands a task's synchronous throw to onFailure", async () => { |
182 | 302 | const failures: unknown[] = []; |
|
0 commit comments