Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions tests/fakes/fake-audit.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { AuditEntry } from '../../src/types/account.types'

Check warning on line 1 in tests/fakes/fake-audit.provider.ts

View workflow job for this annotation

GitHub Actions / Lint, typecheck, format, test

'AuditEntry' is defined but never used. Allowed unused vars must match /^I[A-Z]|^_/u

export interface AuditLogEntry {
id: string
userId: string
action: string
ipAddress: string | null
userAgent: string | null
metadata: Record<string, unknown>
createdAt: Date
}

export class FakeAuditService {
readonly entries: AuditLogEntry[] = []
private sequence = 0

async op(params: {
userId: string
action: string
ipAddress?: string
userAgent?: string
metadata?: Record<string, unknown>
}): Promise<void> {
this.sequence += 1
this.entries.push({
id: `audit-${this.sequence}`,
userId: params.userId,
action: params.action,
ipAddress: params.ipAddress ?? null,
userAgent: params.userAgent ?? null,
metadata: params.metadata ?? {},
createdAt: new Date(),
})
}

async record(entry: Omit<AuditLogEntry, 'id' | 'createdAt'>): Promise<void> {
this.sequence += 1
this.entries.push({
id: `audit-${this.sequence}`,
...entry,
createdAt: new Date(),
})
}

getEntriesForUser(userId: string): AuditLogEntry[] {
return this.entries.filter((e) => e.userId === userId)
}

getEntriesForAction(action: string): AuditLogEntry[] {
return this.entries.filter((e) => e.action === action)
}

clear(): void {
this.entries.length = 0
this.sequence = 0
}
}

export const fakeAuditService = new FakeAuditService()
100 changes: 100 additions & 0 deletions tests/fakes/fake-email.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { EmailDeliveryRecord } from '../../src/services/email.service'

