134 lines
4.8 KiB
TypeScript
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));
|
|
});
|
|
});
|