Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
6 changes: 6 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"sonarlint.connectedMode.project": {
"connectionId": "bitcoindefi",
"projectKey": "Bitcoindefi_Open-Stellar"
}
}
6 changes: 5 additions & 1 deletion __tests__/api/explorer/receipts.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { describe, expect, it } from "vitest"
import { describe, expect, it, beforeEach } from "vitest"
import { createX402Quote, listX402ExplorerReceipts, settleX402 } from "@/lib/protocols/x402"
import { resetX402ReceiptStoreForTests } from "@/lib/protocols/x402-receipt-store"

describe("x402 explorer receipts", () => {
beforeEach(() => {
resetX402ReceiptStoreForTests()
})
it("records accepted settlements for explorer queries", () => {
const quote = createX402Quote({
serviceId: "data-api",
Expand Down
18 changes: 18 additions & 0 deletions __tests__/api/protocol/x402-services.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { GET } from '@/app/api/protocol/x402/services/route'

describe('/api/protocol/x402/services API Route', () => {
it('lists registered x402 marketplace services', async () => {
const req = new Request('https://openstellar.org/api/protocol/x402/services')
const res = await GET(req)

expect(res.status).toBe(200)
const json = await res.json()

expect(json.ok).toBe(true)
expect(json.count).toBeGreaterThan(0)
expect(json.services.length).toBeGreaterThan(0)
expect(json.services[0].id).toBeTruthy()
expect(json.services[0].priceXlm).toBeGreaterThan(0)
})
})
37 changes: 37 additions & 0 deletions __tests__/api/protocol/x402-subscriptions-renew.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it, beforeEach } from 'vitest'
import { POST } from '@/app/api/protocol/x402/subscriptions/renew/route'
import { createX402Subscription, resetX402SubscriptionsForTests } from '@/lib/protocols/x402'
import { resetX402SubscriptionStoreForTests } from '@/lib/protocols/x402-subscription-store'

describe('/api/protocol/x402/subscriptions/renew API Route', () => {
beforeEach(() => {
resetX402SubscriptionsForTests()
resetX402SubscriptionStoreForTests()
})

it('triggers recurring renewals and returns summary', async () => {
const pastDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000)
createX402Subscription({
serviceId: 'data-oracle',
agentId: 'agent-bot-77',
plan: 'starter',
now: pastDate,
})

const req = new Request('https://openstellar.org/api/protocol/x402/subscriptions/renew', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
balances: {
'agent-bot-77': 50,
},
}),
})

const res = await POST(req)
expect(res.status).toBe(200)
const json = await res.json()
expect(json.ok).toBe(true)
expect(json.renewedCount).toBe(1)
})
})
88 changes: 88 additions & 0 deletions __tests__/x402-sdk.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, expect, it, beforeEach } from 'vitest'
import { withX402, gateX402Request } from '@/lib/sdk/x402-sdk'
import {
createX402Quote,
createX402Subscription,
resetX402SubscriptionsForTests,
} from '@/lib/protocols/x402'
import { resetX402ReceiptStoreForTests } from '@/lib/protocols/x402-receipt-store'

describe('x402 SDK (withX402)', () => {
beforeEach(() => {
resetX402SubscriptionsForTests()
resetX402ReceiptStoreForTests()
})

it('returns HTTP 402 with quote payload when request is unpaid', async () => {
const handler = withX402(
{ serviceId: 'stellar-oracle', unitPriceUsd: 0.05 },
async () => Response.json({ data: 'oracle payload' }),
)

const req = new Request('https://openstellar.org/api/oracle')
const res = await handler(req)

expect(res.status).toBe(402)
expect(res.headers.get('X-402-Quote-ID')).toBeTruthy()

const body = await res.json()
expect(body.code).toBe(402)
expect(body.serviceId).toBe('stellar-oracle')
expect(body.amountUsd).toBe(0.05)
expect(body.options.length).toBeGreaterThanOrEqual(3)
})

it('allows access when request has an active subscription', async () => {
createX402Subscription({
serviceId: 'stellar-oracle',
agentId: 'agent-007',
plan: 'starter',
callsPerMonth: 100,
})

const handler = withX402(
{ serviceId: 'stellar-oracle', unitPriceUsd: 0.05 },
async () => Response.json({ data: 'oracle payload' }),
)

const req = new Request('https://openstellar.org/api/oracle', {
headers: {
'X-402-Agent-Id': 'agent-007',
},
})
const res = await handler(req)

expect(res.status).toBe(200)
const body = await res.json()
expect(body.data).toBe('oracle payload')
})

it('allows access when valid payment settlement header is supplied', async () => {
const quote = createX402Quote({
serviceId: 'stellar-oracle',
unitPriceUsd: 0.05,
units: 1,
payer: 'agent-999',
chain: 'stellar',
})

const handler = withX402(
{ serviceId: 'stellar-oracle', unitPriceUsd: 0.05 },
async () => Response.json({ data: 'paid content' }),
)

const req = new Request('https://openstellar.org/api/oracle', {
headers: {
'X-402-Payment-Ref': quote.paymentRef,
'X-402-Tx-Hash': '0x1234567890123456789012345678901234567890123456789012345678901234',
'X-402-Chain': 'stellar',
'X-402-Agent-Id': 'agent-999',
},
})

const res = await handler(req)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data).toBe('paid content')
})
})
76 changes: 76 additions & 0 deletions __tests__/x402-ssrf-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it, beforeEach } from 'vitest'
import {
validateWebhookTargetUrl,
isPrivateOrLoopbackHost,
dispatchX402SettlementWebhook,
resetX402WebhookDeliveriesForTests,
} from '@/lib/protocols/x402-webhooks'
import { POST } from '@/app/api/protocol/x402/webhooks/route'

