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
28 changes: 25 additions & 3 deletions server/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1146,7 +1146,7 @@
"Auth"
],
"summary": "Register a new phone number after recovery",
"description": "Presents the one-time recovery token from POST /api/v1/auth/recover and moves the account to a new phone number (issue #108). Returns a fresh session JWT so the user is signed in immediately.",
"description": "Presents the one-time recovery token from POST /api/v1/auth/recover and moves the account to a new phone number. Sends an OTP to the new phone number and prompts the user to verify ownership via POST /api/v1/auth/verify-otp before a session JWT is issued.",
"security": [],
"requestBody": {
"required": true,
Expand All @@ -1160,11 +1160,22 @@
},
"responses": {
"200": {
"description": "Phone number updated — session JWT issued.",
"description": "Phone number updated — verification prompt returned.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AuthTokenResponse"
"type": "object",
"properties": {
"message": {
"type": "string"
},
"phone": {
"type": "string"
},
"requiresVerification": {
"type": "boolean"
}
}
}
}
}
Expand Down Expand Up @@ -1299,6 +1310,17 @@
"bearerAuth": []
}
],
"parameters": [
{
"name": "Idempotency-Key",
"in": "header",
"required": false,
"description": "Optional idempotency key to prevent duplicate listing creation on retry.",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
Expand Down
19 changes: 19 additions & 0 deletions server/src/config/validateEnv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,25 @@ describe("validateEnv", () => {
expect(result.missingOptional).toEqual(expect.arrayContaining([...OPTIONAL_ENV_VARS]));
});

it("requires REDIS_URL when NODE_ENV is production", () => {
const prodEnvWithoutRedis = {
...validFullEnv,
NODE_ENV: "production",
REDIS_URL: "",
};

const result = validateEnv(prodEnvWithoutRedis);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("REDIS_URL is required in production"))).toBe(true);

const prodEnvWithRedis = {
...validFullEnv,
NODE_ENV: "production",
REDIS_URL: "redis://localhost:6379",
};
expect(validateEnv(prodEnvWithRedis).valid).toBe(true);
});

