import { Test, TestingModule } from '@nestjs/testing'; import { OnboardingService } from './onboarding.service'; import { PrismaService } from '../../prisma/prisma.service'; import { JwtService } from '@nestjs/jwt'; import { AuditService } from '../audit/audit.service'; import { ConfigService } from '@nestjs/config'; import { UnauthorizedException, NotFoundException, ConflictException } from '@nestjs/common'; import { createHash } from 'crypto'; const PEPPER = 'pepper-secret-must-be-32-chars-min'; const TEST_PHONE = '+19198765432'; const TEST_PHONE_HASH = createHash('sha256').update(`${PEPPER}:${TEST_PHONE}`).digest('hex'); describe('OnboardingService', () => { let service: OnboardingService; const mockPrisma: any = { group: { findUnique: jest.fn() }, tenant: { findUnique: jest.fn() }, otpChallenge: { create: jest.fn(), findUnique: jest.fn(), update: jest.fn() }, towerUser: { upsert: jest.fn() }, consentRecord: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn() }, }; const mockJwt = { signAsync: jest.fn().mockResolvedValue('member-jwt'), verify: jest.fn() }; const mockAudit = { log: jest.fn() }; const mockConfig = { get: jest.fn().mockReturnValue('pepper-secret-must-be-32-chars-min') }; beforeEach(async () => { jest.clearAllMocks(); const module: TestingModule = await Test.createTestingModule({ providers: [ OnboardingService, { provide: PrismaService, useValue: mockPrisma }, { provide: JwtService, useValue: mockJwt }, { provide: AuditService, useValue: mockAudit }, { provide: ConfigService, useValue: mockConfig }, ], }).compile(); service = module.get(OnboardingService); }); function validToken(): string { return Buffer.from( JSON.stringify({ tenantId: 'tnt-1', groupId: 'grp-1', jid: '1234@s.whatsapp.net' }), 'utf8', ).toString('base64url'); } describe('decodeOnboardingToken', () => { it('rejects garbage', () => { expect(() => service.decodeOnboardingToken('!!!')).toThrow(UnauthorizedException); }); it('rejects missing fields', () => { const tok = Buffer.from(JSON.stringify({ groupId: 'x' }), 'utf8').toString('base64url'); expect(() => service.decodeOnboardingToken(tok)).toThrow(UnauthorizedException); }); it('decodes a valid token', () => { const out = service.decodeOnboardingToken(validToken()); expect(out.tenantId).toBe('tnt-1'); expect(out.jid).toBe('1234@s.whatsapp.net'); }); }); describe('getOnboardInfo', () => { it('throws when group is not claimed', async () => { mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp-1', name: 'Foo', tenantId: null }); await expect(service.getOnboardInfo(validToken())).rejects.toThrow(ConflictException); }); it('returns group + tenant + policy info', async () => { mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp-1', name: 'UP Parivar', tenantId: 'tnt-1' }); mockPrisma.tenant.findUnique.mockResolvedValue({ id: 'tnt-1', name: 'UP Parivar Dallas' }); const res = await service.getOnboardInfo(validToken()); expect(res.groupName).toBe('UP Parivar'); expect(res.tenantName).toBe('UP Parivar Dallas'); expect(res.defaultScopes).toContain('INGEST'); }); }); describe('requestOtp', () => { it('creates a challenge with a 6-digit code', async () => { mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp-1', tenantId: 'tnt-1' }); mockPrisma.otpChallenge.create.mockResolvedValue({ id: 'ch-1' }); const res = await service.requestOtp(validToken(), '+19198765432'); expect(res.ok).toBe(true); expect(res.challengeId).toBeTruthy(); expect(res.expiresInSeconds).toBe(300); expect(mockPrisma.otpChallenge.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ jid: '1234@s.whatsapp.net', policyVersion: 'v1', }), }), ); }); it('rejects if group is not claimable', async () => { mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp-1', tenantId: null }); await expect(service.requestOtp(validToken(), '+19198765432')).rejects.toThrow(ConflictException); }); }); describe('verifyOtp', () => { it('rejects unknown challenge', async () => { mockPrisma.otpChallenge.findUnique.mockResolvedValue(null); await expect( service.verifyOtp(validToken(), 'ch-x', '+19198765432', '123456', [], undefined), ).rejects.toThrow(NotFoundException); }); it('rejects consumed challenge', async () => { mockPrisma.otpChallenge.findUnique.mockResolvedValue({ id: 'ch-1', code: '123456', consumedAt: new Date(), expiresAt: new Date(Date.now() + 60000), phoneHash: 'a', }); await expect( service.verifyOtp(validToken(), 'ch-1', '+19198765432', '123456', [], undefined), ).rejects.toThrow(UnauthorizedException); }); it('rejects wrong code', async () => { mockPrisma.otpChallenge.findUnique.mockResolvedValue({ id: 'ch-1', code: '111111', consumedAt: null, expiresAt: new Date(Date.now() + 60000), phoneHash: 'computed-hash', }); await expect( service.verifyOtp(validToken(), 'ch-1', '+19198765432', '123456', [], undefined), ).rejects.toThrow(/Invalid code/); }); it('creates TowerUser, ConsentRecord, and member JWT on success', async () => { mockPrisma.otpChallenge.findUnique.mockResolvedValue({ id: 'ch-1', code: '123456', consumedAt: null, expiresAt: new Date(Date.now() + 60000), phoneHash: TEST_PHONE_HASH, tenantId: 'tnt-1', jid: '1234@s.whatsapp.net', groupId: 'grp-1', }); mockPrisma.towerUser.upsert.mockResolvedValue({ id: 'user-1', tenantId: 'tnt-1', jid: '1234@s.whatsapp.net', displayName: '1234@s.whatsapp.net', }); mockPrisma.consentRecord.findFirst.mockResolvedValue(null); mockPrisma.consentRecord.create.mockResolvedValue({ id: 'consent-1', scopes: ['INGEST', 'DISPLAY'], retentionDays: 90, policyVersion: 'v1', }); const res = await service.verifyOtp(validToken(), 'ch-1', '+19198765432', '123456', [], undefined); expect(res.memberToken).toBe('member-jwt'); expect(res.user.id).toBe('user-1'); expect(res.consent.scopes).toContain('INGEST'); expect(mockPrisma.otpChallenge.update).toHaveBeenCalledWith({ where: { id: 'ch-1' }, data: { consumedAt: expect.any(Date) }, }); expect(mockAudit.log).toHaveBeenCalledWith( expect.objectContaining({ action: 'MEMBER_ONBOARDED' }), ); }); }); });