describe('SSRF Guard & Webhook Auth Security', () => {
beforeEach(() => {
resetX402WebhookDeliveriesForTests()
})

it('detects private, loopback, and cloud metadata hostnames', () => {
expect(isPrivateOrLoopbackHost('localhost')).toBe(true)
expect(isPrivateOrLoopbackHost('127.0.0.1')).toBe(true)
expect(isPrivateOrLoopbackHost('169.254.169.254')).toBe(true)
expect(isPrivateOrLoopbackHost('10.0.0.5')).toBe(true)
expect(isPrivateOrLoopbackHost('192.168.1.100')).toBe(true)
expect(isPrivateOrLoopbackHost('172.20.0.1')).toBe(true)
expect(isPrivateOrLoopbackHost('example.internal')).toBe(true)

expect(isPrivateOrLoopbackHost('api.example.com')).toBe(false)
})

it('rejects SSRF target URLs in validateWebhookTargetUrl', () => {
expect(() => validateWebhookTargetUrl('http://169.254.169.254/latest/meta-data')).toThrow(
/private\/loopback\/metadata/
)
expect(() => validateWebhookTargetUrl('http://localhost:8080/admin')).toThrow(
/private\/loopback\/metadata/
)
expect(() => validateWebhookTargetUrl('http://127.0.0.1/internal')).toThrow(
/private\/loopback\/metadata/
)
})

it('prevents SSRF dispatches via dispatchX402SettlementWebhook', async () => {
const log = await dispatchX402SettlementWebhook(
{
accepted: true,
quoteId: 'q_123',
paymentRef: 'ref_123',
settledAt: new Date().toISOString(),
txHash: '0x1234567890123456789012345678901234567890123456789012345678901234',
chain: 'stellar',
},
'http://169.254.169.254/latest/meta-data'
)

expect(log.status).toBe('failed')
expect(log.error).toMatch(/private\/loopback\/metadata/)
})

it('rejects unauthenticated POST requests to /api/protocol/x402/webhooks', async () => {
const req = new Request('https://openstellar.org/api/protocol/x402/webhooks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
receipt: { paymentRef: 'test' },
}),
})

// Set ADMIN_SECRET to simulate production auth requirement
const oldSecret = process.env.ADMIN_SECRET
process.env.ADMIN_SECRET = 'top-secret-key'

try {
const res = await POST(req)
expect(res.status).toBe(401)
} finally {
process.env.ADMIN_SECRET = oldSecret
}
})
})
22 changes: 22 additions & 0 deletions __tests__/x402-stellar-verification.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { verifyStellarPayment } from '@/lib/protocols/x402'

describe('Stellar On-Chain Payment Verification', () => {
it('rejects invalid txHash formats', async () => {
const res = await verifyStellarPayment({
txHash: 'invalid-hash-123',
expectedTo: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF',
})
expect(res.accepted).toBe(false)
expect(res.error).toMatch(/Invalid Stellar txHash format/)
})

it('accepts valid 64-hex txHash format in test environment', async () => {
const validHash = '0x1234567890123456789012345678901234567890123456789012345678901234'
const res = await verifyStellarPayment({
txHash: validHash,
expectedTo: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF',
})
expect(res.accepted).toBe(true)
})
})
62 changes: 62 additions & 0 deletions __tests__/x402-subscription-concurrency.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, it, beforeEach } from 'vitest'
import {
writeSubscriptions,
saveX402SubscriptionStoreRecord,
scheduleSubscriptionFlush,
flushSubscriptionsToDisk,
readSubscriptions,
resetX402SubscriptionStoreForTests,
} from '@/lib/protocols/x402-subscription-store'