describe("assertEnvValid", () => {
it("throws an error when required variables are missing", () => {
expect(() => assertEnvValid({})).toThrow(/Missing required environment variables/);
Expand Down
6 changes: 6 additions & 0 deletions server/src/config/validateEnv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ export function validateEnv(
}
}

if (env["NODE_ENV"] === "production" && !env["REDIS_URL"]) {
errors.push(
"[startup] REDIS_URL is required in production to ensure rate limiting and velocity checks persist across restarts and replicas"
);
}

return {
valid: errors.length === 0,
missingRequired,
Expand Down
21 changes: 21 additions & 0 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { errorHandler } from "./middleware/errorHandler";
import { apiVersion } from "./middleware/apiVersion";
import { requestId } from "./middleware/requestId";
import { pool, query } from "./db/pool";
import { cache } from "./services/cache";

import { assertEnvValid } from "./config/validateEnv";

Expand Down Expand Up @@ -48,6 +49,26 @@ if (!isTest) {
});
}

// ---------------------------------------------------------------------------
// Redis connection test on startup
// ---------------------------------------------------------------------------

if (!isTest && (process.env["NODE_ENV"] === "production" || process.env["REDIS_URL"])) {
cache
.checkReady()
.then(() => {
logger.info("[cache] Redis connection validated at startup");
})
.catch((err) => {
logger.fatal(
`[startup] FATAL: Redis is unavailable at startup: ${(err as Error).message}\n` +
"Verify REDIS_URL is reachable. Server exiting."
);
console.error(`[FATAL] Redis is unavailable at startup: ${(err as Error).message}`);
process.exit(1);
});
}

// ---------------------------------------------------------------------------
// App setup
// ---------------------------------------------------------------------------
Expand Down
25 changes: 21 additions & 4 deletions server/src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -852,8 +852,9 @@ export const openApiDocument = {
summary: "Register a new phone number after recovery",
description:
"Presents the one-time recovery token from POST /api/v1/auth/recover " +
"and moves the account to a new phone number (issue #108). Returns a " +
"fresh session JWT so the user is signed in immediately.",
"and moves the account to a new phone number. Sends an OTP to the new phone " +
"number and prompts the user to verify ownership via POST /api/v1/auth/verify-otp " +
"before a session JWT is issued.",
security: [],
requestBody: {
required: true,
Expand All @@ -865,10 +866,17 @@ export const openApiDocument = {
},
responses: {
"200": {
description: "Phone number updated — session JWT issued.",
description: "Phone number updated — verification prompt returned.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/AuthTokenResponse" },
schema: {
type: "object",
properties: {
message: { type: "string" },
phone: { type: "string" },
requiresVerification: { type: "boolean" },
},
},
},
},
},
Expand Down Expand Up @@ -963,6 +971,15 @@ export const openApiDocument = {
"Creates a new trade listing. Calls `create_listing` on the Soroban escrow contract " +
"and stores the resulting on-chain listing ID.",
security: [{ bearerAuth: [] }],
parameters: [
{
name: "Idempotency-Key",
in: "header",
required: false,
description: "Optional idempotency key to prevent duplicate listing creation on retry.",
schema: { type: "string" },
},
],
requestBody: {
required: true,
content: {
Expand Down
84 changes: 84 additions & 0 deletions server/src/routes/admin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import request from "supertest";
import express from "express";
import jwt from "jsonwebtoken";

const JWT_SECRET = "test-secret-key-12345678901234567890123456789012";
process.env["JWT_SECRET"] = JWT_SECRET;

const mockQuery = jest.fn();
jest.mock("../db", () => ({
__esModule: true,
default: {
query: (...args: any[]) => mockQuery(...args),
connect: jest.fn(),
},
}));

jest.mock("../services/stellar", () => ({
resolveDispute: jest.fn(),
}));

jest.mock("../services/fraudDetection", () => ({
FraudDetectionService: {
getFlaggedAccounts: jest.fn(),
},
}));

import adminRouter from "./admin";

const app = express();
app.use(express.json());
app.use("/api/v1/admin", adminRouter);

describe("GET /api/v1/admin/users/:phone", () => {
const adminId = "99999999-9999-9999-9999-999999999999";
const adminToken = jwt.sign(
{ sub: adminId, role: "admin", tokenVersion: 1 },
JWT_SECRET,
{ expiresIn: "1h" }
);

beforeEach(() => {
mockQuery.mockReset();
});

it("normalises phone parameter and returns maskedPhone and phoneHash instead of raw phone", async () => {
// 1st query: authenticate token_version check
mockQuery.mockResolvedValueOnce({ rows: [{ token_version: 1 }] });
// 2nd query: authorize role check
mockQuery.mockResolvedValueOnce({ rows: [{ role: "admin" }] });
// 3rd query: SELECT user WHERE phone = $1
mockQuery.mockResolvedValueOnce({
rows: [
{
id: "user-123",
phone: "+2348012345678",
role: "user",
created_at: "2026-01-01T00:00:00.000Z",
stellar_public_key: null,
balance: 5000,
},
],
});
// 4th query: SELECT trade_offers
mockQuery.mockResolvedValueOnce({ rows: [] });

// Request with unnormalized local phone number
const res = await request(app)
.get("/api/v1/admin/users/08012345678")
.set("Authorization", `Bearer ${adminToken}`);

expect(res.status).toBe(200);
// User query was called with normalized E.164 phone number (+2348012345678)
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("WHERE u.phone = $1"),
["+2348012345678"]
);

// Response contains maskedPhone and phoneHash, not raw phone
expect(res.body.data.maskedPhone).toBe("+234 *** *** 5678");
expect(res.body.data.phoneHash).toBeDefined();
expect(res.body.data.phoneHash.length).toBe(8);
expect(res.body.data.phone).toBeUndefined();
});
});
13 changes: 11 additions & 2 deletions server/src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@
*/

import { Router } from "express";
import { createHash } from "crypto";
import { authenticate } from "../middleware/authenticate";
import { authorize } from "../middleware/authorize";
import { validate } from "../middleware/validate";
import { normalizePhone, maskPhone } from "../utils/phone";
import { QueueService } from "../jobs";
import { SseEmitter } from "../services/sseEmitter";
import pool from "../db";
Expand Down Expand Up @@ -368,6 +370,7 @@ router.get(
authorize("admin"),
async (req, res) => {
const { phone } = req.params;
const normalizedPhone = normalizePhone(phone);

const { rows: userRows } = await pool.query<{
id: string;
Expand All @@ -391,7 +394,7 @@ router.get(
FROM users u
WHERE u.phone = $1
LIMIT 1`,
[phone]
[normalizedPhone]
);

if (!userRows.length) {
Expand All @@ -408,10 +411,16 @@ router.get(
[user.id]
);

const phoneHash = createHash("sha256")
.update(user.phone)
.digest("hex")
.slice(0, 8);

res.status(200).json({
data: {
id: user.id,
phone: user.phone,
maskedPhone: maskPhone(user.phone),
phoneHash,
role: user.role,
registrationDate: user.created_at,
stellarPublicKey: user.stellar_public_key,
Expand Down
Loading
Loading