diff --git a/package-lock.json b/package-lock.json index 7f0389f..d6b28c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3887,7 +3887,7 @@ "version": "1.15.11", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.11.tgz", "integrity": "sha512-iLmLTodbYxU39HhMPaMUooPwO/zqJWvsqkrXv1ZI38rMb048p6N7qtAtTp37sw9NzSrvH6oli8EdDygo09IZ/w==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "peer": true, @@ -3930,6 +3930,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3946,6 +3947,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3962,6 +3964,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3978,6 +3981,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3994,6 +3998,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4010,6 +4015,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4026,6 +4032,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4042,6 +4049,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4058,6 +4066,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4074,6 +4083,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4087,14 +4097,14 @@ "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@swc/types": { "version": "0.1.25", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 71c3fc5..5577aaf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -16,14 +16,19 @@ model User { id String @id @default(uuid()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - + + // Primary wallet address — canonical identifier for the user + walletAddress String @unique + reputation Int @default(0) // Sybil Resistance - worldcoinVerified Boolean @default(false) - sybilScores SybilScore[] + worldcoinVerified Boolean @default(false) + worldcoinVerifiedAt DateTime? - wallets Wallet[] + wallets Wallet[] + sybilScores SybilScore[] + worldIdVerifications WorldIdVerification[] } model Wallet { @@ -31,12 +36,12 @@ model Wallet { address String chain String linkedAt DateTime @default(now()) - - userId String - user User @relation(fields: [userId], references: [id]) + + userId String + user User @relation(fields: [userId], references: [id]) @@unique([address, chain]) - // We will enforce "one address -> one user" logic in the service layer + // We will enforce "one address -> one user" logic in the service layer // to handle multi-chain address reuse scenarios properly. } @@ -44,22 +49,24 @@ model SybilScore { id String @id @default(uuid()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - + + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + // Component scores (0-1 normalized) - worldcoinScore Float @default(0.0) // Binary verification signal (0 or 1) - walletAgeScore Float @default(0.0) // Based on wallet age - stakingScore Float @default(0.0) // Historical participation in staking - accuracyScore Float @default(0.0) // Claim accuracy from verification history - + worldcoinScore Float @default(0.0) // Binary verification signal (0 or 1) + walletAgeScore Float @default(0.0) // Based on wallet age + stakingScore Float @default(0.0) // Historical participation in staking + accuracyScore Float @default(0.0) // Claim accuracy from verification history + // Final composite score (0-1) - compositeScore Float @default(0.0) // Final Sybil resistance score - + compositeScore Float @default(0.0) // Final Sybil resistance score + // Metadata - calculationDetails String? // JSON string for explainability - + calculationDetails String? // JSON string for explainability + + explanation SybilExplanation? + @@unique([userId, createdAt]) @@index([userId]) @@index([compositeScore]) @@ -72,20 +79,24 @@ model SybilExplanation { createdAt DateTime @default(now()) sybilScore SybilScore @relation(fields: [sybilScoreId], references: [id], onDelete: Cascade) +} + model WorldIdVerification { - id String @id @default(uuid()) - verifiedAt DateTime @default(now()) - - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - nullifierHash String @unique + id String @id @default(uuid()) + verifiedAt DateTime @default(now()) + + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + nullifierHash String @unique verificationLevel String worldcoinAppId String worldcoinAction String merkleRoot String? proof Json? - + @@index([userId]) @@index([nullifierHash]) + + @@map("world_id_verifications") } diff --git a/src/entities/user.entity.spec.ts b/src/entities/user.entity.spec.ts new file mode 100644 index 0000000..049f69f --- /dev/null +++ b/src/entities/user.entity.spec.ts @@ -0,0 +1,83 @@ +import 'reflect-metadata'; +import { getMetadataArgsStorage } from 'typeorm'; +import { User } from './user.entity'; +import { UserEntity } from '../modules/users/entities/user.entity'; + +describe('User entity schema sync (BE-203)', () => { + it('canonical User entity and re-exported UserEntity should reference the same class', () => { + expect(UserEntity).toBe(User); + }); + + it('User entity maps to the "users" table', () => { + const tableMetadata = getMetadataArgsStorage().tables.find( + (t) => t.target === User, + ); + expect(tableMetadata?.name).toBe('users'); + }); + + describe('field coverage — TypeORM ↔ Prisma sync', () => { + let columnNames: string[]; + + beforeAll(() => { + columnNames = getMetadataArgsStorage() + .columns.filter((c) => c.target === User) + .map((c) => c.propertyName as string); + }); + + // Fields that must exist in both TypeORM entity and Prisma User model + const requiredFields = [ + 'id', + 'walletAddress', + 'reputation', + 'worldcoinVerified', + 'worldcoinVerifiedAt', + 'createdAt', + 'updatedAt', + ]; + + for (const field of requiredFields) { + it(`should declare column "${field}"`, () => { + expect(columnNames).toContain(field); + }); + } + + it('walletAddress should be marked unique', () => { + const col = getMetadataArgsStorage().columns.find( + (c) => c.target === User && c.propertyName === 'walletAddress', + ); + expect(col?.options?.unique).toBe(true); + }); + + it('worldcoinVerified should default to false', () => { + const col = getMetadataArgsStorage().columns.find( + (c) => c.target === User && c.propertyName === 'worldcoinVerified', + ); + expect(col?.options?.default).toBe(false); + }); + + it('worldcoinVerifiedAt should be nullable', () => { + const col = getMetadataArgsStorage().columns.find( + (c) => c.target === User && c.propertyName === 'worldcoinVerifiedAt', + ); + expect(col?.options?.nullable).toBe(true); + }); + + it('reputation should default to 0', () => { + const col = getMetadataArgsStorage().columns.find( + (c) => c.target === User && c.propertyName === 'reputation', + ); + expect(col?.options?.default).toBe(0); + }); + }); + + describe('relation coverage', () => { + it('User entity has a wallets OneToMany relation', () => { + const relations = getMetadataArgsStorage().relations.filter( + (r) => r.target === User, + ); + const walletsRelation = relations.find((r) => r.propertyName === 'wallets'); + expect(walletsRelation).toBeDefined(); + expect(walletsRelation?.relationType).toBe('one-to-many'); + }); + }); +}); diff --git a/src/entities/user.entity.ts b/src/entities/user.entity.ts index 50c8b26..d5b6ce0 100644 --- a/src/entities/user.entity.ts +++ b/src/entities/user.entity.ts @@ -8,40 +8,25 @@ import { Index, } from 'typeorm'; -/** - * User Entity - * - * Represents a verified user in the TruthBounty protocol. - * Users can link multiple wallets across different chains. - * Reputation is tracked to weight verification votes. - */ @Entity('users') export class User { @PrimaryGeneratedColumn('uuid') id: string; - /** - * Primary wallet address for the user - * This is the canonical identifier for the user - */ @Column({ unique: true }) @Index() walletAddress: string; - /** - * User's reputation score (0-100) - * Used to weight verification votes - * Increases with accurate verifications, decreases with inaccurate ones - */ @Column({ type: 'int', default: 0 }) reputation: number; - /** - * All wallets linked to this user across different chains - */ - @OneToMany('Wallet', 'user', { - cascade: true, - }) + @Column({ default: false }) + worldcoinVerified: boolean; + + @Column({ type: 'datetime', nullable: true }) + worldcoinVerifiedAt: Date | null; + + @OneToMany('Wallet', 'user', { cascade: true }) wallets: any[]; @CreateDateColumn() diff --git a/src/generated/client/internal/class.ts b/src/generated/client/internal/class.ts index e9333db..c617b22 100644 --- a/src/generated/client/internal/class.ts +++ b/src/generated/client/internal/class.ts @@ -213,6 +213,21 @@ export interface PrismaClient< * ``` */ get sybilScore(): Prisma.SybilScoreDelegate; + + /** + * `prisma.sybilExplanation`: Exposes CRUD operations for the **SybilExplanation** model. + */ + get sybilExplanation(): any; + + /** + * `prisma.worldIdVerification`: Exposes CRUD operations for the **WorldIdVerification** model. + * Example usage: + * ```ts + * // Fetch zero or more WorldIdVerifications + * const verifications = await prisma.worldIdVerification.findMany() + * ``` + */ + get worldIdVerification(): any; } export function getPrismaClientClass(): PrismaClientConstructor { diff --git a/src/identity/worldcoin/worldcoin.service.spec.ts b/src/identity/worldcoin/worldcoin.service.spec.ts index f890cb6..d95c5da 100644 --- a/src/identity/worldcoin/worldcoin.service.spec.ts +++ b/src/identity/worldcoin/worldcoin.service.spec.ts @@ -4,11 +4,21 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { WorldcoinService } from './worldcoin.service'; import { WorldIdVerification } from './entities/world-id-verification.entity'; +import { PrismaService } from '../../prisma/prisma.service'; +import { SybilResistanceService } from '../../sybil-resistance/sybil-resistance.service'; + +// Prevent native @libsql binaries from loading during unit tests +jest.mock('../../prisma/prisma.service', () => ({ PrismaService: jest.fn() })); +jest.mock('../../sybil-resistance/sybil-resistance.service', () => ({ + SybilResistanceService: jest.fn(), +})); describe('WorldcoinService', () => { let service: WorldcoinService; let repository: jest.Mocked>; let configService: jest.Mocked; + let prisma: any; + let sybilResistanceService: jest.Mocked; let fetchMock: jest.Mock; const mockRepository = { @@ -21,6 +31,21 @@ describe('WorldcoinService', () => { get: jest.fn(), }; + const mockPrisma = { + worldIdVerification: { + findUnique: jest.fn(), + findFirst: jest.fn(), + create: jest.fn(), + }, + user: { + update: jest.fn(), + }, + }; + + const mockSybilResistanceService = { + recordSybilScore: jest.fn(), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -33,21 +58,30 @@ describe('WorldcoinService', () => { provide: ConfigService, useValue: mockConfigService, }, + { + provide: PrismaService, + useValue: mockPrisma, + }, + { + provide: SybilResistanceService, + useValue: mockSybilResistanceService, + }, ], }).compile(); service = module.get(WorldcoinService); repository = module.get(getRepositoryToken(WorldIdVerification)); configService = module.get(ConfigService); + prisma = module.get(PrismaService); + sybilResistanceService = module.get(SybilResistanceService) as jest.Mocked; fetchMock = jest.fn(); global.fetch = fetchMock as typeof fetch; - // Setup default config values configService.get.mockImplementation((key: string) => { - const config = { - 'WORLDCOIN_APP_ID': 'test-app-id', - 'WORLDCOIN_ACTION': 'test-action', - 'WORLDCOIN_VERIFY_BASE_URL': 'https://developer.worldcoin.org/api/v2/verify', + const config: Record = { + WORLDCOIN_APP_ID: 'test-app-id', + WORLDCOIN_ACTION: 'test-action', + WORLDCOIN_VERIFY_BASE_URL: 'https://developer.worldcoin.org/api/v2/verify', }; return config[key]; }); @@ -59,6 +93,10 @@ describe('WorldcoinService', () => { }); }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('should be defined', () => { expect(service).toBeDefined(); }); @@ -77,10 +115,9 @@ describe('WorldcoinService', () => { }; it('should successfully verify a valid Worldcoin proof', async () => { - // Mock no existing verification repository.findOne.mockResolvedValue(null); - - // Mock created verification + prisma.worldIdVerification.findUnique.mockResolvedValue(null); + const mockVerification = { id: 'verification-id', userId, @@ -90,9 +127,12 @@ describe('WorldcoinService', () => { worldcoinAction: verifyDto.action, verifiedAt: new Date(), }; - + repository.create.mockReturnValue(mockVerification); repository.save.mockResolvedValue(mockVerification); + prisma.worldIdVerification.create.mockResolvedValue(mockVerification); + prisma.user.update.mockResolvedValue({}); + sybilResistanceService.recordSybilScore.mockResolvedValue({ compositeScore: 0.5 }); const result = await service.verifyProof(userId, verifyDto); @@ -101,9 +141,7 @@ describe('WorldcoinService', () => { 'https://developer.worldcoin.org/api/v2/verify/test-app-id', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...verifyDto.proof, action: verifyDto.action, @@ -125,21 +163,96 @@ describe('WorldcoinService', () => { }); }); - it('should throw ConflictException for duplicate nullifier hash', async () => { - // Mock existing verification + it('should write verification to both TypeORM and Prisma (dual-write sync)', async () => { + repository.findOne.mockResolvedValue(null); + prisma.worldIdVerification.findUnique.mockResolvedValue(null); + + const mockVerification = { + id: 'ver-1', + userId, + nullifierHash: verifyDto.proof.nullifier_hash, + verifiedAt: new Date(), + } as unknown as WorldIdVerification; + + repository.create.mockReturnValue(mockVerification); + repository.save.mockResolvedValue(mockVerification); + prisma.worldIdVerification.create.mockResolvedValue(mockVerification); + prisma.user.update.mockResolvedValue({}); + sybilResistanceService.recordSybilScore.mockResolvedValue({ compositeScore: 0.5 }); + + await service.verifyProof(userId, verifyDto); + + // Verify both stores were written + expect(repository.save).toHaveBeenCalledTimes(1); + expect(prisma.worldIdVerification.create).toHaveBeenCalledTimes(1); + expect(prisma.worldIdVerification.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId, + nullifierHash: verifyDto.proof.nullifier_hash, + }), + }), + ); + }); + + it('should set worldcoinVerified=true on User via Prisma after successful verification', async () => { + repository.findOne.mockResolvedValue(null); + prisma.worldIdVerification.findUnique.mockResolvedValue(null); + + const mockVerification = { id: 'ver-1', userId, nullifierHash: verifyDto.proof.nullifier_hash } as unknown as WorldIdVerification; + repository.create.mockReturnValue(mockVerification); + repository.save.mockResolvedValue(mockVerification); + prisma.worldIdVerification.create.mockResolvedValue(mockVerification); + prisma.user.update.mockResolvedValue({}); + sybilResistanceService.recordSybilScore.mockResolvedValue({ compositeScore: 0.5 }); + + await service.verifyProof(userId, verifyDto); + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: userId }, + data: { worldcoinVerified: true }, + }); + }); + + it('should trigger sybil score recalculation after verification', async () => { + repository.findOne.mockResolvedValue(null); + prisma.worldIdVerification.findUnique.mockResolvedValue(null); + + const mockVerification = { id: 'ver-1', userId } as unknown as WorldIdVerification; + repository.create.mockReturnValue(mockVerification); + repository.save.mockResolvedValue(mockVerification); + prisma.worldIdVerification.create.mockResolvedValue(mockVerification); + prisma.user.update.mockResolvedValue({}); + sybilResistanceService.recordSybilScore.mockResolvedValue({ compositeScore: 0.5 }); + + await service.verifyProof(userId, verifyDto); + + expect(sybilResistanceService.recordSybilScore).toHaveBeenCalledWith(userId); + }); + + it('should throw ConflictException when nullifier hash exists in TypeORM store', async () => { repository.findOne.mockResolvedValue({ - id: 'existing-verification', + id: 'existing', userId: 'other-user', nullifierHash: verifyDto.proof.nullifier_hash, } as WorldIdVerification); + prisma.worldIdVerification.findUnique.mockResolvedValue(null); await expect(service.verifyProof(userId, verifyDto)).rejects.toThrow( 'This Worldcoin proof has already been used', ); + }); - expect(repository.findOne).toHaveBeenCalledWith({ - where: { nullifierHash: verifyDto.proof.nullifier_hash }, + it('should throw ConflictException when nullifier hash exists in Prisma store', async () => { + repository.findOne.mockResolvedValue(null); + prisma.worldIdVerification.findUnique.mockResolvedValue({ + id: 'existing-prisma', + nullifierHash: verifyDto.proof.nullifier_hash, }); + + await expect(service.verifyProof(userId, verifyDto)).rejects.toThrow( + 'This Worldcoin proof has already been used', + ); }); it('should throw BadRequestException for invalid proof', async () => { @@ -148,7 +261,6 @@ describe('WorldcoinService', () => { status: 200, json: jest.fn().mockResolvedValue({ success: false }), }); - repository.findOne.mockResolvedValue(null); await expect(service.verifyProof(userId, verifyDto)).rejects.toThrow( @@ -160,10 +272,7 @@ describe('WorldcoinService', () => { repository.findOne.mockResolvedValue(null); await expect( - service.verifyProof(userId, { - ...verifyDto, - action: 'unexpected-action', - }), + service.verifyProof(userId, { ...verifyDto, action: 'unexpected-action' }), ).rejects.toThrow('Invalid Worldcoin proof'); expect(fetchMock).not.toHaveBeenCalled(); @@ -193,18 +302,30 @@ describe('WorldcoinService', () => { }); describe('isUserVerified', () => { - it('should return true for verified user', async () => { + it('should return true when TypeORM store has a verification record', async () => { const userId = 'test-user-123'; repository.findOne.mockResolvedValue({ id: 'verification-id' } as WorldIdVerification); + prisma.worldIdVerification.findFirst.mockResolvedValue(null); + + const result = await service.isUserVerified(userId); + + expect(result).toBe(true); + }); + + it('should return true when Prisma store has a verification record', async () => { + const userId = 'test-user-123'; + repository.findOne.mockResolvedValue(null); + prisma.worldIdVerification.findFirst.mockResolvedValue({ id: 'prisma-ver' }); const result = await service.isUserVerified(userId); expect(result).toBe(true); }); - it('should return false for unverified user', async () => { + it('should return false when neither store has a verification record', async () => { const userId = 'test-user-123'; repository.findOne.mockResolvedValue(null); + prisma.worldIdVerification.findFirst.mockResolvedValue(null); const result = await service.isUserVerified(userId); diff --git a/src/modules/users/entities/user.entity.ts b/src/modules/users/entities/user.entity.ts index a035818..2672156 100644 --- a/src/modules/users/entities/user.entity.ts +++ b/src/modules/users/entities/user.entity.ts @@ -1,13 +1,2 @@ -import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; - -@Entity('users') -export class UserEntity { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ default: false }) - worldcoinVerified: boolean; - - @Column({ type: 'timestamp', nullable: true }) - worldcoinVerifiedAt: Date | null; -} \ No newline at end of file +// Re-export the canonical User entity so module-local imports keep working. +export { User as UserEntity } from '../../../entities/user.entity';