diff --git a/.env.example b/.env.example index 377ed0c..1c7e411 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,10 @@ JWT_ACCESS_EXPIRATION=15m JWT_REFRESH_EXPIRATION=7d NONCE_EXPIRATION=300 +# Comma-separated Stellar public keys allowed to use admin endpoints. +# Empty or unset denies all admin access by design. +ADMIN_WALLETS= + # Redis REDIS_URL=redis://localhost:6379 REDIS_DB=0 diff --git a/context/progress-tracker.md b/context/progress-tracker.md index 3a5dd01..d964c4f 100644 --- a/context/progress-tracker.md +++ b/context/progress-tracker.md @@ -6,6 +6,18 @@ pure chore/docs commits). Direct pushes to main must also be logged here. --- +## 2026-07-22 + +- Added a fail-closed `ADMIN_WALLETS` allowlist validated as Stellar public + keys at startup, plus a shared `AdminGuard` for privileged API endpoints. + The audit-log controller now requires both a valid JWT and an allowlisted + wallet; self-selected user roles remain unchanged and cannot grant admin + access. +- Added admin-only vendor approval and suspension XDR endpoints backed by the + vendor-registry contract. Vendor lifecycle state is mirrored locally only + after the existing transaction status checker confirms the signed + transaction on Stellar. + ## 2026-07-19 - Wired `LiquidityContractClient` (restored under `src/blockchain/contracts/liquidity-contract.client.ts`) into `LiquidityService` constructor. diff --git a/src/app.module.ts b/src/app.module.ts index 65d297a..085409d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -29,10 +29,11 @@ import { CreditScoringModule } from './modules/credit-scoring/credit-scoring.mod import { AdminModule } from './modules/admin/admin.module'; import { CorrelationIdMiddleware } from './common/logger/correlation-id.middleware'; import { StateReconciliationModule } from './jobs/state-reconciliation/state-reconciliation.module'; +import { validateEnvironment } from './config/env'; @Module({ imports: [ - ConfigModule.forRoot({ isGlobal: true }), + ConfigModule.forRoot({ isGlobal: true, validate: validateEnvironment }), SentryModule.forRoot(), ScheduleModule.forRoot(), LoggerModule, diff --git a/src/auth/guards/admin.guard.ts b/src/auth/guards/admin.guard.ts new file mode 100644 index 0000000..54f9a9a --- /dev/null +++ b/src/auth/guards/admin.guard.ts @@ -0,0 +1,38 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + Logger, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { parseAdminWallets } from '../../config/env'; + +interface AuthenticatedRequest { + user?: { wallet?: string }; +} + +@Injectable() +export class AdminGuard implements CanActivate { + private readonly logger = new Logger(AdminGuard.name); + private readonly adminWallets: ReadonlySet; + + constructor(configService: ConfigService) { + this.adminWallets = new Set(parseAdminWallets(configService.get('ADMIN_WALLETS'))); + } + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const wallet = request.user?.wallet; + + if (!wallet || !this.adminWallets.has(wallet)) { + this.logger.warn(`Admin access denied for wallet ${wallet ?? 'unknown'}`); + throw new ForbiddenException({ + code: 'AUTH_ADMIN_FORBIDDEN', + message: 'Admin access required.', + }); + } + + return true; + } +} diff --git a/src/config/env.ts b/src/config/env.ts index e69de29..0d13398 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -0,0 +1,24 @@ +import { StrKey } from 'stellar-sdk'; + +export function parseAdminWallets(value: unknown): string[] { + if (typeof value !== 'string' || value.trim() === '') return []; + + const wallets = value + .split(',') + .map((wallet) => wallet.trim()) + .filter((wallet) => wallet.length > 0); + + const invalidWallet = wallets.find((wallet) => !StrKey.isValidEd25519PublicKey(wallet)); + if (invalidWallet) { + throw new Error(`ADMIN_WALLETS contains an invalid Stellar public key: ${invalidWallet}`); + } + + return [...new Set(wallets)]; +} + +export function validateEnvironment(config: Record): Record { + return { + ...config, + ADMIN_WALLETS: parseAdminWallets(config.ADMIN_WALLETS).join(','), + }; +} diff --git a/src/database/repositories/vendors.repository.ts b/src/database/repositories/vendors.repository.ts index f325757..5dcb13b 100644 --- a/src/database/repositories/vendors.repository.ts +++ b/src/database/repositories/vendors.repository.ts @@ -2,6 +2,7 @@ import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { SupabaseService } from '../supabase.client'; export type VendorType = 'school' | 'bootcamp' | 'electronics' | 'books' | 'subscriptions'; +export type VendorStatus = 'pending' | 'approved' | 'suspended' | 'rejected'; export interface VendorRecord { id: string; @@ -9,6 +10,7 @@ export interface VendorRecord { name: string; type: VendorType; verified: boolean; + status: VendorStatus; } export interface VendorDetailRecord extends VendorRecord { diff --git a/src/jobs/transaction-status-checker/transaction-status-checker.service.ts b/src/jobs/transaction-status-checker/transaction-status-checker.service.ts index fcc8dee..35bb011 100644 --- a/src/jobs/transaction-status-checker/transaction-status-checker.service.ts +++ b/src/jobs/transaction-status-checker/transaction-status-checker.service.ts @@ -19,6 +19,8 @@ interface PendingTransaction { interface TransactionMetadata { loanId?: string; amount?: number; + vendorWallet?: string; + vendorStatus?: 'approved' | 'suspended'; } interface TransactionStatusResult { @@ -32,6 +34,8 @@ interface FollowUpResult { loanId?: string; remainingBalance?: number; loanStatus?: string; + vendorWallet?: string; + vendorStatus?: 'approved' | 'suspended'; } /** @@ -351,10 +355,21 @@ export class TransactionStatusCheckerService { } const metadata = this.parseTransactionMetadata(transaction.xdr); - if (!metadata?.loanId) { + if (!metadata) { return {}; } + if ( + (transaction.type === TransactionType.VENDOR_APPROVE || + transaction.type === TransactionType.VENDOR_SUSPEND) && + metadata.vendorWallet && + metadata.vendorStatus + ) { + return this.applyVendorStatus(metadata.vendorWallet, metadata.vendorStatus); + } + + if (!metadata.loanId) return {}; + if (transaction.type === TransactionType.LOAN_CREATE) { return this.activatePendingLoan(metadata.loanId, transaction.user_wallet); } @@ -366,6 +381,55 @@ export class TransactionStatusCheckerService { return { loanId: metadata.loanId }; } + private async applyVendorStatus( + vendorWallet: string, + status: 'approved' | 'suspended', + ): Promise { + const db = this.supabaseService.getServiceRoleClient(); + const expectedStatus = status === 'approved' ? 'pending' : 'approved'; + const { data, error } = await db + .from('vendors') + .update({ + status, + verified: status === 'approved', + updated_at: new Date().toISOString(), + }) + .eq('wallet_address', vendorWallet) + .eq('status', expectedStatus) + .select('id') + .maybeSingle(); + + if (error) { + this.logger.warn( + { + context: 'TransactionStatusCheckerService', + action: 'applyVendorStatus', + vendorWallet, + status, + error: error.message, + }, + 'Failed to update vendor status after successful transaction', + ); + return { vendorWallet }; + } + + if (!data) { + this.logger.warn( + { + context: 'TransactionStatusCheckerService', + action: 'applyVendorStatus', + vendorWallet, + status, + expectedStatus, + }, + 'Vendor status was not updated because its local state no longer matched', + ); + return { vendorWallet }; + } + + return { vendorWallet, vendorStatus: status }; + } + private async activatePendingLoan( loanId: string, userWallet: string, @@ -544,6 +608,15 @@ export class TransactionStatusCheckerService { return { loanId, amount }; } + if (functionName === 'approve_vendor' || functionName === 'suspend_vendor') { + const vendorWallet = this.contractAddressToString(nativeArgs[1]); + if (!vendorWallet) return null; + return { + vendorWallet, + vendorStatus: functionName === 'approve_vendor' ? 'approved' : 'suspended', + }; + } + return null; } catch (error) { this.logger.warn( @@ -559,6 +632,15 @@ export class TransactionStatusCheckerService { } } + private contractAddressToString(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if (value && typeof value === 'object' && 'toString' in value) { + const address = String(value); + return address.startsWith('G') ? address : undefined; + } + return undefined; + } + private async createNotification( transaction: PendingTransaction, status: 'success' | 'failed', @@ -638,6 +720,20 @@ export class TransactionStatusCheckerService { }; } + if ( + transaction.type === TransactionType.VENDOR_APPROVE || + transaction.type === TransactionType.VENDOR_SUSPEND + ) { + const approved = transaction.type === TransactionType.VENDOR_APPROVE; + return { + type: approved ? 'vendor_approve_success' : 'vendor_suspend_success', + title: approved ? 'Vendor Approved' : 'Vendor Suspended', + message: followUp.vendorStatus + ? `Vendor status was updated to ${followUp.vendorStatus} after Stellar confirmation.` + : 'The vendor status transaction was confirmed on Stellar.', + }; + } + return { type: 'transaction_success', title: 'Transaction Confirmed', diff --git a/src/modules/admin/audit.controller.ts b/src/modules/admin/audit.controller.ts index 71f7c46..dc99ecb 100644 --- a/src/modules/admin/audit.controller.ts +++ b/src/modules/admin/audit.controller.ts @@ -6,10 +6,11 @@ import { AuditLogListResponseDto } from './dto/audit-log-response.dto'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { AuditInterceptor } from '../../common/interceptors/audit.interceptor'; import { AuditAction } from '../../common/decorators/audit-action.decorator'; +import { AdminGuard } from '../../auth/guards/admin.guard'; @ApiTags('admin') @Controller('admin') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, AdminGuard) @ApiBearerAuth() export class AuditController { constructor(private readonly auditService: AuditService) {} @@ -35,6 +36,7 @@ export class AuditController { type: AuditLogListResponseDto, }) @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid admin JWT' }) + @ApiResponse({ status: 403, description: 'Forbidden - wallet is not an administrator' }) async getAuditLogs(@Query() query: AuditLogQueryDto) { return this.auditService.findMany(query); } diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 77ddd3b..32513e5 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -9,6 +9,7 @@ import { ApiKeyGuard } from '../../auth/guards/api-key.guard'; import { SupabaseService } from '../../database/supabase.client'; import { UsersRepository } from '../../database/repositories/users.repository'; import { getJwtConfig } from '../../config/jwt.config'; +import { AdminGuard } from '../../auth/guards/admin.guard'; @Module({ imports: [ @@ -20,7 +21,7 @@ import { getJwtConfig } from '../../config/jwt.config'; }), ], controllers: [AuthController], - providers: [AuthService, JwtStrategy, ApiKeyGuard, SupabaseService, ConfigService, UsersRepository], - exports: [AuthService, JwtStrategy, ApiKeyGuard, PassportModule], + providers: [AuthService, JwtStrategy, ApiKeyGuard, AdminGuard, SupabaseService, ConfigService, UsersRepository], + exports: [AuthService, JwtStrategy, ApiKeyGuard, AdminGuard, PassportModule], }) export class AuthModule {} diff --git a/src/modules/transactions/dto/submit-transaction-request.dto.ts b/src/modules/transactions/dto/submit-transaction-request.dto.ts index 68ddae0..e25d13f 100644 --- a/src/modules/transactions/dto/submit-transaction-request.dto.ts +++ b/src/modules/transactions/dto/submit-transaction-request.dto.ts @@ -6,6 +6,8 @@ export enum TransactionType { LOAN_REPAY = 'loan_repay', DEPOSIT = 'deposit', WITHDRAW = 'withdraw', + VENDOR_APPROVE = 'vendor_approve', + VENDOR_SUSPEND = 'vendor_suspend', } /** diff --git a/src/modules/vendors/dto/vendor.dto.ts b/src/modules/vendors/dto/vendor.dto.ts index a759ec2..ead4dce 100644 --- a/src/modules/vendors/dto/vendor.dto.ts +++ b/src/modules/vendors/dto/vendor.dto.ts @@ -9,6 +9,13 @@ export enum VendorType { SUBSCRIPTIONS = 'subscriptions', } +export enum VendorStatus { + PENDING = 'pending', + APPROVED = 'approved', + SUSPENDED = 'suspended', + REJECTED = 'rejected', +} + export class CreateVendorDto { @ApiProperty({ example: 'University of Lagos' }) @IsString() @@ -40,9 +47,24 @@ export class VendorResponseDto { @ApiProperty() name: string; @ApiProperty({ enum: VendorType }) type: VendorType; @ApiProperty() verified: boolean; + @ApiProperty({ enum: VendorStatus }) status: VendorStatus; @ApiPropertyOptional() website?: string; @ApiPropertyOptional() country?: string; @ApiPropertyOptional() city?: string; @ApiPropertyOptional() description?: string; @ApiProperty() createdAt: string; } + +export class VendorStatusChangeResponseDto { + @ApiProperty({ description: 'Unsigned transaction XDR for the admin wallet to sign' }) + unsignedXdr: string; + + @ApiProperty({ example: 'Approve vendor Acme University' }) + description: string; + + @ApiProperty({ description: 'Local vendor UUID' }) + vendorId: string; + + @ApiProperty({ enum: VendorStatus }) + targetStatus: VendorStatus; +} diff --git a/src/modules/vendors/vendors.controller.ts b/src/modules/vendors/vendors.controller.ts index c22ccce..c92aebc 100644 --- a/src/modules/vendors/vendors.controller.ts +++ b/src/modules/vendors/vendors.controller.ts @@ -11,6 +11,7 @@ import { ParseIntPipe, DefaultValuePipe, UseGuards, + UseInterceptors, HttpCode, HttpStatus, } from '@nestjs/common'; @@ -26,7 +27,11 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { Roles, RolesGuard } from '../../auth/guards/roles.guard'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { VendorsService } from './vendors.service'; -import { VendorResponseDto, VendorType } from './dto/vendor.dto'; +import { + VendorResponseDto, + VendorStatusChangeResponseDto, + VendorType, +} from './dto/vendor.dto'; import { RegisterVendorDto } from './dto/register-vendor.dto'; import { VendorDashboardDto } from './dto/vendor-dashboard.dto'; import { VendorLoansPageDto } from './dto/vendor-loan.dto'; @@ -38,6 +43,9 @@ import { } from './dto/vendor-product.dto'; import { CreateApiKeyDto } from './dto/create-api-key.dto'; import { ApiKeyResponseDto, ApiKeyCreatedResponseDto } from './dto/api-key-response.dto'; +import { AdminGuard } from '../../auth/guards/admin.guard'; +import { AuditInterceptor } from '../../common/interceptors/audit.interceptor'; +import { AuditAction } from '../../common/decorators/audit-action.decorator'; /** Standard response envelope: { success, data, message }. */ interface Envelope { @@ -262,6 +270,56 @@ export class VendorsController { return this.vendorsService.revokeApiKey(user.wallet, keyId); } + @Post(':id/approve') + @UseGuards(JwtAuthGuard, AdminGuard) + @UseInterceptors(AuditInterceptor) + @AuditAction('vendors', 'APPROVE_VENDOR') + @HttpCode(HttpStatus.OK) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Build an unsigned vendor approval transaction', + description: + 'Requires an allowlisted admin wallet. The API returns unsigned XDR and does not update local vendor state until the signed transaction is confirmed on-chain.', + }) + @ApiParam({ name: 'id', description: 'Vendor UUID' }) + @ApiResponse({ status: 200, description: 'Unsigned approval XDR', type: VendorStatusChangeResponseDto }) + @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid JWT' }) + @ApiResponse({ status: 403, description: 'Forbidden - wallet is not an administrator' }) + @ApiResponse({ status: 404, description: 'Vendor not found' }) + @ApiResponse({ status: 409, description: 'Vendor is not pending' }) + async approveVendor( + @CurrentUser() user: { wallet: string }, + @Param('id', ParseUUIDPipe) vendorId: string, + ): Promise> { + const data = await this.vendorsService.buildApproveVendorXdr(user.wallet, vendorId); + return { success: true, data, message: 'Vendor approval transaction built successfully' }; + } + + @Post(':id/suspend') + @UseGuards(JwtAuthGuard, AdminGuard) + @UseInterceptors(AuditInterceptor) + @AuditAction('vendors', 'SUSPEND_VENDOR') + @HttpCode(HttpStatus.OK) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Build an unsigned vendor suspension transaction', + description: + 'Requires an allowlisted admin wallet. The API returns unsigned XDR and does not update local vendor state until the signed transaction is confirmed on-chain.', + }) + @ApiParam({ name: 'id', description: 'Vendor UUID' }) + @ApiResponse({ status: 200, description: 'Unsigned suspension XDR', type: VendorStatusChangeResponseDto }) + @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid JWT' }) + @ApiResponse({ status: 403, description: 'Forbidden - wallet is not an administrator' }) + @ApiResponse({ status: 404, description: 'Vendor not found' }) + @ApiResponse({ status: 409, description: 'Vendor is not approved' }) + async suspendVendor( + @CurrentUser() user: { wallet: string }, + @Param('id', ParseUUIDPipe) vendorId: string, + ): Promise> { + const data = await this.vendorsService.buildSuspendVendorXdr(user.wallet, vendorId); + return { success: true, data, message: 'Vendor suspension transaction built successfully' }; + } + // --- Public single-vendor lookup (wildcard, declared LAST) --- @Get(':id') diff --git a/src/modules/vendors/vendors.module.ts b/src/modules/vendors/vendors.module.ts index a9af58c..5b361f3 100644 --- a/src/modules/vendors/vendors.module.ts +++ b/src/modules/vendors/vendors.module.ts @@ -4,9 +4,11 @@ import { VendorsController } from './vendors.controller'; import { SupabaseService } from '../../database/supabase.client'; import { VendorsRepository } from '../../database/repositories/vendors.repository'; import { AuthModule } from '../auth/auth.module'; +import { StellarModule } from '../../stellar/stellar.module'; +import { AdminModule } from '../admin/admin.module'; @Module({ - imports: [AuthModule], + imports: [AuthModule, StellarModule, AdminModule], providers: [VendorsService, VendorsRepository, SupabaseService], controllers: [VendorsController], exports: [VendorsService], diff --git a/src/modules/vendors/vendors.service.ts b/src/modules/vendors/vendors.service.ts index 16dd25f..cc98202 100644 --- a/src/modules/vendors/vendors.service.ts +++ b/src/modules/vendors/vendors.service.ts @@ -9,7 +9,12 @@ import { import { createHash, randomBytes } from 'crypto'; import { SupabaseService } from '../../database/supabase.client'; import { VendorsRepository, VendorDetailRecord } from '../../database/repositories/vendors.repository'; -import { VendorResponseDto, VendorType } from './dto/vendor.dto'; +import { + VendorResponseDto, + VendorStatus, + VendorStatusChangeResponseDto, + VendorType, +} from './dto/vendor.dto'; import { RegisterVendorDto } from './dto/register-vendor.dto'; import { VendorDashboardDto } from './dto/vendor-dashboard.dto'; import { VendorLoanDto, VendorLoansPageDto } from './dto/vendor-loan.dto'; @@ -21,6 +26,7 @@ import { } from './dto/vendor-product.dto'; import { CreateApiKeyDto } from './dto/create-api-key.dto'; import { ApiKeyResponseDto, ApiKeyCreatedResponseDto } from './dto/api-key-response.dto'; +import { VendorRegistryContractClient } from '../../stellar/contracts/clients/vendor-registry.client'; const ACTIVE_LOAN_STATUS = 'active'; const DEFAULTED_LOAN_STATUS = 'defaulted'; @@ -69,6 +75,7 @@ interface VendorRow { name: string; type: VendorType; verified: boolean; + status: VendorStatus; website: string | null; country: string | null; city: string | null; @@ -97,6 +104,7 @@ export class VendorsService { constructor( private readonly supabaseService: SupabaseService, private readonly vendorsRepository: VendorsRepository, + private readonly vendorRegistryClient: VendorRegistryContractClient, ) {} async getAll(type?: VendorType): Promise { @@ -137,6 +145,42 @@ export class VendorsService { return this.mapToDto(data as VendorRow); } + async buildApproveVendorXdr( + adminWallet: string, + vendorId: string, + ): Promise { + const vendor = await this.requireVendorForTransition(vendorId, VendorStatus.PENDING); + const unsignedXdr = await this.vendorRegistryClient.buildApproveVendorXdr( + adminWallet, + vendor.wallet_address, + ); + + return { + unsignedXdr, + description: `Approve vendor ${vendor.name}`, + vendorId: vendor.id, + targetStatus: VendorStatus.APPROVED, + }; + } + + async buildSuspendVendorXdr( + adminWallet: string, + vendorId: string, + ): Promise { + const vendor = await this.requireVendorForTransition(vendorId, VendorStatus.APPROVED); + const unsignedXdr = await this.vendorRegistryClient.buildSuspendVendorXdr( + adminWallet, + vendor.wallet_address, + ); + + return { + unsignedXdr, + description: `Suspend vendor ${vendor.name}`, + vendorId: vendor.id, + targetStatus: VendorStatus.SUSPENDED, + }; + } + /** * Registers the authenticated wallet as a vendor. Exactly one vendor profile * is permitted per wallet; a second attempt returns 409. `category` is stored @@ -413,6 +457,28 @@ export class VendorsService { return vendor; } + private async requireVendorForTransition( + vendorId: string, + expectedStatus: VendorStatus, + ): Promise { + const vendor = await this.vendorsRepository.findById(vendorId); + if (!vendor || !vendor.wallet_address) { + throw new NotFoundException({ + code: 'VENDOR_NOT_FOUND', + message: 'Vendor not found.', + }); + } + + if (vendor.status !== expectedStatus) { + throw new ConflictException({ + code: 'VENDOR_STATUS_CONFLICT', + message: `Vendor must be ${expectedStatus} before this action can be performed.`, + }); + } + + return vendor; + } + private async requireOwnedProduct(vendorId: string, productId: string): Promise { const client = this.supabaseService.getClient(); const { data, error } = await client @@ -621,6 +687,7 @@ export class VendorsService { name: data.name, type: data.type, verified: data.verified, + status: data.status, website: data.website ?? undefined, country: data.country ?? undefined, city: data.city ?? undefined, diff --git a/src/stellar/contracts/clients/vendor-registry.client.ts b/src/stellar/contracts/clients/vendor-registry.client.ts index 1954d20..95944ad 100644 --- a/src/stellar/contracts/clients/vendor-registry.client.ts +++ b/src/stellar/contracts/clients/vendor-registry.client.ts @@ -3,7 +3,7 @@ import { ConfigService } from '@nestjs/config'; import * as StellarSdk from 'stellar-sdk'; import { SorobanService } from '../../../blockchain/soroban/soroban.service'; import { VendorInfo, VENDOR_REGISTRY_CONTRACT_ID_KEY } from '../interfaces/vendor-registry.interface'; -import { ContractNotConfiguredError, ContractReadError } from '../errors'; +import { ContractNotConfiguredError, ContractReadError, ContractTxBuildError } from '../errors'; @Injectable() export class VendorRegistryContractClient { @@ -28,12 +28,12 @@ export class VendorRegistryContractClient { throw new ContractNotConfiguredError('Vendor registry contract'); } - const vendorIdArg = StellarSdk.nativeToScVal(vendorId, { type: 'string' }); + const vendorIdArg = this.addressArg(vendorId); try { const result = await this.sorobanService.simulateContractCall( this.contractId, - 'is_vendor_active', + 'is_active', [vendorIdArg], ); return Boolean(StellarSdk.scValToNative(result)); @@ -48,12 +48,12 @@ export class VendorRegistryContractClient { throw new ContractNotConfiguredError('Vendor registry contract'); } - const vendorIdArg = StellarSdk.nativeToScVal(vendorId, { type: 'string' }); + const vendorIdArg = this.addressArg(vendorId); try { const result = await this.sorobanService.simulateContractCall( this.contractId, - 'get_vendor', + 'get_vendor_info', [vendorIdArg], ); const raw = StellarSdk.scValToNative(result) as Record; @@ -63,9 +63,11 @@ export class VendorRegistryContractClient { } return { - id: String(raw['id'] ?? raw['vendor_id'] ?? ''), + id: vendorId, name: String(raw['name'] ?? ''), - active: Boolean(raw['active'] ?? raw['is_active'] ?? false), + active: + String(raw['status'] ?? '').toLowerCase().includes('approved') || + Boolean(raw['active'] ?? raw['is_active'] ?? false), }; } catch (error) { if ( @@ -79,4 +81,46 @@ export class VendorRegistryContractClient { throw new ContractReadError('vendor info'); } } + + buildApproveVendorXdr(admin: string, vendor: string): Promise { + return this.buildStatusTransaction('approve_vendor', admin, vendor); + } + + buildSuspendVendorXdr(admin: string, vendor: string): Promise { + return this.buildStatusTransaction('suspend_vendor', admin, vendor); + } + + private async buildStatusTransaction( + method: 'approve_vendor' | 'suspend_vendor', + admin: string, + vendor: string, + ): Promise { + if (!this.contractId) { + throw new ContractNotConfiguredError('Vendor registry contract'); + } + + try { + const server = this.sorobanService.getServer(); + const sourceAccount = await server.getAccount(admin); + const contract = new StellarSdk.Contract(this.contractId); + const transaction = new StellarSdk.TransactionBuilder(sourceAccount, { + fee: StellarSdk.BASE_FEE, + networkPassphrase: this.sorobanService.getNetworkPassphrase(), + }) + .addOperation(contract.call(method, this.addressArg(admin), this.addressArg(vendor))) + .setTimeout(300) + .build(); + + const prepared = await server.prepareTransaction(transaction); + return prepared.toXDR(); + } catch (error) { + if (error instanceof ContractNotConfiguredError) throw error; + this.logger.error(`Failed to build ${method} transaction: ${error.message}`); + throw new ContractTxBuildError(method); + } + } + + private addressArg(address: string): StellarSdk.xdr.ScVal { + return StellarSdk.nativeToScVal(StellarSdk.Address.fromString(address), { type: 'address' }); + } } diff --git a/src/stellar/contracts/interfaces/vendor-registry.interface.ts b/src/stellar/contracts/interfaces/vendor-registry.interface.ts index 761c2f5..a029a81 100644 --- a/src/stellar/contracts/interfaces/vendor-registry.interface.ts +++ b/src/stellar/contracts/interfaces/vendor-registry.interface.ts @@ -10,4 +10,8 @@ export interface IVendorRegistryClient { isVendorActive(vendorId: string): Promise; getVendor(vendorId: string): Promise; + + buildApproveVendorXdr(admin: string, vendor: string): Promise; + + buildSuspendVendorXdr(admin: string, vendor: string): Promise; } diff --git a/src/stellar/contracts/mocks/vendor-registry.mock.ts b/src/stellar/contracts/mocks/vendor-registry.mock.ts index b6e74c4..444a9bd 100644 --- a/src/stellar/contracts/mocks/vendor-registry.mock.ts +++ b/src/stellar/contracts/mocks/vendor-registry.mock.ts @@ -14,4 +14,12 @@ export class MockVendorRegistryContractClient { active: true, }), ); + + buildApproveVendorXdr = jest.fn( + async (_admin: string, _vendor: string): Promise => 'mock-approve-vendor-xdr', + ); + + buildSuspendVendorXdr = jest.fn( + async (_admin: string, _vendor: string): Promise => 'mock-suspend-vendor-xdr', + ); } diff --git a/supabase/migrations/20260722000001_add_vendor_status.sql b/supabase/migrations/20260722000001_add_vendor_status.sql new file mode 100644 index 0000000..b33bd24 --- /dev/null +++ b/supabase/migrations/20260722000001_add_vendor_status.sql @@ -0,0 +1,14 @@ +ALTER TABLE public.vendors + ADD COLUMN status TEXT DEFAULT 'pending'; + +UPDATE public.vendors +SET status = 'pending' +WHERE status IS NULL; + +ALTER TABLE public.vendors + ALTER COLUMN status SET NOT NULL, + ADD CONSTRAINT vendors_status_check + CHECK (status IN ('pending', 'approved', 'suspended', 'rejected')); + +COMMENT ON COLUMN public.vendors.status IS + 'Vendor approval lifecycle mirrored from the vendor registry contract.'; diff --git a/test/unit/config/env.spec.ts b/test/unit/config/env.spec.ts new file mode 100644 index 0000000..4e65213 --- /dev/null +++ b/test/unit/config/env.spec.ts @@ -0,0 +1,24 @@ +jest.unmock('stellar-sdk'); + +import { Keypair } from 'stellar-sdk'; +import { parseAdminWallets, validateEnvironment } from '../../../src/config/env'; + +describe('environment validation', () => { + it('normalizes and de-duplicates valid admin wallets', () => { + const first = Keypair.random().publicKey(); + const second = Keypair.random().publicKey(); + + expect(parseAdminWallets(` ${first},${second},${first} `)).toEqual([first, second]); + }); + + it('treats an empty or missing allowlist as deny-all configuration', () => { + expect(parseAdminWallets(undefined)).toEqual([]); + expect(validateEnvironment({ ADMIN_WALLETS: ' ' }).ADMIN_WALLETS).toBe(''); + }); + + it('fails startup validation for an invalid Stellar public key', () => { + expect(() => validateEnvironment({ ADMIN_WALLETS: 'not-a-stellar-wallet' })).toThrow( + 'ADMIN_WALLETS contains an invalid Stellar public key', + ); + }); +}); diff --git a/test/unit/jobs/transaction-status-checker/transaction-status-checker.service.spec.ts b/test/unit/jobs/transaction-status-checker/transaction-status-checker.service.spec.ts new file mode 100644 index 0000000..a2957bc --- /dev/null +++ b/test/unit/jobs/transaction-status-checker/transaction-status-checker.service.spec.ts @@ -0,0 +1,59 @@ +import { ConfigService } from '@nestjs/config'; +import { SupabaseService } from '../../../../src/database/supabase.client'; +import { TransactionStatusCheckerService } from '../../../../src/jobs/transaction-status-checker/transaction-status-checker.service'; + +function chain(result: unknown) { + const query: Record = {}; + for (const method of ['update', 'eq', 'select', 'maybeSingle']) { + query[method] = jest.fn().mockReturnValue(query); + } + (query as { then: unknown }).then = (resolve: (value: unknown) => unknown) => resolve(result); + return query; +} + +describe('TransactionStatusCheckerService vendor follow-up', () => { + const from = jest.fn(); + const supabaseService = { + getServiceRoleClient: jest.fn(() => ({ from })), + }; + const service = new TransactionStatusCheckerService( + new ConfigService(), + supabaseService as unknown as SupabaseService, + ); + + beforeEach(() => jest.clearAllMocks()); + + it('mirrors approved status only from the confirmation follow-up', async () => { + const query = chain({ data: { id: 'vendor-1' }, error: null }); + from.mockReturnValue(query); + + const result = await ( + service as unknown as { + applyVendorStatus(wallet: string, status: 'approved'): Promise; + } + ).applyVendorStatus('GVENDOR', 'approved'); + + expect(from).toHaveBeenCalledWith('vendors'); + expect(query.update).toHaveBeenCalledWith( + expect.objectContaining({ status: 'approved', verified: true }), + ); + expect(query.eq).toHaveBeenCalledWith('status', 'pending'); + expect(result).toEqual({ vendorWallet: 'GVENDOR', vendorStatus: 'approved' }); + }); + + it('requires approved local state before mirroring suspension', async () => { + const query = chain({ data: { id: 'vendor-1' }, error: null }); + from.mockReturnValue(query); + + await ( + service as unknown as { + applyVendorStatus(wallet: string, status: 'suspended'): Promise; + } + ).applyVendorStatus('GVENDOR', 'suspended'); + + expect(query.update).toHaveBeenCalledWith( + expect.objectContaining({ status: 'suspended', verified: false }), + ); + expect(query.eq).toHaveBeenCalledWith('status', 'approved'); + }); +}); diff --git a/test/unit/modules/auth/admin.guard.spec.ts b/test/unit/modules/auth/admin.guard.spec.ts new file mode 100644 index 0000000..edc1ce0 --- /dev/null +++ b/test/unit/modules/auth/admin.guard.spec.ts @@ -0,0 +1,59 @@ +jest.unmock('stellar-sdk'); + +import { ExecutionContext, ForbiddenException, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Keypair } from 'stellar-sdk'; +import { AdminGuard } from '../../../../src/auth/guards/admin.guard'; + +function contextFor(wallet?: string): ExecutionContext { + return { + switchToHttp: () => ({ + getRequest: () => ({ user: wallet ? { wallet } : undefined }), + }), + } as ExecutionContext; +} + +describe('AdminGuard', () => { + const allowedWallet = Keypair.random().publicKey(); + const otherWallet = Keypair.random().publicKey(); + + it('allows an authenticated wallet on the allowlist', () => { + const guard = new AdminGuard( + new ConfigService({ ADMIN_WALLETS: allowedWallet }), + ); + + expect(guard.canActivate(contextFor(allowedWallet))).toBe(true); + }); + + it('returns a generic 403 for an authenticated wallet not on the allowlist', () => { + const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + const guard = new AdminGuard( + new ConfigService({ ADMIN_WALLETS: allowedWallet }), + ); + + expect(() => guard.canActivate(contextFor(otherWallet))).toThrow(ForbiddenException); + expect(() => guard.canActivate(contextFor(otherWallet))).toThrow( + expect.objectContaining({ + response: { + code: 'AUTH_ADMIN_FORBIDDEN', + message: 'Admin access required.', + }, + }), + ); + expect(warn).toHaveBeenCalledWith(`Admin access denied for wallet ${otherWallet}`); + }); + + it.each([undefined, ''])('denies everyone when ADMIN_WALLETS is %p', (value) => { + const guard = new AdminGuard(new ConfigService({ ADMIN_WALLETS: value })); + + expect(() => guard.canActivate(contextFor(allowedWallet))).toThrow(ForbiddenException); + }); + + it('fails closed when no authenticated user is attached to the request', () => { + const guard = new AdminGuard( + new ConfigService({ ADMIN_WALLETS: allowedWallet }), + ); + + expect(() => guard.canActivate(contextFor())).toThrow(ForbiddenException); + }); +}); diff --git a/test/unit/modules/vendors/vendors.controller.spec.ts b/test/unit/modules/vendors/vendors.controller.spec.ts new file mode 100644 index 0000000..f4fa2a7 --- /dev/null +++ b/test/unit/modules/vendors/vendors.controller.spec.ts @@ -0,0 +1,24 @@ +import { GUARDS_METADATA, INTERCEPTORS_METADATA } from '@nestjs/common/constants'; +import { AdminGuard } from '../../../../src/auth/guards/admin.guard'; +import { AUDIT_ACTION_KEY } from '../../../../src/common/decorators/audit-action.decorator'; +import { AuditInterceptor } from '../../../../src/common/interceptors/audit.interceptor'; +import { JwtAuthGuard } from '../../../../src/common/guards/jwt-auth.guard'; +import { VendorsController } from '../../../../src/modules/vendors/vendors.controller'; + +describe('VendorsController admin status routes', () => { + it.each([ + ['approveVendor', 'APPROVE_VENDOR'], + ['suspendVendor', 'SUSPEND_VENDOR'], + ] as const)('protects and audits %s', (method, auditAction) => { + const handler = VendorsController.prototype[method]; + const guards = Reflect.getMetadata(GUARDS_METADATA, handler) as Array unknown>; + const interceptors = Reflect.getMetadata(INTERCEPTORS_METADATA, handler) as Array unknown>; + + expect(guards).toEqual([JwtAuthGuard, AdminGuard]); + expect(interceptors).toEqual([AuditInterceptor]); + expect(Reflect.getMetadata(AUDIT_ACTION_KEY, handler)).toEqual({ + resource: 'vendors', + action: auditAction, + }); + }); +}); diff --git a/test/unit/modules/vendors/vendors.service.spec.ts b/test/unit/modules/vendors/vendors.service.spec.ts index 48d974b..3b44f29 100644 --- a/test/unit/modules/vendors/vendors.service.spec.ts +++ b/test/unit/modules/vendors/vendors.service.spec.ts @@ -4,6 +4,7 @@ import { VendorsService } from '../../../../src/modules/vendors/vendors.service' import { VendorsRepository } from '../../../../src/database/repositories/vendors.repository'; import { SupabaseService } from '../../../../src/database/supabase.client'; import { VendorType } from '../../../../src/modules/vendors/dto/vendor.dto'; +import { VendorRegistryContractClient } from '../../../../src/stellar/contracts/clients/vendor-registry.client'; /** * Builds a Supabase query-builder mock whose every chainable method returns the @@ -23,7 +24,7 @@ describe('VendorsService', () => { let service: VendorsService; const wallet = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW'; - const vendorRecord = { id: 'vendor-1', wallet_address: wallet, name: 'Acme', type: 'school', verified: true }; + const vendorRecord = { id: 'vendor-1', wallet_address: wallet, name: 'Acme', type: 'school', verified: false, status: 'pending' }; const vendorRow = { id: 'vendor-1', @@ -31,6 +32,7 @@ describe('VendorsService', () => { name: 'Acme University', type: 'school', verified: false, + status: 'pending', website: 'https://acme.edu', country: 'Nigeria', city: 'Lagos', @@ -43,7 +45,11 @@ describe('VendorsService', () => { getClient: jest.fn(), getServiceRoleClient: jest.fn(), }; - const mockVendorsRepository = { findByWallet: jest.fn() }; + const mockVendorsRepository = { findByWallet: jest.fn(), findById: jest.fn() }; + const mockVendorRegistryClient = { + buildApproveVendorXdr: jest.fn(), + buildSuspendVendorXdr: jest.fn(), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -51,6 +57,7 @@ describe('VendorsService', () => { VendorsService, { provide: SupabaseService, useValue: mockSupabaseService }, { provide: VendorsRepository, useValue: mockVendorsRepository }, + { provide: VendorRegistryContractClient, useValue: mockVendorRegistryClient }, ], }).compile(); @@ -82,6 +89,7 @@ describe('VendorsService', () => { expect(result.type).toBe(VendorType.SCHOOL); expect(result.description).toBe('STEM programs'); expect(result.walletAddress).toBe(wallet); + expect(result.status).toBe('pending'); }); it('throws ConflictException when the wallet is already a vendor', async () => { @@ -94,6 +102,54 @@ describe('VendorsService', () => { }); }); + describe('vendor approval lifecycle', () => { + it('builds approval XDR without updating local status', async () => { + mockVendorsRepository.findById.mockResolvedValue(vendorRecord); + mockVendorRegistryClient.buildApproveVendorXdr.mockResolvedValue('approve-xdr'); + + const result = await service.buildApproveVendorXdr('GADMIN', 'vendor-1'); + + expect(result).toMatchObject({ + unsignedXdr: 'approve-xdr', + vendorId: 'vendor-1', + targetStatus: 'approved', + }); + expect(mockVendorRegistryClient.buildApproveVendorXdr).toHaveBeenCalledWith( + 'GADMIN', + wallet, + ); + expect(mockSupabaseService.getServiceRoleClient).not.toHaveBeenCalled(); + }); + + it('rejects approval when the vendor is not pending', async () => { + mockVendorsRepository.findById.mockResolvedValue({ + ...vendorRecord, + status: 'approved', + }); + + await expect(service.buildApproveVendorXdr('GADMIN', 'vendor-1')).rejects.toThrow( + ConflictException, + ); + expect(mockVendorRegistryClient.buildApproveVendorXdr).not.toHaveBeenCalled(); + }); + + it('builds suspension XDR only for an approved vendor', async () => { + mockVendorsRepository.findById.mockResolvedValue({ + ...vendorRecord, + status: 'approved', + }); + mockVendorRegistryClient.buildSuspendVendorXdr.mockResolvedValue('suspend-xdr'); + + const result = await service.buildSuspendVendorXdr('GADMIN', 'vendor-1'); + + expect(result.targetStatus).toBe('suspended'); + expect(mockVendorRegistryClient.buildSuspendVendorXdr).toHaveBeenCalledWith( + 'GADMIN', + wallet, + ); + }); + }); + describe('getDashboard', () => { it('computes totals, active borrowers and default rate', async () => { mockVendorsRepository.findByWallet.mockResolvedValue(vendorRecord); diff --git a/test/unit/stellar/contracts/clients/vendor-registry.client.spec.ts b/test/unit/stellar/contracts/clients/vendor-registry.client.spec.ts new file mode 100644 index 0000000..0b4a16a --- /dev/null +++ b/test/unit/stellar/contracts/clients/vendor-registry.client.spec.ts @@ -0,0 +1,63 @@ +jest.unmock('stellar-sdk'); + +import { ConfigService } from '@nestjs/config'; +import * as StellarSdk from 'stellar-sdk'; +import { SorobanService } from '../../../../../src/blockchain/soroban/soroban.service'; +import { VendorRegistryContractClient } from '../../../../../src/stellar/contracts/clients/vendor-registry.client'; +import { VENDOR_REGISTRY_CONTRACT_ID_KEY } from '../../../../../src/stellar/contracts/interfaces/vendor-registry.interface'; + +describe('VendorRegistryContractClient', () => { + const contractId = StellarSdk.StrKey.encodeContract(Buffer.alloc(32, 1)); + const admin = StellarSdk.Keypair.random().publicKey(); + const vendor = StellarSdk.Keypair.random().publicKey(); + const sourceAccount = new StellarSdk.Account(admin, '1'); + const prepareTransaction = jest.fn(async (transaction: StellarSdk.Transaction) => transaction); + const simulateContractCall = jest.fn(); + const sorobanService = { + getServer: () => ({ + getAccount: jest.fn(async () => sourceAccount), + prepareTransaction, + }), + getNetworkPassphrase: () => StellarSdk.Networks.TESTNET, + simulateContractCall, + }; + const configService = { + get: (key: string) => key === VENDOR_REGISTRY_CONTRACT_ID_KEY ? contractId : undefined, + }; + const client = new VendorRegistryContractClient( + sorobanService as unknown as SorobanService, + configService as unknown as ConfigService, + ); + + beforeEach(() => jest.clearAllMocks()); + + it.each([ + ['buildApproveVendorXdr', 'approve_vendor'], + ['buildSuspendVendorXdr', 'suspend_vendor'], + ] as const)('builds unsigned XDR for %s', async (clientMethod, contractMethod) => { + const xdr = await client[clientMethod](admin, vendor); + const transaction = StellarSdk.TransactionBuilder.fromXDR(xdr, StellarSdk.Networks.TESTNET); + const operation = transaction.operations[0]; + + expect(transaction.source).toBe(admin); + expect(operation.type).toBe('invokeHostFunction'); + if (operation.type !== 'invokeHostFunction') throw new Error('Expected contract invocation'); + + const invocation = operation.func.value() as unknown as { + functionName(): { toString(): string }; + }; + expect(invocation.functionName().toString()).toBe(contractMethod); + expect(prepareTransaction).toHaveBeenCalledTimes(1); + }); + + it('uses the current is_active contract function for approved vendors', async () => { + simulateContractCall.mockResolvedValue(StellarSdk.nativeToScVal(true)); + + await expect(client.isVendorActive(vendor)).resolves.toBe(true); + expect(simulateContractCall).toHaveBeenCalledWith( + contractId, + 'is_active', + [expect.any(StellarSdk.xdr.ScVal)], + ); + }); +});