Skip to content
Merged
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
Empty file.
277 changes: 277 additions & 0 deletions backend/src/horizon/__tests__/horizon.integration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
/**
* Horizon proxy integration tests.
*
* Uses mocked fetch and mocked Redis — no real Horizon credentials required.
* Covers: field filtering, rate limiting, 429 response, cache hit, address validation.
*/

import { Test, TestingModule } from "@nestjs/testing";
import { INestApplication, HttpStatus } from "@nestjs/common";
import * as request from "supertest";
import { ConfigModule } from "@nestjs/config";
import { HorizonModule } from "../horizon.module";
import { RedisService } from "../../cache/redis.service";

// ── Fixtures ──────────────────────────────────────────────────────────────────

const VALID_ACCOUNT = "GBCPNZ6S7RK5N4BX6HBXBCX7P5QNBOJZFGDWBZBXCLK5T6KHWOPTLR3I";

const MOCK_HORIZON_RESPONSE = {
_links: {
self: {
href: "https://horizon-testnet.stellar.org/accounts/G.../operations?limit=20",
},
next: {
href: "https://horizon-testnet.stellar.org/accounts/G.../operations?cursor=abc123&limit=20",
},
},
_embedded: {
records: [
{
id: "1234567890",
paging_token: "token-1",
type: "payment",
type_int: 1,
created_at: "2024-01-15T10:00:00Z",
transaction_hash: "aabbcc",
transaction_successful: true,
source_account: VALID_ACCOUNT,
asset_type: "native",
amount: "100.0000000",
from: VALID_ACCOUNT,
to: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
// Fields that must be stripped:
_links: { self: { href: "..." } },
offer_id: "99",
sponsor: "GSOME_SPONSOR",
funder: "GFUNDER",
},
{
// DEX operation — must be filtered out
id: "999",
paging_token: "token-dex",
type: "manage_sell_offer",
type_int: 3,
created_at: "2024-01-15T09:00:00Z",
transaction_hash: "dex-hash",
transaction_successful: true,
source_account: VALID_ACCOUNT,
offer_id: "42",
price: "1.5",
},
],
},
};

// ── Redis mock ────────────────────────────────────────────────────────────────

function createRedisMock() {
const store = new Map<string, string>();
return {
get: jest.fn(async <T>(key: string): Promise<T | null> => {
const val = store.get(key);
return val ? (JSON.parse(val) as T) : null;
}),
set: jest.fn(async (key: string, value: unknown) => {
store.set(key, JSON.stringify(value));
}),
del: jest.fn(async (key: string) => {
store.delete(key);
}),
delPattern: jest.fn(),
getClient: jest.fn(() => ({
multi: jest.fn(() => ({
zremrangebyscore: jest.fn().mockReturnThis(),
zcard: jest.fn().mockReturnThis(),
zadd: jest.fn().mockReturnThis(),
expire: jest.fn().mockReturnThis(),
exec: jest.fn().mockResolvedValue([
[null, 1],
[null, 0], // zcard returns 0 — under limit
[null, 1],
[null, 1],
]),
})),
zremrangebyscore: jest.fn(),
})),
ping: jest.fn(async () => true),
onModuleDestroy: jest.fn(),
};
}

// ── Test setup ────────────────────────────────────────────────────────────────

describe("HorizonController (integration)", () => {
let app: INestApplication;
let redisMock: ReturnType<typeof createRedisMock>;
let fetchSpy: jest.SpyInstance;

beforeEach(async () => {
redisMock = createRedisMock();

// Mock global fetch — no real Horizon call made
fetchSpy = jest.spyOn(global, "fetch").mockResolvedValue({
ok: true,
status: 200,
json: async () => MOCK_HORIZON_RESPONSE,
} as Response);

// Override STELLAR_NETWORK env so network config does not throw
process.env.STELLAR_NETWORK = "testnet";
process.env.STELLAR_NETWORK_PASSPHRASE = "Test SDF Network ; September 2015";

const moduleRef: TestingModule = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
isGlobal: true,
ignoreEnvFile: true,
load: [
() => ({
REDIS_URL: "redis://mock:6379",
STELLAR_NETWORK: "testnet",
STELLAR_NETWORK_PASSPHRASE: "Test SDF Network ; September 2015",
SOROBAN_RPC_URL: "https://soroban-testnet.stellar.org",
CONTRACT_ID: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM",
}),
],
}),
HorizonModule,
],
})
.overrideProvider(RedisService)
.useValue(redisMock)
.compile();

app = moduleRef.createNestApplication();
app.setGlobalPrefix("api");
await app.init();
});

afterEach(async () => {
jest.restoreAllMocks();
await app.close();
});

// ── Field filtering ───────────────────────────────────────────────────────

it("returns only payment operations and strips internal Horizon fields", async () => {
const res = await request(app.getHttpServer())
.get("/api/horizon/transactions")
.query({ account: VALID_ACCOUNT });

expect(res.status).toBe(HttpStatus.OK);
expect(res.body.records).toHaveLength(1); // manage_sell_offer filtered out
const record = res.body.records[0];

// Required fields present
expect(record).toMatchObject({
id: "1234567890",
type: "payment",
amount: "100.0000000",
transaction_successful: true,
});

// Stripped fields must not be present
expect(record).not.toHaveProperty("_links");
expect(record).not.toHaveProperty("offer_id");
expect(record).not.toHaveProperty("sponsor");
expect(record).not.toHaveProperty("funder");
});

it("exposes next_cursor when Horizon provides a next link", async () => {
const res = await request(app.getHttpServer())
.get("/api/horizon/transactions")
.query({ account: VALID_ACCOUNT });

expect(res.status).toBe(HttpStatus.OK);
expect(res.body.next_cursor).toBe("abc123");
});

