import Redis from 'ioredis'; import { test, expect, createTestUser, registerUser } from '../fixtures'; /** * E2E coverage for PATCH /auth/profile OTP-gated email/phone changes. * * Flow: PATCH /auth/profile → OTP stored in Redis (via notifications bus) → * POST /auth/profile/verify-email|verify-phone → persisted user state. * * We read the OTP code directly from Redis because the notifications transport * is asynchronous in dev/test. This is acceptable for an e2e that is already * exercising the same infra the API uses. */ const EMAIL_OTP_PREFIX = 'auth:email_change_otp'; const PHONE_OTP_PREFIX = 'auth:phone_change_otp'; function redisClient(): Redis { return new Redis({ host: process.env.REDIS_HOST ?? 'localhost', port: Number(process.env.REDIS_PORT ?? 6379), lazyConnect: true, maxRetriesPerRequest: 1, }); } async function readOtp(userId: string, prefix: string): Promise { const redis = redisClient(); try { await redis.connect(); const raw = await redis.get(`${prefix}:${userId}`); if (!raw) return null; const parsed = JSON.parse(raw) as { code: string }; return parsed.code; } catch { return null; } finally { await redis.quit().catch(() => undefined); } } test.describe('PATCH /auth/profile — OTP-gated email change', () => { test('request → OTP → confirm → persisted', async ({ request, authedRequest, testTokens }) => { // Decode JWT to get userId without a DB round-trip. const payload = JSON.parse( Buffer.from(testTokens.accessToken.split('.')[1] ?? '', 'base64url').toString('utf8'), ) as { sub: string }; const userId = payload.sub; const newEmail = `updated${Date.now()}@goodgo.test`; const patchRes = await authedRequest.patch('auth/profile', { data: { email: newEmail } }); expect(patchRes.status()).toBe(200); const patchBody = await patchRes.json(); expect(patchBody.data.emailChangePending).toBe(true); // Email should NOT be persisted yet. expect(patchBody.data.email).not.toBe(newEmail); const code = await readOtp(userId, EMAIL_OTP_PREFIX); expect(code, 'OTP code should be stored in Redis').toMatch(/^\d{6}$/); // Wrong code is rejected. const badRes = await authedRequest.post('auth/profile/verify-email', { data: { code: '000000' }, }); expect([400, 422]).toContain(badRes.status()); // Correct code commits the change. const okRes = await authedRequest.post('auth/profile/verify-email', { data: { code: code! }, }); expect(okRes.status()).toBe(201); const okBody = await okRes.json(); expect(okBody.data.email).toBe(newEmail); // GET /auth/profile now shows the new email. const profileRes = await authedRequest.get('auth/profile'); expect(profileRes.status()).toBe(200); const profile = await profileRes.json(); expect(profile.email).toBe(newEmail); // OTP is consumed — replaying fails. const replayRes = await authedRequest.post('auth/profile/verify-email', { data: { code: code! }, }); expect([400, 422]).toContain(replayRes.status()); // Unauthenticated request is rejected. const unauthRes = await request.post('auth/profile/verify-email', { data: { code: '123456' } }); expect(unauthRes.status()).toBe(401); }); test('expired / missing OTP returns validation error', async ({ authedRequest }) => { const res = await authedRequest.post('auth/profile/verify-email', { data: { code: '123456' }, }); expect([400, 422]).toContain(res.status()); }); }); test.describe('PATCH /auth/profile — OTP-gated phone change', () => { test('request → OTP → confirm → persisted', async ({ request }) => { // Fresh user so we can change phone without colliding with fixtures. const user = createTestUser(); const { accessToken } = await registerUser(request, user); const payload = JSON.parse( Buffer.from(accessToken.split('.')[1] ?? '', 'base64url').toString('utf8'), ) as { sub: string }; const userId = payload.sub; const headers = { Authorization: `Bearer ${accessToken}` }; const newPhone = `09${Date.now().toString().slice(-8)}`; const patchRes = await request.patch('auth/profile', { headers, data: { phoneNumber: newPhone }, }); expect(patchRes.status()).toBe(200); const patchBody = await patchRes.json(); expect(patchBody.data.phoneChangePending).toBe(true); const code = await readOtp(userId, PHONE_OTP_PREFIX); expect(code, 'SMS OTP code should be stored in Redis').toMatch(/^\d{6}$/); const okRes = await request.post('auth/profile/verify-phone', { headers, data: { code: code! }, }); expect(okRes.status()).toBe(201); const okBody = await okRes.json(); // Phone is normalised server-side (+84...) expect(okBody.data.phoneNumber).toContain(newPhone.slice(1)); }); });