175 lines
6.7 KiB
TypeScript
175 lines
6.7 KiB
TypeScript
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>(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' }),
|
|
);
|
|
});
|
|
});
|
|
});
|