// ── Response headers must not leak API keys ───────────────────────────────

it("does not expose Authorization or Horizon API key in response headers", async () => {
const res = await request(app.getHttpServer())
.get("/api/horizon/transactions")
.query({ account: VALID_ACCOUNT });

expect(res.headers).not.toHaveProperty("authorization");
expect(res.headers).not.toHaveProperty("x-horizon-api-key");
});

// ── Address validation ────────────────────────────────────────────────────

it("returns 400 for a missing account parameter", async () => {
const res = await request(app.getHttpServer()).get("/api/horizon/transactions");
expect(res.status).toBe(HttpStatus.BAD_REQUEST);
});

it("returns 400 for an invalid Stellar address", async () => {
const res = await request(app.getHttpServer())
.get("/api/horizon/transactions")
.query({ account: "not-a-stellar-address" });

expect(res.status).toBe(HttpStatus.BAD_REQUEST);
});

// ── Cache hit ─────────────────────────────────────────────────────────────

it("serves from cache on second identical request without calling Horizon again", async () => {
await request(app.getHttpServer())
.get("/api/horizon/transactions")
.query({ account: VALID_ACCOUNT });

// Seed the cache with what the first request stored
const cachedValue = { records: [], next_cursor: undefined };
redisMock.get.mockResolvedValueOnce(cachedValue);

await request(app.getHttpServer())
.get("/api/horizon/transactions")
.query({ account: VALID_ACCOUNT });

// fetch should only have been called once (the second served from cache)
expect(fetchSpy).toHaveBeenCalledTimes(1);
});

// ── Rate limiting ─────────────────────────────────────────────────────────

it("returns 429 with Retry-After header when rate limit is exceeded", async () => {
// Make zcard return a count at the limit
redisMock.getClient.mockReturnValue({
multi: jest.fn(() => ({
zremrangebyscore: jest.fn().mockReturnThis(),
zcard: jest.fn().mockReturnThis(),
zadd: jest.fn().mockReturnThis(),
expire: jest.fn().mockReturnThis(),
exec: jest.fn().mockResolvedValue([
[null, 1],
[null, 30], // at limit
[null, 1],
[null, 1],
]),
})),
zremrangebyscore: jest.fn(),
});

const res = await request(app.getHttpServer())
.get("/api/horizon/transactions")
.query({ account: VALID_ACCOUNT });

expect(res.status).toBe(429);
expect(res.headers).toHaveProperty("retry-after");
expect(res.body.error).toBe("Too Many Requests");
});

// ── Horizon upstream failure ──────────────────────────────────────────────

it("returns 502 when Horizon is unreachable", async () => {
fetchSpy.mockRejectedValueOnce(new Error("ECONNREFUSED"));

const res = await request(app.getHttpServer())
.get("/api/horizon/transactions")
.query({ account: VALID_ACCOUNT });

expect(res.status).toBe(HttpStatus.BAD_GATEWAY);
// Error body must not contain any API key
expect(JSON.stringify(res.body)).not.toContain("Bearer");
});
});
68 changes: 68 additions & 0 deletions backend/src/horizon/dto/horizon-transaction.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Horizon Transaction DTOs
*
* Fields forwarded from Horizon and why:
*
* FORWARDED:
* id — unique operation identifier; used by frontend for deduplication
* paging_token — cursor for paginated fetches
* type — operation type string (e.g. "payment", "change_trust")
* type_int — numeric type; easier for frontend switch/case
* created_at — ISO timestamp of the ledger close containing this operation
* transaction_hash — links operation to its parent transaction; shown in explorer links
* transaction_successful — guards UI from displaying failed operations as completed
* source_account — sender address
* asset_type — "native" | "credit_alphanum4" | "credit_alphanum12"
* asset_code — token ticker (undefined for XLM)
* asset_issuer — issuer address (undefined for XLM)
* amount — transfer amount as string (Stellar uses string to avoid float loss)
* from / to — payment parties
*
* STRIPPED:
* _links — Horizon HAL links; internal navigation not needed by frontend
* records[].links — same reason
* funder / account — create_account-specific; not used in payment history view
* starting_balance — create_account-specific
* offer_id — DEX offer internals; not relevant to policy-payment history
* price / price_r — DEX pricing fields
* buying_* / selling_* — DEX asset pair fields
* claimable_balance_id — claimable-balance internals
* sponsor — reserve-sponsoring; not displayed in current UI
* bump_to — bump_sequence internals
* authorize* — change_trust flag fields
* limit — change_trust limit; not shown
* set_flags* / clear_flags* — account flags
* home_domain / thresholds / signers / inflation_dest — account meta changes
*
* HORIZON FINALITY LAG:
* Stellar closes a ledger approximately every 5 seconds. The `created_at` field
* reflects ledger close time, not submission time. Operations are final once
* included in a closed ledger — there is no probabilistic finality.
* However, Horizon ingestion may lag 1–3 ledgers (~5–15 seconds) behind the
* network tip. The frontend should show a "transactions may take up to 15 seconds
* to appear" notice and avoid treating a missing transaction as definitively
* failed until at least 30 seconds have elapsed.
*/

export interface HorizonOperationRecord {
id: string;
paging_token: string;
type: string;
type_int: number;
created_at: string;
transaction_hash: string;
transaction_successful: boolean;
source_account: string;
// Payment / path-payment fields (optional — only present on relevant types)
asset_type?: string;
asset_code?: string;
asset_issuer?: string;
amount?: string;
from?: string;
to?: string;
}

export interface HorizonTransactionResponse {
records: HorizonOperationRecord[];
next_cursor?: string;
}
Loading
Loading