export class FakeEmailProvider {
readonly deliveries: EmailDeliveryRecord[] = []
readonly sentEmails: Array<{
userId: string
to: string
subject: string
body: string
type: string
}> = []
shouldFail = false
failureError = 'Email provider error'

async queueEmail(
userId: string,
to: string,
subject: string,
body: string,
type = 'EMAIL_VERIFICATION',
): Promise<EmailDeliveryRecord> {
const delivery: EmailDeliveryRecord = {
id: `email-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
userId,
to,
subject,
body,
type,
status: 'pending',
error: null,
attemptCount: 0,
maxAttempts: 5,
nextAttemptAt: new Date(),
lastAttemptAt: null,
sentAt: null,
createdAt: new Date(),
updatedAt: new Date(),
}
this.deliveries.push(delivery)

return delivery
}

async processQueue(): Promise<void> {
for (const delivery of this.deliveries) {
if (delivery.status === 'pending' && delivery.nextAttemptAt <= new Date()) {
await this.sendEmail(delivery)
}
}
}

private async sendEmail(delivery: EmailDeliveryRecord): Promise<void> {
delivery.attemptCount += 1
delivery.lastAttemptAt = new Date()
delivery.updatedAt = new Date()

if (this.shouldFail) {
delivery.error = this.failureError
if (delivery.attemptCount >= delivery.maxAttempts) {
delivery.status = 'dead-letter'
} else {
const backoffMinutes = Math.pow(5, delivery.attemptCount - 1)
delivery.nextAttemptAt = new Date(Date.now() + backoffMinutes * 60_000)
}

return
}

this.sentEmails.push({
userId: delivery.userId,
to: delivery.to,
subject: delivery.subject,
body: delivery.body,
type: delivery.type,
})

delivery.status = 'sent'
delivery.sentAt = new Date()
delivery.error = null
}

getSentEmailsForUser(userId: string): typeof this.sentEmails {
return this.sentEmails.filter((e) => e.userId === userId)
}

getLastSentEmailForUser(userId: string): typeof this.sentEmails[0] | undefined {
const emails = this.getSentEmailsForUser(userId)

return emails[emails.length - 1]
}

clear(): void {
this.deliveries.length = 0
this.sentEmails.length = 0
this.shouldFail = false
this.failureError = 'Email provider error'
}
}

export const fakeEmailProvider = new FakeEmailProvider()
143 changes: 143 additions & 0 deletions tests/fakes/fake-horizon.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import type { AccountBalance, PaymentOptions, PaymentResult, HorizonBalance } from '../../src/services/stellar.service'
import { StellarServiceError } from '../../src/services/stellar.service'

interface FundedAccount {
publicKey: string
balances: HorizonBalance[]
sequence: number
}

export class FakeHorizonProvider {
readonly accounts = new Map<string, FundedAccount>()
readonly transactions = new Map<string, { hash: string; ledger: number; successful: boolean; status: string }>()
readonly payments: Array<{ from: string; to: string; amount: string; memo?: string }> = []
shouldFailOnPayment = false
shouldFailOnBalance = false
shouldFailOnFund = false
paymentFailureError = 'Payment failed'
balanceFailureError = 'Balance fetch failed'
fundFailureError = 'Friendbot funding failed'

fundAccount(publicKey: string): void {
if (this.shouldFailOnFund) {
throw new Error(this.fundFailureError)
}

if (!this.accounts.has(publicKey)) {
this.accounts.set(publicKey, {
publicKey,
balances: [
{ asset_type: 'native', balance: '10000.0000000' },
],
sequence: 0,
})
} else {
const account = this.accounts.get(publicKey)!
const nativeBalance = account.balances.find((b) => b.asset_type === 'native')
if (nativeBalance) {
nativeBalance.balance = String(Number(nativeBalance.balance) + 10000)
}
}
}

async getBalances(publicKey: string): Promise<AccountBalance[]> {
if (this.shouldFailOnBalance) {
throw new StellarServiceError(this.balanceFailureError, 'BALANCE_FETCH_ERROR')
}

const account = this.accounts.get(publicKey)
if (!account) {
throw new StellarServiceError(`Account ${publicKey} not found`, 'BALANCE_FETCH_ERROR')
}

return account.balances.map((b) => {
const assetName =
b.asset_type === 'native'
? 'XLM'
: `${(b as { asset_code: string }).asset_code}:${(b as { asset_issuer: string }).asset_issuer}`

return {
asset: assetName,
balance: b.balance,
limit: b.asset_type !== 'native' ? (b as { limit: string }).limit : undefined,
}
})
}

async getNativeBalance(publicKey: string): Promise<string> {
const balances = await this.getBalances(publicKey)

return balances.find((b) => b.asset === 'XLM')?.balance ?? '0'
}

async sendPayment(options: PaymentOptions): Promise<PaymentResult> {
if (this.shouldFailOnPayment) {
throw new StellarServiceError(this.paymentFailureError, 'PAYMENT_ERROR')
}

const sourceAccount = this.accounts.get(options.sourceSecret)
if (!sourceAccount) {
throw new StellarServiceError('Source account not found', 'PAYMENT_ERROR')
}

let destinationAccount = this.accounts.get(options.destinationPublicKey)
if (!destinationAccount) {
destinationAccount = {
publicKey: options.destinationPublicKey,
balances: [{ asset_type: 'native', balance: '0' }],
sequence: 0,
}
this.accounts.set(options.destinationPublicKey, destinationAccount)
}

const amount = Number(options.amount)
const sourceNative = sourceAccount.balances.find((b) => b.asset_type === 'native')
const destNative = destinationAccount.balances.find((b) => b.asset_type === 'native')

if (!sourceNative || Number(sourceNative.balance) < amount) {
throw new StellarServiceError('Insufficient funds', 'PAYMENT_ERROR')
}

sourceNative.balance = String(Number(sourceNative.balance) - amount)
if (destNative) {
destNative.balance = String(Number(destNative.balance) + amount)
} else {
destinationAccount.balances.push({ asset_type: 'native', balance: String(amount) })
}

const hash = `tx-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const ledger = Date.now()

this.payments.push({
from: options.sourceSecret,
to: options.destinationPublicKey,
amount: options.amount,
memo: options.memo,
})

this.transactions.set(hash, { hash, ledger, successful: true, status: 'success' })

return { hash, ledger, successful: true }
}

async verifyTransaction(hash: string): Promise<boolean> {
const tx = this.transactions.get(hash)

return tx?.status === 'success'
}

getAccount(publicKey: string): FundedAccount | undefined {
return this.accounts.get(publicKey)
}

clear(): void {
this.accounts.clear()
this.transactions.clear()
this.payments.length = 0
this.shouldFailOnPayment = false
this.shouldFailOnBalance = false
this.shouldFailOnFund = false
}
}

export const fakeHorizonProvider = new FakeHorizonProvider()
84 changes: 84 additions & 0 deletions tests/fakes/fake-kms.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { SensitiveValue, type StoredStellarKey, type StoreStellarSecretInput, type KmsSecretStore } from '../../src/services/kms/kms-secret-store'

export class FakeKmsProvider implements KmsSecretStore {
readonly storedKeys = new Map<string, StoredStellarKey>()
readonly idempotencyKeys = new Map<string, string>()
readonly accessLog: Array<{ action: string; opaqueReference?: string; idempotencyKey?: string; timestamp: Date }> = []
shouldFailOnStore = false
shouldFailOnLoad = false
shouldFailOnDelete = false
storeFailureError = 'KMS store error'
loadFailureError = 'KMS load error'
deleteFailureError = 'KMS delete error'

async findByIdempotencyKey(idempotencyKey: string): Promise<StoredStellarKey | null> {
this.accessLog.push({ action: 'findByIdempotencyKey', idempotencyKey, timestamp: new Date() })
const opaqueReference = this.idempotencyKeys.get(idempotencyKey)
if (!opaqueReference) return null

return this.storedKeys.get(opaqueReference) ?? null
}

async storeStellarSecret(input: StoreStellarSecretInput): Promise<StoredStellarKey> {
this.accessLog.push({ action: 'storeStellarSecret', idempotencyKey: input.idempotencyKey, timestamp: new Date() })

if (this.shouldFailOnStore) {
throw new Error(this.storeFailureError)
}

const existingRef = this.idempotencyKeys.get(input.idempotencyKey)
if (existingRef) {
const existing = this.storedKeys.get(existingRef)
if (existing) return existing
}

const opaqueReference = `kms-ref-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const stored: StoredStellarKey = {
provider: 'fake-kms',
opaqueReference,
keyVersion: '1',
publicKey: input.publicKey,
}

this.storedKeys.set(opaqueReference, stored)
this.idempotencyKeys.set(input.idempotencyKey, opaqueReference)

return stored
}

async loadStellarSecret(opaqueReference: string): Promise<SensitiveValue | null> {
this.accessLog.push({ action: 'loadStellarSecret', opaqueReference, timestamp: new Date() })

if (this.shouldFailOnLoad) {
throw new Error(this.loadFailureError)
}

const stored = this.storedKeys.get(opaqueReference)
if (!stored) return null

const secret = `S${opaqueReference}-secret-material`

return new SensitiveValue(secret)
}

async deleteStellarSecret(opaqueReference: string): Promise<void> {
this.accessLog.push({ action: 'deleteStellarSecret', opaqueReference, timestamp: new Date() })

if (this.shouldFailOnDelete) {
throw new Error(this.deleteFailureError)
}

this.storedKeys.delete(opaqueReference)
}

clear(): void {
this.storedKeys.clear()
this.idempotencyKeys.clear()
this.accessLog.length = 0
this.shouldFailOnStore = false
this.shouldFailOnLoad = false
this.shouldFailOnDelete = false
}
}

export const fakeKmsProvider = new FakeKmsProvider()
Loading
Loading