diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 459c72e9..1b7d646a 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -21,6 +21,7 @@ import { BackupModule } from './backup/backup.module'; import { AnalyticsModule } from './analytics/analytics.module'; import { PermissionsModule } from './permissions/permissions.module'; import { CredentialsModule } from './credentials/credentials.module'; +import { HealthAuthorityModule } from './health-authority/health-authority.module'; @Module({ imports: [ @@ -79,6 +80,7 @@ import { CredentialsModule } from './credentials/credentials.module'; AnalyticsModule, PermissionsModule, CredentialsModule, + HealthAuthorityModule, ], providers: [ { diff --git a/backend/src/health-authority/dto/connect-authority.dto.ts b/backend/src/health-authority/dto/connect-authority.dto.ts new file mode 100644 index 00000000..fa96a9c5 --- /dev/null +++ b/backend/src/health-authority/dto/connect-authority.dto.ts @@ -0,0 +1,48 @@ +import { IsString, IsNotEmpty, IsUrl, IsOptional, IsEnum } from 'class-validator'; + +export enum AuthorityAuthType { + API_KEY = 'api_key', + OAUTH2 = 'oauth2', + MUTUAL_TLS = 'mutual_tls', + JWT_BEARER = 'jwt_bearer', +} + +export class ConnectAuthorityDto { + @IsString() + @IsNotEmpty() + name: string; + + @IsUrl() + @IsNotEmpty() + apiUrl: string; + + @IsEnum(AuthorityAuthType) + authType: AuthorityAuthType; + + @IsString() + @IsOptional() + apiKey?: string; + + @IsString() + @IsOptional() + clientId?: string; + + @IsString() + @IsOptional() + clientSecret?: string; + + @IsString() + @IsOptional() + tokenUrl?: string; + + @IsString() + @IsOptional() + certificatePath?: string; + + @IsString() + @IsOptional() + jurisdiction?: string; + + @IsOptional() + metadata?: Record; +} diff --git a/backend/src/health-authority/dto/index.ts b/backend/src/health-authority/dto/index.ts new file mode 100644 index 00000000..f3f0897c --- /dev/null +++ b/backend/src/health-authority/dto/index.ts @@ -0,0 +1,3 @@ +export * from './connect-authority.dto'; +export * from './request-credential.dto'; +export * from './update-authority.dto'; diff --git a/backend/src/health-authority/dto/request-credential.dto.ts b/backend/src/health-authority/dto/request-credential.dto.ts new file mode 100644 index 00000000..e0a81144 --- /dev/null +++ b/backend/src/health-authority/dto/request-credential.dto.ts @@ -0,0 +1,30 @@ +import { IsString, IsNotEmpty, IsOptional, IsDateString, IsObject } from 'class-validator'; + +export class RequestCredentialDto { + @IsString() + @IsNotEmpty() + authorityId: string; + + @IsString() + @IsNotEmpty() + credentialType: string; + + @IsString() + @IsNotEmpty() + patientWalletAddress: string; + + @IsObject() + @IsNotEmpty() + healthData: Record; + + @IsDateString() + @IsOptional() + expirationDate?: string; + + @IsString() + @IsOptional() + issuerNotes?: string; + + @IsOptional() + metadata?: Record; +} diff --git a/backend/src/health-authority/dto/update-authority.dto.ts b/backend/src/health-authority/dto/update-authority.dto.ts new file mode 100644 index 00000000..77f80831 --- /dev/null +++ b/backend/src/health-authority/dto/update-authority.dto.ts @@ -0,0 +1,20 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { ConnectAuthorityDto } from './connect-authority.dto'; +import { IsString, IsOptional, IsEnum } from 'class-validator'; + +export enum AuthorityStatus { + ACTIVE = 'active', + INACTIVE = 'inactive', + SUSPENDED = 'suspended', + PENDING_VERIFICATION = 'pending_verification', +} + +export class UpdateAuthorityDto extends PartialType(ConnectAuthorityDto) { + @IsEnum(AuthorityStatus) + @IsOptional() + status?: AuthorityStatus; + + @IsString() + @IsOptional() + name?: string; +} diff --git a/backend/src/health-authority/health-authority-api.client.spec.ts b/backend/src/health-authority/health-authority-api.client.spec.ts new file mode 100644 index 00000000..56f3fd36 --- /dev/null +++ b/backend/src/health-authority/health-authority-api.client.spec.ts @@ -0,0 +1,88 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { HealthAuthorityApiClient } from './health-authority-api.client'; +import { HealthAuthority, AuthorityAuthType, AuthorityStatus } from './health-authority.entity'; +import { CredentialFormat, IssuanceStatus } from './issuance-record.entity'; + +describe('HealthAuthorityApiClient', () => { + let client: HealthAuthorityApiClient; + + const mockAuthority: HealthAuthority = { + id: 'auth-1', + name: 'Test Authority', + apiUrl: 'https://api.test.com', + authType: AuthorityAuthType.API_KEY, + status: AuthorityStatus.ACTIVE, + apiKey: 'test-key', + clientId: 'client-id', + clientSecret: 'client-secret', + tokenUrl: '/oauth/token', + certificatePath: null, + jurisdiction: 'US', + accessToken: null, + tokenExpiresAt: null, + metadata: {}, + credentialsIssued: 0, + lastConnectedAt: new Date(), + lastError: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [HealthAuthorityApiClient], + }).compile(); + + client = module.get(HealthAuthorityApiClient); + }); + + it('should be defined', () => { + expect(client).toBeDefined(); + }); + + describe('clearClient', () => { + it('should clear cached client for an authority', () => { + expect(() => client.clearClient('auth-1')).not.toThrow(); + }); + }); + + describe('parseCredentialResponse', () => { + it('should parse issued status correctly', () => { + const method = (client as any).parseCredentialResponse.bind(client); + const result = method( + { + requestId: 'req-1', + status: 'issued', + credential: { type: 'VaccinationCredential' }, + issuedAt: '2024-01-01T00:00:00Z', + }, + CredentialFormat.CUSTOM_JSON, + ); + + expect(result.requestId).toBe('req-1'); + expect(result.status).toBe(IssuanceStatus.ISSUED); + expect(result.credential).toEqual({ type: 'VaccinationCredential' }); + }); + + it('should parse pending status correctly', () => { + const method = (client as any).parseCredentialResponse.bind(client); + const result = method( + { requestId: 'req-2', status: 'pending' }, + CredentialFormat.FHIR, + ); + + expect(result.status).toBe(IssuanceStatus.PENDING); + expect(result.format).toBe(CredentialFormat.FHIR); + }); + + it('should default to processing for unknown status', () => { + const method = (client as any).parseCredentialResponse.bind(client); + const result = method( + { requestId: 'req-3', status: 'unknown' }, + CredentialFormat.W3C_VC, + ); + + expect(result.status).toBe(IssuanceStatus.PROCESSING); + }); + }); +}); diff --git a/backend/src/health-authority/health-authority-api.client.ts b/backend/src/health-authority/health-authority-api.client.ts new file mode 100644 index 00000000..0f4fa039 --- /dev/null +++ b/backend/src/health-authority/health-authority-api.client.ts @@ -0,0 +1,213 @@ +import { Injectable, Logger } from '@nestjs/common'; +import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; +import { HealthAuthority, AuthorityAuthType } from './health-authority.entity'; +import { CredentialFormat, IssuanceStatus } from './issuance-record.entity'; + +export interface CredentialIssuanceRequest { + credentialType: string; + patientId: string; + healthData: Record; + expirationDate?: string; + format: CredentialFormat; +} + +export interface AuthorityCredentialResponse { + requestId: string; + status: IssuanceStatus; + credential?: Record; + format: CredentialFormat; + issuedAt: string; + expiresAt?: string; + signature?: string; + rawResponse: Record; +} + +@Injectable() +export class HealthAuthorityApiClient { + private readonly logger = new Logger(HealthAuthorityApiClient.name); + private readonly clients: Map = new Map(); + + private getClient(authority: HealthAuthority): AxiosInstance { + const cached = this.clients.get(authority.id); + if (cached) return cached; + + const config: AxiosRequestConfig = { + baseURL: authority.apiUrl, + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'ValidFi-HealthCredential/1.0', + }, + }; + + const client = axios.create(config); + + client.interceptors.request.use((req) => { + this.logger.debug(`API Request: ${req.method?.toUpperCase()} ${req.baseURL}${req.url}`); + return req; + }); + + client.interceptors.response.use( + (res) => { + this.logger.debug(`API Response: ${res.status} from ${res.config.url}`); + return res; + }, + (error) => { + this.logger.error(`API Error: ${error.message}`, error.stack); + throw error; + }, + ); + + this.clients.set(authority.id, client); + return client; + } + + async authenticate(authority: HealthAuthority): Promise { + const client = this.getClient(authority); + + switch (authority.authType) { + case AuthorityAuthType.API_KEY: + client.defaults.headers['Authorization'] = `Bearer ${authority.apiKey}`; + return authority.apiKey; + + case AuthorityAuthType.OAUTH2: + return await this.authenticateOAuth2(authority, client); + + case AuthorityAuthType.JWT_BEARER: + return await this.authenticateJwtBearer(authority, client); + + case AuthorityAuthType.MUTUAL_TLS: + this.logger.log(`mTLS authentication configured for authority ${authority.id}`); + return 'mtls-configured'; + + default: + throw new Error(`Unsupported auth type: ${authority.authType}`); + } + } + + private async authenticateOAuth2( + authority: HealthAuthority, + client: AxiosInstance, + ): Promise { + try { + const response = await client.post(authority.tokenUrl || '/oauth/token', { + grant_type: 'client_credentials', + client_id: authority.clientId, + client_secret: authority.clientSecret, + scope: 'credential:issue credential:verify', + }); + + const { access_token, expires_in } = response.data; + client.defaults.headers['Authorization'] = `Bearer ${access_token}`; + + this.logger.log(`OAuth2 token obtained for authority ${authority.id}, expires in ${expires_in}s`); + return access_token; + } catch (error) { + this.logger.error(`OAuth2 authentication failed for authority ${authority.id}: ${error.message}`); + throw new Error(`OAuth2 authentication failed: ${error.message}`); + } + } + + private async authenticateJwtBearer( + authority: HealthAuthority, + client: AxiosInstance, + ): Promise { + try { + const response = await client.post(authority.tokenUrl || '/auth/token', { + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: authority.apiKey, + }); + + const { access_token } = response.data; + client.defaults.headers['Authorization'] = `Bearer ${access_token}`; + return access_token; + } catch (error) { + this.logger.error(`JWT Bearer auth failed for authority ${authority.id}: ${error.message}`); + throw new Error(`JWT Bearer authentication failed: ${error.message}`); + } + } + + async checkHealth(authority: HealthAuthority): Promise { + try { + const client = this.getClient(authority); + const response = await client.get('/health', { timeout: 5000 }); + return response.status === 200; + } catch { + return false; + } + } + + async requestCredentialIssuance( + authority: HealthAuthority, + request: CredentialIssuanceRequest, + ): Promise { + const client = this.getClient(authority); + + try { + const response = await client.post('/credentials/issue', { + type: request.credentialType, + subject: { + patientId: request.patientId, + }, + claims: request.healthData, + format: request.format, + expirationDate: request.expirationDate, + }); + + return this.parseCredentialResponse(response.data, request.format); + } catch (error) { + if (error.response) { + this.logger.error( + `Credential issuance failed: ${error.response.status} - ${JSON.stringify(error.response.data)}`, + ); + throw new Error( + `Authority API error (${error.response.status}): ${error.response.data?.message || 'Unknown error'}`, + ); + } + throw error; + } + } + + async getCredentialStatus( + authority: HealthAuthority, + requestId: string, + ): Promise { + const client = this.getClient(authority); + + try { + const response = await client.get(`/credentials/status/${requestId}`); + return this.parseCredentialResponse(response.data, CredentialFormat.CUSTOM_JSON); + } catch (error) { + this.logger.error(`Status check failed for request ${requestId}: ${error.message}`); + throw error; + } + } + + private parseCredentialResponse( + data: Record, + format: CredentialFormat, + ): AuthorityCredentialResponse { + const statusMap: Record = { + issued: IssuanceStatus.ISSUED, + pending: IssuanceStatus.PENDING, + processing: IssuanceStatus.PROCESSING, + failed: IssuanceStatus.FAILED, + revoked: IssuanceStatus.REVOKED, + }; + + return { + requestId: data.requestId || data.request_id || data.id, + status: statusMap[data.status?.toLowerCase()] || IssuanceStatus.PROCESSING, + credential: data.credential || data.verifiableCredential, + format, + issuedAt: data.issuedAt || data.issued_at || new Date().toISOString(), + expiresAt: data.expiresAt || data.expires_at, + signature: data.signature, + rawResponse: data, + }; + } + + clearClient(authorityId: string): void { + this.clients.delete(authorityId); + } +} diff --git a/backend/src/health-authority/health-authority.controller.spec.ts b/backend/src/health-authority/health-authority.controller.spec.ts new file mode 100644 index 00000000..8a613cf7 --- /dev/null +++ b/backend/src/health-authority/health-authority.controller.spec.ts @@ -0,0 +1,112 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { HealthAuthorityController } from './health-authority.controller'; +import { HealthAuthorityService } from './health-authority.service'; +import { AuthorityAuthType, AuthorityStatus } from './health-authority.entity'; +import { IssuanceStatus } from './issuance-record.entity'; + +const mockService = () => ({ + connectAuthority: jest.fn(), + findAll: jest.fn(), + findOne: jest.fn(), + findActive: jest.fn(), + update: jest.fn(), + disconnect: jest.fn(), + reconnect: jest.fn(), + requestCredential: jest.fn(), + checkIssuanceStatus: jest.fn(), + findIssuancesByWallet: jest.fn(), + findIssuancesByAuthority: jest.fn(), + retryIssuance: jest.fn(), + revokeIssuance: jest.fn(), +}); + +describe('HealthAuthorityController', () => { + let controller: HealthAuthorityController; + let service: jest.Mocked>; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [HealthAuthorityController], + providers: [{ provide: HealthAuthorityService, useFactory: mockService }], + }).compile(); + + controller = module.get(HealthAuthorityController); + service = module.get(HealthAuthorityService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('connect', () => { + it('should connect an authority', async () => { + const dto = { + name: 'Test', + apiUrl: 'https://test.com', + authType: AuthorityAuthType.API_KEY, + }; + service.connectAuthority.mockResolvedValue({ + id: 'auth-1', + ...dto, + status: AuthorityStatus.ACTIVE, + } as any); + + const result = await controller.connect(dto); + expect(result.status).toBe(AuthorityStatus.ACTIVE); + }); + }); + + describe('findAll', () => { + it('should return all authorities', async () => { + service.findAll.mockResolvedValue([]); + const result = await controller.findAll(); + expect(result).toEqual([]); + }); + }); + + describe('requestCredential', () => { + it('should request credential issuance', async () => { + const dto = { + authorityId: 'auth-1', + credentialType: 'vaccination', + patientWalletAddress: 'GABC123', + healthData: { vaccine: 'COVID-19' }, + }; + service.requestCredential.mockResolvedValue({ + id: 'issuance-1', + status: IssuanceStatus.PENDING, + } as any); + + const result = await controller.requestCredential(dto); + expect(result.status).toBe(IssuanceStatus.PENDING); + }); + }); + + describe('disconnect', () => { + it('should disconnect an authority', async () => { + service.disconnect.mockResolvedValue({ status: AuthorityStatus.INACTIVE } as any); + const result = await controller.disconnect('auth-1'); + expect(result.status).toBe(AuthorityStatus.INACTIVE); + }); + }); + + describe('retryIssuance', () => { + it('should retry a failed issuance', async () => { + service.retryIssuance.mockResolvedValue({ + status: IssuanceStatus.PENDING, + } as any); + const result = await controller.retryIssuance('issuance-1'); + expect(result.status).toBe(IssuanceStatus.PENDING); + }); + }); + + describe('revokeIssuance', () => { + it('should revoke an issuance', async () => { + service.revokeIssuance.mockResolvedValue({ + status: IssuanceStatus.REVOKED, + } as any); + const result = await controller.revokeIssuance('issuance-1'); + expect(result.status).toBe(IssuanceStatus.REVOKED); + }); + }); +}); diff --git a/backend/src/health-authority/health-authority.controller.ts b/backend/src/health-authority/health-authority.controller.ts new file mode 100644 index 00000000..ab3671aa --- /dev/null +++ b/backend/src/health-authority/health-authority.controller.ts @@ -0,0 +1,104 @@ +import { + Controller, + Get, + Post, + Put, + Patch, + Delete, + Body, + Param, + Query, + UseGuards, + UseInterceptors, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { HealthAuthorityService } from './health-authority.service'; +import { ConnectAuthorityDto } from './dto/connect-authority.dto'; +import { RequestCredentialDto } from './dto/request-credential.dto'; +import { UpdateAuthorityDto } from './dto/update-authority.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { AuditInterceptor } from '../audit/audit.interceptor'; +import { Audit } from '../audit/audit.decorator'; +import { AuditOperation } from '../audit/audit-log.entity'; + +@Controller('health-authorities') +@UseGuards(JwtAuthGuard) +@UseInterceptors(AuditInterceptor) +export class HealthAuthorityController { + constructor(private readonly healthAuthorityService: HealthAuthorityService) {} + + @Post('connect') + @Audit(AuditOperation.CREATED) + connect(@Body() dto: ConnectAuthorityDto) { + return this.healthAuthorityService.connectAuthority(dto); + } + + @Get() + findAll() { + return this.healthAuthorityService.findAll(); + } + + @Get('active') + findActive() { + return this.healthAuthorityService.findActive(); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.healthAuthorityService.findOne(id); + } + + @Put(':id') + update(@Param('id') id: string, @Body() dto: UpdateAuthorityDto) { + return this.healthAuthorityService.update(id, dto); + } + + @Post(':id/verify') + verify(@Param('id') id: string) { + return this.healthAuthorityService.reconnect(id); + } + + @Delete(':id/disconnect') + @HttpCode(HttpStatus.OK) + disconnect(@Param('id') id: string) { + return this.healthAuthorityService.disconnect(id); + } + + @Post(':id/reconnect') + reconnect(@Param('id') id: string) { + return this.healthAuthorityService.reconnect(id); + } + + @Post('credentials/request') + @Audit(AuditOperation.CREATED) + requestCredential(@Body() dto: RequestCredentialDto) { + return this.healthAuthorityService.requestCredential(dto); + } + + @Get('credentials/:id') + checkIssuanceStatus(@Param('id') id: string) { + return this.healthAuthorityService.checkIssuanceStatus(id); + } + + @Get('credentials/wallet/:walletAddress') + findIssuancesByWallet(@Param('walletAddress') walletAddress: string) { + return this.healthAuthorityService.findIssuancesByWallet(walletAddress); + } + + @Get('credentials/authority/:authorityId') + findIssuancesByAuthority(@Param('authorityId') authorityId: string) { + return this.healthAuthorityService.findIssuancesByAuthority(authorityId); + } + + @Post('credentials/:id/retry') + retryIssuance(@Param('id') id: string) { + return this.healthAuthorityService.retryIssuance(id); + } + + @Patch('credentials/:id/revoke') + @Audit(AuditOperation.UPDATED) + revokeIssuance(@Param('id') id: string) { + return this.healthAuthorityService.revokeIssuance(id); + } +} diff --git a/backend/src/health-authority/health-authority.entity.ts b/backend/src/health-authority/health-authority.entity.ts new file mode 100644 index 00000000..e1f07dbf --- /dev/null +++ b/backend/src/health-authority/health-authority.entity.ts @@ -0,0 +1,83 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum AuthorityAuthType { + API_KEY = 'api_key', + OAUTH2 = 'oauth2', + MUTUAL_TLS = 'mutual_tls', + JWT_BEARER = 'jwt_bearer', +} + +export enum AuthorityStatus { + ACTIVE = 'active', + INACTIVE = 'inactive', + SUSPENDED = 'suspended', + PENDING_VERIFICATION = 'pending_verification', +} + +@Entity('health_authorities') +export class HealthAuthority { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column() + name: string; + + @Column() + apiUrl: string; + + @Column({ type: 'enum', enum: AuthorityAuthType }) + authType: AuthorityAuthType; + + @Column({ type: 'enum', enum: AuthorityStatus, default: AuthorityStatus.PENDING_VERIFICATION }) + status: AuthorityStatus; + + @Column({ nullable: true }) + apiKey: string; + + @Column({ nullable: true }) + clientId: string; + + @Column({ nullable: true }) + clientSecret: string; + + @Column({ nullable: true }) + tokenUrl: string; + + @Column({ nullable: true }) + certificatePath: string; + + @Column({ nullable: true }) + jurisdiction: string; + + @Column({ nullable: true }) + accessToken: string; + + @Column({ type: 'timestamptz', nullable: true }) + tokenExpiresAt: Date; + + @Column({ type: 'json', nullable: true }) + metadata: Record; + + @Column({ default: 0 }) + credentialsIssued: number; + + @Column({ type: 'timestamptz', nullable: true }) + lastConnectedAt: Date; + + @Column({ nullable: true }) + lastError: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/health-authority/health-authority.module.ts b/backend/src/health-authority/health-authority.module.ts new file mode 100644 index 00000000..c323adcb --- /dev/null +++ b/backend/src/health-authority/health-authority.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { HealthAuthority } from './health-authority.entity'; +import { IssuanceRecord } from './issuance-record.entity'; +import { HealthAuthorityService } from './health-authority.service'; +import { HealthAuthorityController } from './health-authority.controller'; +import { HealthAuthorityApiClient } from './health-authority-api.client'; + +@Module({ + imports: [TypeOrmModule.forFeature([HealthAuthority, IssuanceRecord])], + controllers: [HealthAuthorityController], + providers: [HealthAuthorityService, HealthAuthorityApiClient], + exports: [HealthAuthorityService, HealthAuthorityApiClient], +}) +export class HealthAuthorityModule {} diff --git a/backend/src/health-authority/health-authority.service.spec.ts b/backend/src/health-authority/health-authority.service.spec.ts new file mode 100644 index 00000000..54659570 --- /dev/null +++ b/backend/src/health-authority/health-authority.service.spec.ts @@ -0,0 +1,295 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { HealthAuthorityService } from './health-authority.service'; +import { HealthAuthority, AuthorityStatus, AuthorityAuthType } from './health-authority.entity'; +import { IssuanceRecord, IssuanceStatus, CredentialFormat } from './issuance-record.entity'; +import { HealthAuthorityApiClient } from './health-authority-api.client'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; + +const mockAuthorityRepository = () => ({ + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), +}); + +const mockIssuanceRepository = () => ({ + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), +}); + +const mockApiClient = () => ({ + authenticate: jest.fn(), + checkHealth: jest.fn(), + requestCredentialIssuance: jest.fn(), + getCredentialStatus: jest.fn(), + clearClient: jest.fn(), +}); + +describe('HealthAuthorityService', () => { + let service: HealthAuthorityService; + let authorityRepo: jest.Mocked>; + let issuanceRepo: jest.Mocked>; + let apiClient: jest.Mocked; + + const mockAuthority: HealthAuthority = { + id: 'auth-1', + name: 'Test Health Authority', + apiUrl: 'https://api.healthauthority.test', + authType: AuthorityAuthType.API_KEY, + status: AuthorityStatus.ACTIVE, + apiKey: 'test-api-key', + clientId: null, + clientSecret: null, + tokenUrl: null, + certificatePath: null, + jurisdiction: 'US', + accessToken: null, + tokenExpiresAt: null, + metadata: {}, + credentialsIssued: 0, + lastConnectedAt: new Date(), + lastError: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockIssuance: IssuanceRecord = { + id: 'issuance-1', + authorityId: 'auth-1', + authority: mockAuthority, + patientWalletAddress: 'GABC123', + credentialType: 'vaccination', + format: CredentialFormat.CUSTOM_JSON, + status: IssuanceStatus.PENDING, + healthData: { vaccine: 'COVID-19', dose: 1 }, + issuedCredential: null, + credentialHash: null, + externalRequestId: null, + expirationDate: null, + failureReason: null, + retryCount: 0, + issuerNotes: null, + metadata: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + HealthAuthorityService, + { provide: getRepositoryToken(HealthAuthority), useFactory: mockAuthorityRepository }, + { provide: getRepositoryToken(IssuanceRecord), useFactory: mockIssuanceRepository }, + { provide: HealthAuthorityApiClient, useFactory: mockApiClient }, + ], + }).compile(); + + service = module.get(HealthAuthorityService); + authorityRepo = module.get(getRepositoryToken(HealthAuthority)); + issuanceRepo = module.get(getRepositoryToken(IssuanceRecord)); + apiClient = module.get(HealthAuthorityApiClient); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('connectAuthority', () => { + it('should connect a new authority', async () => { + const dto = { + name: 'Test Authority', + apiUrl: 'https://api.test.com', + authType: AuthorityAuthType.API_KEY, + apiKey: 'test-key', + }; + + authorityRepo.findOne.mockResolvedValue(null); + authorityRepo.create.mockReturnValue(mockAuthority); + authorityRepo.save.mockResolvedValue(mockAuthority); + apiClient.authenticate.mockResolvedValue('token'); + apiClient.checkHealth.mockResolvedValue(true); + + const result = await service.connectAuthority(dto); + + expect(result).toBeDefined(); + expect(authorityRepo.create).toHaveBeenCalledWith(dto); + expect(authorityRepo.save).toHaveBeenCalled(); + }); + + it('should throw if authority already exists', async () => { + const dto = { + name: 'Test Authority', + apiUrl: 'https://api.test.com', + authType: AuthorityAuthType.API_KEY, + }; + + authorityRepo.findOne.mockResolvedValue(mockAuthority); + + await expect(service.connectAuthority(dto)).rejects.toThrow(BadRequestException); + }); + }); + + describe('findAll', () => { + it('should return all authorities', async () => { + authorityRepo.find.mockResolvedValue([mockAuthority]); + const result = await service.findAll(); + expect(result).toEqual([mockAuthority]); + }); + }); + + describe('findOne', () => { + it('should return a single authority', async () => { + authorityRepo.findOne.mockResolvedValue(mockAuthority); + const result = await service.findOne('auth-1'); + expect(result).toEqual(mockAuthority); + }); + + it('should throw NotFoundException if authority not found', async () => { + authorityRepo.findOne.mockResolvedValue(null); + await expect(service.findOne('nonexistent')).rejects.toThrow(NotFoundException); + }); + }); + + describe('findActive', () => { + it('should return only active authorities', async () => { + authorityRepo.find.mockResolvedValue([mockAuthority]); + const result = await service.findActive(); + expect(result).toEqual([mockAuthority]); + expect(authorityRepo.find).toHaveBeenCalledWith({ + where: { status: AuthorityStatus.ACTIVE }, + order: { name: 'ASC' }, + }); + }); + }); + + describe('disconnect', () => { + it('should disconnect an authority', async () => { + authorityRepo.findOne.mockResolvedValue(mockAuthority); + authorityRepo.save.mockResolvedValue({ ...mockAuthority, status: AuthorityStatus.INACTIVE }); + + const result = await service.disconnect('auth-1'); + + expect(result.status).toBe(AuthorityStatus.INACTIVE); + expect(apiClient.clearClient).toHaveBeenCalledWith('auth-1'); + }); + }); + + describe('reconnect', () => { + it('should reconnect an authority successfully', async () => { + authorityRepo.findOne.mockResolvedValue(mockAuthority); + apiClient.authenticate.mockResolvedValue('token'); + apiClient.checkHealth.mockResolvedValue(true); + authorityRepo.save.mockResolvedValue({ ...mockAuthority, status: AuthorityStatus.ACTIVE }); + + const result = await service.reconnect('auth-1'); + + expect(result.status).toBe(AuthorityStatus.ACTIVE); + }); + + it('should set inactive on reconnect failure', async () => { + authorityRepo.findOne.mockResolvedValue(mockAuthority); + apiClient.authenticate.mockRejectedValue(new Error('Auth failed')); + authorityRepo.save.mockResolvedValue({ + ...mockAuthority, + status: AuthorityStatus.INACTIVE, + lastError: 'Auth failed', + }); + + const result = await service.reconnect('auth-1'); + + expect(result.status).toBe(AuthorityStatus.INACTIVE); + }); + }); + + describe('requestCredential', () => { + it('should create a credential issuance request', async () => { + const dto = { + authorityId: 'auth-1', + credentialType: 'vaccination', + patientWalletAddress: 'GABC123', + healthData: { vaccine: 'COVID-19' }, + }; + + authorityRepo.findOne.mockResolvedValue(mockAuthority); + issuanceRepo.create.mockReturnValue(mockIssuance); + issuanceRepo.save.mockResolvedValue(mockIssuance); + + const result = await service.requestCredential(dto); + + expect(result).toBeDefined(); + expect(issuanceRepo.create).toHaveBeenCalled(); + }); + + it('should throw if authority is not active', async () => { + const dto = { + authorityId: 'auth-1', + credentialType: 'vaccination', + patientWalletAddress: 'GABC123', + healthData: { vaccine: 'COVID-19' }, + }; + + authorityRepo.findOne.mockResolvedValue({ + ...mockAuthority, + status: AuthorityStatus.INACTIVE, + }); + + await expect(service.requestCredential(dto)).rejects.toThrow(BadRequestException); + }); + }); + + describe('retryIssuance', () => { + it('should retry a failed issuance', async () => { + const failedIssuance = { + ...mockIssuance, + status: IssuanceStatus.FAILED, + authority: mockAuthority, + }; + + issuanceRepo.findOne.mockResolvedValue(failedIssuance); + issuanceRepo.save.mockResolvedValue({ + ...failedIssuance, + status: IssuanceStatus.PENDING, + retryCount: 0, + }); + + const result = await service.retryIssuance('issuance-1'); + + expect(result.status).toBe(IssuanceStatus.PENDING); + }); + + it('should throw if issuance is not failed', async () => { + issuanceRepo.findOne.mockResolvedValue(mockIssuance); + + await expect(service.retryIssuance('issuance-1')).rejects.toThrow(BadRequestException); + }); + }); + + describe('revokeIssuance', () => { + it('should revoke an issued credential', async () => { + const issuedIssuance = { + ...mockIssuance, + status: IssuanceStatus.ISSUED, + }; + + issuanceRepo.findOne.mockResolvedValue(issuedIssuance); + issuanceRepo.save.mockResolvedValue({ + ...issuedIssuance, + status: IssuanceStatus.REVOKED, + }); + + const result = await service.revokeIssuance('issuance-1'); + + expect(result.status).toBe(IssuanceStatus.REVOKED); + }); + + it('should throw if credential is not issued', async () => { + issuanceRepo.findOne.mockResolvedValue(mockIssuance); + + await expect(service.revokeIssuance('issuance-1')).rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/backend/src/health-authority/health-authority.service.ts b/backend/src/health-authority/health-authority.service.ts new file mode 100644 index 00000000..50f2d8d5 --- /dev/null +++ b/backend/src/health-authority/health-authority.service.ts @@ -0,0 +1,316 @@ +import { + Injectable, + Logger, + NotFoundException, + BadRequestException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { HealthAuthority, AuthorityStatus } from './health-authority.entity'; +import { IssuanceRecord, IssuanceStatus } from './issuance-record.entity'; +import { HealthAuthorityApiClient, CredentialIssuanceRequest } from './health-authority-api.client'; +import { ConnectAuthorityDto } from './dto/connect-authority.dto'; +import { RequestCredentialDto } from './dto/request-credential.dto'; +import { UpdateAuthorityDto } from './dto/update-authority.dto'; +import { createHash } from 'crypto'; + +const MAX_RETRIES = 3; +const RETRY_DELAY_MS = 1000; + +@Injectable() +export class HealthAuthorityService { + private readonly logger = new Logger(HealthAuthorityService.name); + + constructor( + @InjectRepository(HealthAuthority) + private readonly authorityRepository: Repository, + @InjectRepository(IssuanceRecord) + private readonly issuanceRepository: Repository, + private readonly apiClient: HealthAuthorityApiClient, + ) {} + + async connectAuthority(dto: ConnectAuthorityDto): Promise { + const existing = await this.authorityRepository.findOne({ + where: { name: dto.name, apiUrl: dto.apiUrl }, + }); + + if (existing) { + throw new BadRequestException('Authority with this name and URL already exists'); + } + + const authority = this.authorityRepository.create(dto); + authority.status = AuthorityStatus.PENDING_VERIFICATION; + + const saved = await this.authorityRepository.save(authority); + + try { + await this.verifyConnection(saved); + saved.status = AuthorityStatus.ACTIVE; + saved.lastConnectedAt = new Date(); + } catch (error) { + saved.status = AuthorityStatus.INACTIVE; + saved.lastError = error.message; + this.logger.warn(`Initial connection verification failed for authority ${saved.id}: ${error.message}`); + } + + return await this.authorityRepository.save(saved); + } + + async verifyConnection(authority: HealthAuthority): Promise { + try { + await this.apiClient.authenticate(authority); + const isHealthy = await this.apiClient.checkHealth(authority); + + if (!isHealthy) { + throw new Error('Health check endpoint returned unhealthy status'); + } + + return true; + } catch (error) { + this.logger.error(`Connection verification failed for authority ${authority.id}: ${error.message}`); + throw error; + } + } + + async findAll(): Promise { + return await this.authorityRepository.find({ order: { createdAt: 'DESC' } }); + } + + async findOne(id: string): Promise { + const authority = await this.authorityRepository.findOne({ where: { id } }); + if (!authority) { + throw new NotFoundException(`Health authority ${id} not found`); + } + return authority; + } + + async findActive(): Promise { + return await this.authorityRepository.find({ + where: { status: AuthorityStatus.ACTIVE }, + order: { name: 'ASC' }, + }); + } + + async update(id: string, dto: UpdateAuthorityDto): Promise { + const authority = await this.findOne(id); + Object.assign(authority, dto); + return await this.authorityRepository.save(authority); + } + + async disconnect(id: string): Promise { + const authority = await this.findOne(id); + authority.status = AuthorityStatus.INACTIVE; + authority.accessToken = null; + authority.tokenExpiresAt = null; + this.apiClient.clearClient(id); + return await this.authorityRepository.save(authority); + } + + async reconnect(id: string): Promise { + const authority = await this.findOne(id); + + try { + await this.verifyConnection(authority); + authority.status = AuthorityStatus.ACTIVE; + authority.lastConnectedAt = new Date(); + authority.lastError = null; + } catch (error) { + authority.status = AuthorityStatus.INACTIVE; + authority.lastError = error.message; + } + + return await this.authorityRepository.save(authority); + } + + async requestCredential(dto: RequestCredentialDto): Promise { + const authority = await this.findOne(dto.authorityId); + + if (authority.status !== AuthorityStatus.ACTIVE) { + throw new BadRequestException( + `Authority ${authority.name} is not active (status: ${authority.status})`, + ); + } + + const issuance = this.issuanceRepository.create({ + authorityId: dto.authorityId, + patientWalletAddress: dto.patientWalletAddress, + credentialType: dto.credentialType, + healthData: dto.healthData, + expirationDate: dto.expirationDate ? new Date(dto.expirationDate) : null, + issuerNotes: dto.issuerNotes, + metadata: dto.metadata, + status: IssuanceStatus.PENDING, + }); + + const saved = await this.issuanceRepository.save(issuance); + + this.processIssuance(saved, authority).catch((error) => { + this.logger.error(`Background issuance failed for ${saved.id}: ${error.message}`); + }); + + return saved; + } + + private async processIssuance( + issuance: IssuanceRecord, + authority: HealthAuthority, + ): Promise { + const request: CredentialIssuanceRequest = { + credentialType: issuance.credentialType, + patientId: issuance.patientWalletAddress, + healthData: issuance.healthData, + expirationDate: issuance.expirationDate?.toISOString(), + format: issuance.format, + }; + + let lastError: Error | null = null; + + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + issuance.status = IssuanceStatus.PROCESSING; + issuance.retryCount = attempt; + await this.issuanceRepository.save(issuance); + + await this.apiClient.authenticate(authority); + + const response = await this.apiClient.requestCredentialIssuance(authority, request); + + issuance.status = response.status; + issuance.issuedCredential = response.credential; + issuance.externalRequestId = response.requestId; + issuance.credentialHash = response.credential + ? this.computeCredentialHash(response.credential) + : null; + + if (response.status === IssuanceStatus.ISSUED) { + authority.credentialsIssued += 1; + await this.authorityRepository.save(authority); + } + + await this.issuanceRepository.save(issuance); + this.logger.log(`Credential issuance ${issuance.id} completed with status: ${response.status}`); + return; + } catch (error) { + lastError = error; + this.logger.warn( + `Issuance attempt ${attempt + 1}/${MAX_RETRIES} failed for ${issuance.id}: ${error.message}`, + ); + + if (attempt < MAX_RETRIES - 1) { + await this.delay(RETRY_DELAY_MS * (attempt + 1)); + } + } + } + + issuance.status = IssuanceStatus.FAILED; + issuance.failureReason = lastError?.message || 'Unknown error after max retries'; + await this.issuanceRepository.save(issuance); + this.logger.error(`Credential issuance ${issuance.id} failed after ${MAX_RETRIES} attempts`); + } + + async checkIssuanceStatus(issuanceId: string): Promise { + const issuance = await this.issuanceRepository.findOne({ + where: { id: issuanceId }, + relations: ['authority'], + }); + + if (!issuance) { + throw new NotFoundException(`Issuance record ${issuanceId} not found`); + } + + if (issuance.status === IssuanceStatus.PROCESSING && issuance.externalRequestId) { + try { + const response = await this.apiClient.getCredentialStatus( + issuance.authority, + issuance.externalRequestId, + ); + + if (response.status !== issuance.status) { + issuance.status = response.status; + issuance.issuedCredential = response.credential || issuance.issuedCredential; + await this.issuanceRepository.save(issuance); + } + } catch (error) { + this.logger.warn(`Status sync failed for issuance ${issuanceId}: ${error.message}`); + } + } + + return issuance; + } + + async findAllIssuances(): Promise { + return await this.issuanceRepository.find({ + relations: ['authority'], + order: { createdAt: 'DESC' }, + }); + } + + async findIssuancesByWallet(walletAddress: string): Promise { + return await this.issuanceRepository.find({ + where: { patientWalletAddress: walletAddress }, + relations: ['authority'], + order: { createdAt: 'DESC' }, + }); + } + + async findIssuancesByAuthority(authorityId: string): Promise { + return await this.issuanceRepository.find({ + where: { authorityId }, + relations: ['authority'], + order: { createdAt: 'DESC' }, + }); + } + + async retryIssuance(issuanceId: string): Promise { + const issuance = await this.issuanceRepository.findOne({ + where: { id: issuanceId }, + relations: ['authority'], + }); + + if (!issuance) { + throw new NotFoundException(`Issuance record ${issuanceId} not found`); + } + + if (issuance.status !== IssuanceStatus.FAILED) { + throw new BadRequestException('Only failed issuances can be retried'); + } + + issuance.status = IssuanceStatus.PENDING; + issuance.failureReason = null; + issuance.retryCount = 0; + const saved = await this.issuanceRepository.save(issuance); + + this.processIssuance(saved, issuance.authority).catch((error) => { + this.logger.error(`Retry issuance failed for ${saved.id}: ${error.message}`); + }); + + return saved; + } + + async revokeIssuance(issuanceId: string): Promise { + const issuance = await this.issuanceRepository.findOne({ + where: { id: issuanceId }, + }); + + if (!issuance) { + throw new NotFoundException(`Issuance record ${issuanceId} not found`); + } + + if (issuance.status !== IssuanceStatus.ISSUED) { + throw new BadRequestException('Only issued credentials can be revoked'); + } + + issuance.status = IssuanceStatus.REVOKED; + return await this.issuanceRepository.save(issuance); + } + + private computeCredentialHash(credential: Record): string { + const serialized = JSON.stringify(credential, Object.keys(credential).sort()); + return createHash('sha256').update(serialized).digest('hex'); + } + + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/backend/src/health-authority/issuance-record.entity.ts b/backend/src/health-authority/issuance-record.entity.ts new file mode 100644 index 00000000..a2dda73a --- /dev/null +++ b/backend/src/health-authority/issuance-record.entity.ts @@ -0,0 +1,87 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { HealthAuthority } from './health-authority.entity'; + +export enum IssuanceStatus { + PENDING = 'pending', + PROCESSING = 'processing', + ISSUED = 'issued', + FAILED = 'failed', + REVOKED = 'revoked', +} + +export enum CredentialFormat { + W3C_VC = 'w3c_vc', + FHIR = 'fhir', + HL7 = 'hl7', + CUSTOM_JSON = 'custom_json', + SMART_HEALTH_CARD = 'smart_health_card', +} + +@Entity('issuance_records') +export class IssuanceRecord { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column() + authorityId: string; + + @ManyToOne(() => HealthAuthority) + @JoinColumn({ name: 'authorityId' }) + authority: HealthAuthority; + + @Index() + @Column() + patientWalletAddress: string; + + @Column() + credentialType: string; + + @Column({ type: 'enum', enum: CredentialFormat, default: CredentialFormat.CUSTOM_JSON }) + format: CredentialFormat; + + @Column({ type: 'enum', enum: IssuanceStatus, default: IssuanceStatus.PENDING }) + status: IssuanceStatus; + + @Column({ type: 'json' }) + healthData: Record; + + @Column({ type: 'json', nullable: true }) + issuedCredential: Record; + + @Column({ nullable: true }) + credentialHash: string; + + @Column({ nullable: true }) + externalRequestId: string; + + @Column({ type: 'timestamptz', nullable: true }) + expirationDate: Date; + + @Column({ nullable: true }) + failureReason: string; + + @Column({ default: 0 }) + retryCount: number; + + @Column({ nullable: true }) + issuerNotes: string; + + @Column({ type: 'json', nullable: true }) + metadata: Record; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +}