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
4 changes: 4 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
"./phone": {
"types": "./phone.d.ts",
"default": "./phone.js"
},
"./units": {
"types": "./units.d.ts",
"default": "./units.js"
}
},
"scripts": {
Expand Down
14 changes: 14 additions & 0 deletions server/migrations/001_add_profile_fields.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
ALTER TABLE users
ADD COLUMN IF NOT EXISTS alias VARCHAR(30),
ADD COLUMN IF NOT EXISTS notifications_enabled BOOLEAN NOT NULL DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS kyc_status VARCHAR(20) NOT NULL DEFAULT 'unverified',
ADD COLUMN IF NOT EXISTS virtual_account_number VARCHAR(64);

ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_kyc_status_check;

ALTER TABLE users
ADD CONSTRAINT users_kyc_status_check
CHECK (kyc_status IN ('unverified', 'pending', 'verified'));

CREATE INDEX IF NOT EXISTS users_kyc_status_idx ON users (kyc_status);
2 changes: 1 addition & 1 deletion server/src/__tests__/trade-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import request from "supertest";
import jwt from "jsonwebtoken";
import { v4 as uuidv4 } from "uuid";
import app from "../index";
import pool from "../db/pool";
import { pool } from "../db/pool";

// Mock the entire stellar service
jest.mock("../services/stellar", () => {
Expand Down
2 changes: 2 additions & 0 deletions server/src/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { pool, query } from "./db/pool";
export { pool as default } from "./db/pool";
32 changes: 32 additions & 0 deletions server/src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,9 @@ export const openApiDocument = {
"maskedPhone",
"createdAt",
"totalTradesCompleted",
"role",
"kycStatus",
"virtualAccountNumber",
"stellarPublicKey",
],
properties: {
Expand All @@ -430,6 +433,8 @@ export const openApiDocument = {
},
createdAt: { type: "string", format: "date-time" },
totalTradesCompleted: { type: "integer", example: 5 },
role: { type: "string", example: "user" },
virtualAccountNumber: { type: "string", example: "0123456789" },
stellarPublicKey: { type: "string", example: "GABC1234..." },
kycStatus: {
type: "string",
Expand Down Expand Up @@ -1402,6 +1407,23 @@ export const openApiDocument = {

// ------------------------------------------------------------------ Profile
"/api/v1/profile": {
patch: {
tags: ["Profile"],
summary: "Update own profile",
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: { "application/json": { schema: {
type: "object",
properties: {
alias: { type: "string", maxLength: 30, pattern: "^[A-Za-z0-9]+$" },
notificationsEnabled: { type: "boolean" },
},
additionalProperties: false,
} } },
},
responses: { "200": { description: "Profile updated." }, "400": { $ref: "#/components/responses/ErrorResponse" }, "401": { $ref: "#/components/responses/Unauthorized" }, "422": { $ref: "#/components/responses/UnprocessableEntity" } },
},
get: {
tags: ["Profile"],
summary: "Get own profile",
Expand Down Expand Up @@ -1607,6 +1629,16 @@ export const openApiDocument = {
},

// ------------------------------------------------------------------ Admin
"/api/v1/admin/users/{id}/kyc": {
patch: {
tags: ["Admin"],
summary: "Update user KYC status",
security: [{ bearerAuth: [] }],
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string", format: "uuid" } }],
requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["status"], properties: { status: { type: "string", enum: ["unverified", "pending", "verified"] } } } } } },
responses: { "200": { description: "KYC status updated." }, "401": { $ref: "#/components/responses/Unauthorized" }, "403": { $ref: "#/components/responses/Forbidden" }, "404": { $ref: "#/components/responses/NotFound" } },
},
},
"/api/v1/admin/queues": {
get: {
tags: ["Admin"],
Expand Down
96 changes: 96 additions & 0 deletions server/src/routes/admin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* admin.test.ts
*
* Tests for the admin-only KYC status endpoint
* (PATCH /api/v1/admin/users/:id/kyc).
*/

import request from "supertest";
import express from "express";
import jwt from "jsonwebtoken";

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

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

import adminRouter from "./admin";

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

describe("PATCH /api/v1/admin/users/:id/kyc", () => {
const userId = "11111111-1111-4111-8111-111111111111";
const token = jwt.sign(
{ sub: userId, stellarPublicKey: "GTEST" },
JWT_SECRET,
{ expiresIn: "1h" }
);

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

it("returns 401 when unauthenticated", async () => {
const res = await request(app).patch(`/api/v1/admin/users/${userId}/kyc`);
expect(res.status).toBe(401);
});

it("requires admin role", async () => {
// 0th query: authenticate's token-revocation check (token_version lookup).
mockQuery.mockResolvedValueOnce({ rows: [{ token_version: 1 }] });

// 1st query: authorize's role lookup.
mockQuery.mockResolvedValueOnce({ rows: [{ role: "user" }] });

const res = await request(app)
.patch(`/api/v1/admin/users/${userId}/kyc`)
.set("Authorization", `Bearer ${token}`)
.send({ status: "verified" });

expect(res.status).toBe(403);
expect(res.body.error).toBe("Admin access required");
});

it("validates KYC status", async () => {
mockQuery.mockResolvedValueOnce({ rows: [{ token_version: 1 }] });
mockQuery.mockResolvedValueOnce({ rows: [{ role: "admin" }] });

const res = await request(app)
.patch(`/api/v1/admin/users/${userId}/kyc`)
.set("Authorization", `Bearer ${token}`)
.send({ status: "approved" });

expect(res.status).toBe(422);
expect(res.body.error).toBe("Invalid KYC status");
});

it("updates a user's KYC status", async () => {
mockQuery.mockResolvedValueOnce({ rows: [{ token_version: 1 }] });
mockQuery.mockResolvedValueOnce({ rows: [{ role: "admin" }] });
mockQuery.mockResolvedValueOnce({
rows: [{ id: userId, kyc_status: "verified" }],
});

const res = await request(app)
.patch(`/api/v1/admin/users/${userId}/kyc`)
.set("Authorization", `Bearer ${token}`)
.send({ status: "verified" });

expect(res.status).toBe(200);
expect(res.body.data.kyc_status).toBe("verified");
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("UPDATE users SET kyc_status"),
["verified", userId]
);
});
});
38 changes: 38 additions & 0 deletions server/src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { authorize } from "../middleware/authorize";
import { validate } from "../middleware/validate";
import { QueueService } from "../jobs";
import { SseEmitter } from "../services/sseEmitter";
import { z } from "zod";
import pool from "../db";
import { resolveDispute } from "../services/stellar";
import {
Expand Down Expand Up @@ -493,4 +494,41 @@ router.post(
}
);

// ---------------------------------------------------------------------------
// PATCH /api/v1/admin/users/:id/kyc (admin only)
// ---------------------------------------------------------------------------

const kycSchema = z.object({
status: z.enum(["unverified", "pending", "verified"]),
});

router.patch(
"/users/:id/kyc",
authenticate,
authorize("admin"),
async (req, res) => {
const parsed = kycSchema.safeParse(req.body);
if (!parsed.success) {
res.status(422).json({
error: "Invalid KYC status",
details: parsed.error.flatten(),
});
return;
}

const { rows } = await pool.query(
`UPDATE users SET kyc_status = $1, updated_at = NOW()
WHERE id = $2
RETURNING id, kyc_status`,
[parsed.data.status, req.params.id]
);

if (!rows.length) {
res.status(404).json({ error: "User not found" });
return;
}
res.status(200).json({ data: rows[0] });
}
);

export default router;
1 change: 1 addition & 0 deletions server/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,5 @@ export function registerRoutes(app: Express): void {
// GET /api/docs.json — always available
// GET /api/docs — Swagger UI (disabled in production unless ENABLE_API_DOCS=true)
app.use('/api', docsRouter);
app.use('/api/v1', docsRouter);
}
120 changes: 119 additions & 1 deletion server/src/routes/profile.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
/**
* profile.test.ts
*
* Tests for GET /api/v1/profile endpoint including referralCode verification.
* Tests for GET /api/v1/profile including referralCode verification,
* plus the PATCH /api/v1/profile update and trade-history query validation
* added in issue #74.
*/

import request from "supertest";
Expand Down Expand Up @@ -52,7 +54,9 @@ describe("GET /api/v1/profile", () => {
phone: "+2348012345678",
created_at: "2026-01-01T00:00:00.000Z",
stellar_public_key: "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H",
role: "user",
kyc_status: "verified",
virtual_account_number: "0123456789",
referral_code: "AIR77REF",
},
],
Expand All @@ -74,6 +78,9 @@ describe("GET /api/v1/profile", () => {
expect(typeof res.body.data.referralCode).toBe("string");
expect(res.body.data.referralCode.length).toBeGreaterThan(0);
expect(res.body.data.totalTradesCompleted).toBe(12);
expect(res.body.data.role).toBe("user");
expect(res.body.data.kycStatus).toBe("verified");
expect(res.body.data.virtualAccountNumber).toBe("0123456789");
});

it("returns 404 when user is not found in database", async () => {
Expand All @@ -91,3 +98,114 @@ describe("GET /api/v1/profile", () => {
expect(res.body.error).toBe("User not found");
});
});

describe("PATCH /api/v1/profile", () => {
const userId = "11111111-1111-4111-8111-111111111111";
const token = jwt.sign(
{ sub: userId, stellarPublicKey: "GTEST" },
JWT_SECRET,
{ expiresIn: "1h" }
);

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

it("returns 401 when unauthenticated", async () => {
const res = await request(app).patch("/api/v1/profile");
expect(res.status).toBe(401);
});

it("rejects invalid profile updates", async () => {
// 0th query: authenticate's token-revocation check (token_version lookup).
mockQuery.mockResolvedValueOnce({ rows: [{ token_version: 1 }] });

const res = await request(app)
.patch("/api/v1/profile")
.set("Authorization", `Bearer ${token}`)
.send({ alias: "not valid!" });

expect(res.status).toBe(422);
});

it("updates only supplied profile fields", async () => {
// 0th query: authenticate's token-revocation check (token_version lookup).
mockQuery.mockResolvedValueOnce({ rows: [{ token_version: 1 }] });

// 1st query: the profile UPDATE itself.
mockQuery.mockResolvedValueOnce({
rows: [{ id: userId, alias: "Alice", notifications_enabled: false }],
});

const res = await request(app)
.patch("/api/v1/profile")
.set("Authorization", `Bearer ${token}`)
.send({ alias: "Alice", notificationsEnabled: false });

expect(res.status).toBe(200);
expect(res.body.data.alias).toBe("Alice");
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("UPDATE users SET"),
["Alice", false, userId]
);
});

it("returns 400 when no profile field is supplied", async () => {
// 0th query: authenticate's token-revocation check (token_version lookup).
mockQuery.mockResolvedValueOnce({ rows: [{ token_version: 1 }] });

const res = await request(app)
.patch("/api/v1/profile")
.set("Authorization", `Bearer ${token}`)
.send({});

expect(res.status).toBe(400);
});
});

describe("GET /api/v1/profile/trades", () => {
const userId = "11111111-1111-4111-8111-111111111111";
const token = jwt.sign(
{ sub: userId, stellarPublicKey: "GTEST" },
JWT_SECRET,
{ expiresIn: "1h" }
);

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

it("rejects an invalid trade-history status filter", async () => {
// 0th query: authenticate's token-revocation check (token_version lookup).
mockQuery.mockResolvedValueOnce({ rows: [{ token_version: 1 }] });

const res = await request(app)
.get("/api/v1/profile/trades?status=unknown")
.set("Authorization", `Bearer ${token}`);

expect(res.status).toBe(400);
expect(res.body.error).toBe("Invalid query parameters");
});

it("returns paginated trade history", async () => {
// 0th query: authenticate's token-revocation check (token_version lookup).
mockQuery.mockResolvedValueOnce({ rows: [{ token_version: 1 }] });

// 1st query: trades, 2nd query: total count.
mockQuery.mockResolvedValueOnce({ rows: [{ id: "trade-1", status: "Active" }] });
mockQuery.mockResolvedValueOnce({ rows: [{ count: "1" }] });

const res = await request(app)
.get("/api/v1/profile/trades?page=1&limit=10&status=Active")
.set("Authorization", `Bearer ${token}`);

expect(res.status).toBe(200);
expect(res.body.data).toHaveLength(1);
expect(res.body.pagination).toEqual({
page: 1,
limit: 10,
total: 1,
totalPages: 1,
});
});
});
Loading