Files
goodgo-platform/e2e/api/auth-profile-otp.spec.ts
2026-05-04 17:27:08 +07:00

134 lines
4.8 KiB
TypeScript

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<string | null> {
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([400, 401]).toContain(unauthRes.status());
});
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));
});
});