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
14 changes: 11 additions & 3 deletions src/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ describe("POST /api/v1/auth/login", () => {
.post("/api/v1/auth/login")
.expect(401);
expect(res.body.error).toBe("Unauthorized");
expect(res.body.code).toBe("API_KEY_UNAUTHORIZED");
});

it("rejects wrong API key", async () => {
Expand All @@ -49,6 +50,7 @@ describe("POST /api/v1/auth/login", () => {
.set("x-api-key", "wrong-key")
.expect(401);
expect(res.body.error).toBe("Unauthorized");
expect(res.body.code).toBe("API_KEY_UNAUTHORIZED");
});
});

Expand Down Expand Up @@ -82,14 +84,19 @@ describe("POST /api/v1/auth/refresh", () => {
await request(app)
.post("/api/v1/auth/refresh")
.send({ refreshToken })
.expect(401);
.expect(401)
.expect(({ body }) => {
expect(body.code).toBe("REFRESH_TOKEN_INVALID");
});
});

it("rejects empty body", async () => {
await request(app)
const res = await request(app)
.post("/api/v1/auth/refresh")
.send({})
.expect(400);
expect(res.body.code).toBe("VALIDATION_ERROR");
expect(Array.isArray(res.body.details)).toBe(true);
});
});

Expand All @@ -114,9 +121,10 @@ describe("POST /api/v1/auth/logout", () => {

describe("wallet routes with JWT", () => {
it("rejects unauthenticated request", async () => {
await request(app)
const res = await request(app)
.post("/api/v1/wallet/keypair")
.expect(401);
expect(res.body.code).toBe("AUTH_HEADER_INVALID");
});

it("accepts authenticated request with valid Bearer token", async () => {
Expand Down
77 changes: 77 additions & 0 deletions src/__tests__/errorResponses.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import request from "supertest";
import { createApp } from "../app";
import { NotFoundError } from "../errors/appError";
import { GlobeWalletContract } from "../services/contracts/globeWallet";
import { StellarService } from "../services/stellar";

function createMockStellar(overrides: Record<string, unknown> = {}): StellarService {
return {
buildPartialTransaction: jest.fn(),
feeBumpTransaction: jest.fn(),
findStrictReceivePaths: jest.fn(),
findStrictSendPaths: jest.fn(),
generateKeypair: jest.fn(),
getAccount: jest.fn(),
getAccountThresholds: jest.fn(),
getBalances: jest.fn(),
getTransactions: jest.fn(),
pathPaymentStrictReceive: jest.fn(),
pathPaymentStrictSend: jest.fn(),
sendPayment: jest.fn(),
submitWithAdditionalSignatures: jest.fn(),
...overrides,
} as unknown as StellarService;
}

function createMockContract(): GlobeWalletContract {
return {} as GlobeWalletContract;
}

describe("error responses", () => {
it("returns a typed 404 for not found domain errors", async () => {
const app = createApp(
createMockStellar({
getAccount: jest.fn().mockRejectedValue(
new NotFoundError("Account GTEST was not found on Horizon", "ACCOUNT_NOT_FOUND")
),
}),
createMockContract()
);

const res = await request(app)
.get(`/api/v1/account/${"G".repeat(56)}`)
.expect(404);

expect(res.body.code).toBe("ACCOUNT_NOT_FOUND");
expect(res.body.error).toBe("Account GTEST was not found on Horizon");
});

it("does not infer 404 from an unrelated error message substring", async () => {
const app = createApp(
createMockStellar({
getAccount: jest
.fn()
.mockRejectedValue(new Error("Validation failed because field does not exist in payload")),
}),
createMockContract()
);

const res = await request(app)
.get(`/api/v1/account/${"G".repeat(56)}`)
.expect(500);

expect(res.body.code).toBe("INTERNAL_ERROR");
expect(res.body.error).toBe("Validation failed because field does not exist in payload");
});

it("returns a typed validation error body for invalid account parameters", async () => {
const app = createApp(createMockStellar(), createMockContract());

const res = await request(app)
.get(`/api/v1/account/${"M".repeat(56)}`)
.expect(400);

expect(res.body.code).toBe("VALIDATION_ERROR");
expect(res.body.details[0].path).toBe("publicKey");
});
});
10 changes: 10 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { config } from "./config";
import { StellarService } from "./services/stellar";
import { SorobanService } from "./services/soroban";
import { GlobeWalletContract } from "./services/contracts/globeWallet";
import { NotFoundError } from "./errors/appError";

export function createApp(
stellar: StellarService = new StellarService(),
Expand Down Expand Up @@ -44,6 +45,15 @@ export function createApp(
app.use("/api/v1/price", priceRouter);
app.use("/api/v1/auth", authRouter);

app.use((req, _res, next) => {
next(
new NotFoundError(
`Route ${req.method} ${req.originalUrl} was not found`,
"ROUTE_NOT_FOUND"
)
);
});

app.use(errorHandler);

return app;
Expand Down
80 changes: 80 additions & 0 deletions src/errors/appError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
export interface ValidationIssue {
location?: string;
message: string;
path?: string;
type?: string;
value?: unknown;
}

export interface AppErrorOptions {
code: string;
details?: unknown;
retryable?: boolean;
status: number;
}

export class AppError extends Error {
readonly code: string;
readonly details?: unknown;
readonly retryable?: boolean;
readonly status: number;

constructor(message: string, options: AppErrorOptions) {
super(message);
this.name = new.target.name;
this.code = options.code;
this.details = options.details;
this.retryable = options.retryable;
this.status = options.status;
}
}

export class InternalServerError extends AppError {
constructor(message = "Internal server error") {
super(message, { code: "INTERNAL_ERROR", status: 500 });
}
}

export class NotFoundError extends AppError {
constructor(message = "Not found", code = "NOT_FOUND", details?: unknown) {
super(message, { code, details, status: 404 });
}
}

export class UnauthorizedError extends AppError {
constructor(message = "Unauthorized", code = "UNAUTHORIZED", details?: unknown) {
super(message, { code, details, status: 401 });
}
}

export class ValidationError extends AppError {
readonly details: ValidationIssue[];

constructor(issues: ValidationIssue[], message = "Request validation failed") {
super(message, { code: "VALIDATION_ERROR", details: issues, status: 400 });
this.details = issues;
}
}

export class HorizonError extends AppError {
constructor(
message: string,
options: {
code: string;
details?: unknown;
retryable?: boolean;
status?: number;
}
) {
super(message, {
code: options.code,
details: options.details,
retryable: options.retryable,
status: options.status ?? 502,
});
}
}

export function isAppError(error: unknown): error is AppError {
return error instanceof AppError;
}
7 changes: 2 additions & 5 deletions src/middleware/apiKeyAuth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { timingSafeEqual } from "crypto";
import { config } from "../config";
import { UnauthorizedError } from "../errors/appError";

function safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a);
Expand All @@ -9,10 +10,6 @@ function safeEqual(a: string, b: string): boolean {
return timingSafeEqual(bufA, bufB);
}

// DEPRECATED: Gates sensitive routes behind a shared API key.
// Replaced by JWT-based per-user auth. Migrate by calling POST /api/v1/auth/login
// with x-api-key to receive access/refresh tokens, then use Bearer auth.
// Scheduled for removal in a future release.
export function apiKeyAuth(req: Request, res: Response, next: NextFunction) {
console.warn("[DEPRECATED] apiKeyAuth - use POST /api/v1/auth/login and Bearer tokens instead");
res.setHeader("X-Deprecated", "apiKeyAuth - use JWT auth via /api/v1/auth/login");
Expand All @@ -21,7 +18,7 @@ export function apiKeyAuth(req: Request, res: Response, next: NextFunction) {
const provided = req.header("x-api-key");

if (!provided || !safeEqual(provided, expected)) {
res.status(401).json({ error: "Unauthorized" });
next(new UnauthorizedError("Unauthorized", "API_KEY_UNAUTHORIZED"));
return;
}

Expand Down
98 changes: 38 additions & 60 deletions src/middleware/errorHandler.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,39 @@
import { Request, Response, NextFunction } from "express";
import {
LockAcquisitionError,
MemoRequiredError,
NonRetryableHorizonError,
SequenceConflictError,
} from "../services/stellarErrors";
import {
SorobanNotConfiguredError,
SorobanSimulationError,
SorobanTransactionError,
} from "../services/sorobanErrors";
import { NextFunction, Request, Response } from "express";
import { AppError, InternalServerError, isAppError } from "../errors/appError";
import { SorobanTransactionError } from "../services/sorobanErrors";

function toAppError(error: unknown): AppError {
if (isAppError(error)) {
return error;
}

if (error instanceof Error) {
return new InternalServerError(error.message);
}

return new InternalServerError();
}

function toErrorBody(error: AppError) {
const body: Record<string, unknown> = {
code: error.code,
error: error.message,
};

if (error.details !== undefined) {
body.details = error.details;
}

if (error.retryable !== undefined) {
body.retryable = error.retryable;
}

if (error instanceof SorobanTransactionError) {
body.hash = error.hash;
}

return body;
}

export function errorHandler(
err: unknown,
Expand All @@ -18,52 +42,6 @@ export function errorHandler(
_next: NextFunction
) {
console.error(err);

// These carry their own accurate status/code/message — a bare Horizon
// 400 or a generic 500 would hide exactly the information (retryable?
// what happened? what to do?) the caller needs.
if (err instanceof SequenceConflictError) {
res
.status(409)
.json({ error: err.message, code: err.code, retryable: err.retryable });
return;
}
if (err instanceof LockAcquisitionError) {
res
.status(503)
.json({ error: err.message, code: err.code, retryable: err.retryable });
return;
}
if (err instanceof MemoRequiredError) {
if (err instanceof NonRetryableHorizonError) {
res
.status(400)
.json({ error: err.message, code: err.code, retryable: err.retryable });
return;
}
if (err instanceof SorobanNotConfiguredError) {
res
.status(503)
.json({ error: err.message, code: err.code, retryable: err.retryable });
return;
}
if (err instanceof SorobanSimulationError) {
// Simulation caught this before anything was submitted or paid for —
// it's a rejected request, not a server fault.
res
.status(422)
.json({ error: err.message, code: err.code, retryable: err.retryable });
return;
}
if (err instanceof SorobanTransactionError) {
res
.status(502)
.json({ error: err.message, code: err.code, retryable: err.retryable, hash: err.hash });
return;
}

const message = err instanceof Error ? err.message : "Internal server error";
const status =
message.includes("Not Found") || message.includes("does not exist") ? 404 : 500;
res.status(status).json({ error: message });
const error = toAppError(err);
res.status(error.status).json(toErrorBody(error));
}
Loading