describe('Subscription Store Concurrency & Flushing', () => {
beforeEach(() => {
resetX402SubscriptionStoreForTests()
})

it('handles concurrent writeSubscriptions calls sequentially without corruption', async () => {
const promises = Array.from({ length: 10 }).map((_, i) =>
saveX402SubscriptionStoreRecord({
id: `sub_${i}`,
serviceId: `service-${i}`,
agentId: `agent-${i}`,
plan: 'starter',
callsPerMonth: 100,
callsUsed: i,
pricePerMonth: '1 XLM',
status: 'active',
active: true,
createdAt: new Date().toISOString(),
renewsAt: new Date().toISOString(),
lastChargedAt: new Date().toISOString(),
billingEvents: [],
})
)

await Promise.all(promises)
const stored = readSubscriptions()
expect(stored.length).toBe(10)

Check warning on line 37 in __tests__/x402-subscription-concurrency.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer "expect(stored).toHaveLength(10)" over this generic assertion for better reporting; it works on any object with a numeric length property.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AaAbGJlF5bICpC6LbZT_&open=AaAbGJlF5bICpC6LbZT_&pullRequest=480
})

it('flushes debounced subscription updates to disk', async () => {
scheduleSubscriptionFlush({
id: 'sub_flush_1',
serviceId: 'oracle',
agentId: 'agent-hot',
plan: 'pro',
callsPerMonth: 1000,
callsUsed: 42,
pricePerMonth: '20 XLM',
status: 'active',
active: true,
createdAt: new Date().toISOString(),
renewsAt: new Date().toISOString(),
lastChargedAt: new Date().toISOString(),
billingEvents: [],
})

await flushSubscriptionsToDisk()
const stored = readSubscriptions()
expect(stored.length).toBe(1)

Check warning on line 59 in __tests__/x402-subscription-concurrency.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer "expect(stored).toHaveLength(1)" over this generic assertion for better reporting; it works on any object with a numeric length property.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AaAbGJlF5bICpC6LbZUA&open=AaAbGJlF5bICpC6LbZUA&pullRequest=480
expect(stored[0].callsUsed).toBe(42)
})
})
64 changes: 64 additions & 0 deletions __tests__/x402-subscriptions-persistence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, it, beforeEach } from 'vitest'
import {
createX402Subscription,
checkX402Subscription,
renewX402Subscriptions,
resetX402SubscriptionsForTests,
} from '@/lib/protocols/x402'
import {
readSubscriptions,
flushSubscriptionsToDisk,
resetX402SubscriptionStoreForTests,
} from '@/lib/protocols/x402-subscription-store'

describe('x402 Subscriptions Persistence', () => {
beforeEach(() => {
resetX402SubscriptionsForTests()
resetX402SubscriptionStoreForTests()
})

it('persists subscriptions to disk and restores them on reload', async () => {
createX402Subscription({
serviceId: 'oracle-service',
agentId: 'agent-bot-42',
plan: 'starter',
callsPerMonth: 50,
walletBalanceXlm: 10,
})

await flushSubscriptionsToDisk()

const onDisk = readSubscriptions()
expect(onDisk.length).toBe(1)

Check warning on line 32 in __tests__/x402-subscriptions-persistence.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer "expect(onDisk).toHaveLength(1)" over this generic assertion for better reporting; it works on any object with a numeric length property.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AaAbCu5mqAnnN-dvfNy_&open=AaAbCu5mqAnnN-dvfNy_&pullRequest=480
expect(onDisk[0].agentId).toBe('agent-bot-42')
expect(onDisk[0].serviceId).toBe('oracle-service')
expect(onDisk[0].callsPerMonth).toBe(50)

// Consume a call
const consumed = checkX402Subscription('agent-bot-42', 'oracle-service', { consumeCall: true })
expect(consumed.callsRemaining).toBe(49)

await flushSubscriptionsToDisk()

const updatedDisk = readSubscriptions()
expect(updatedDisk[0].callsUsed).toBe(1)
})

it('handles recurring renewal billing cycle deductions', async () => {
const pastDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000)
createX402Subscription({
serviceId: 'oracle-service',
agentId: 'agent-bot-42',
plan: 'starter',
callsPerMonth: 50,
now: pastDate,
})

await flushSubscriptionsToDisk()

// Trigger recurring renewal
const result = renewX402Subscriptions(new Date(), { 'agent-bot-42': 100 })
expect(result.renewed.length).toBe(1)

Check warning on line 61 in __tests__/x402-subscriptions-persistence.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer "expect(result.renewed).toHaveLength(1)" over this generic assertion for better reporting; it works on any object with a numeric length property.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AaAbCu5mqAnnN-dvfNzA&open=AaAbCu5mqAnnN-dvfNzA&pullRequest=480
expect(result.renewed[0].billingEvents.length).toBeGreaterThan(1)
})
})
Loading
Loading