From 9d07678854e36c831f6cca9f3e302b2bc76ec1e1 Mon Sep 17 00:00:00 2001 From: Zac Lou <97340247+ZacLou@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:07:50 +0800 Subject: [PATCH] feat(admin): GET /admin/users/:id/credentials with on-chain verification (#410) --- src/modules/admin/admin-users.controller.ts | 18 ++ src/modules/admin/admin-users.routes.ts | 20 ++ src/modules/admin/admin.types.ts | 9 + src/modules/credentials/credential.service.ts | 76 +++++++ src/modules/credentials/credential.types.ts | 25 +++ .../unit/admin/admin-user-credentials.test.ts | 203 ++++++++++++++++++ 6 files changed, 351 insertions(+) create mode 100644 tests/unit/admin/admin-user-credentials.test.ts diff --git a/src/modules/admin/admin-users.controller.ts b/src/modules/admin/admin-users.controller.ts index a933a38..a6b3294 100644 --- a/src/modules/admin/admin-users.controller.ts +++ b/src/modules/admin/admin-users.controller.ts @@ -1,6 +1,7 @@ import type { FastifyRequest, FastifyReply } from "fastify"; import { adminUsersService } from "./admin-users.service.js"; import type { ListUsersQuery } from "./admin.types.js"; +import { credentialService } from "../credentials/credential.service.js"; export class AdminUsersController { /** @@ -56,6 +57,23 @@ export class AdminUsersController { data: activities, }); } + + /** + * GET /api/v1/admin/users/:id/credentials + * All credentials for a user with live on-chain verification (#410). + */ + async getCredentials( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply + ): Promise { + const { id } = request.params; + const credentials = await credentialService.getAdminUserCredentials(id); + + reply.send({ + success: true, + data: credentials, + }); + } } export const adminUsersController = new AdminUsersController(); diff --git a/src/modules/admin/admin-users.routes.ts b/src/modules/admin/admin-users.routes.ts index c7d0a1b..8636bb1 100644 --- a/src/modules/admin/admin-users.routes.ts +++ b/src/modules/admin/admin-users.routes.ts @@ -67,4 +67,24 @@ export async function adminUsersRoutes(app: FastifyInstance): Promise { }, (request, reply) => adminUsersController.getActivity(request, reply), ); + + app.get<{ Params: { id: string } }>( + "/:id/credentials", + { + schema: { + description: + "Get all credentials for a user, each verified against Stellar Horizon (admin only, cached 30s, #410)", + tags: ["admin", "users"], + security: [{ bearerAuth: [] }], + params: { + type: "object", + required: ["id"], + properties: { + id: { type: "string", format: "uuid" }, + }, + }, + } as FastifySchema, + }, + (request, reply) => adminUsersController.getCredentials(request, reply), + ); } diff --git a/src/modules/admin/admin.types.ts b/src/modules/admin/admin.types.ts index 9c9ed70..9be71fb 100644 --- a/src/modules/admin/admin.types.ts +++ b/src/modules/admin/admin.types.ts @@ -25,3 +25,12 @@ export interface AdminUserSummary { // were cleared". deletedAt: Date | null; } + +// ─── Admin user credentials listing (#410) ───────────────────────────────── + +// Param shape shared with /:id/ban and /:id/activity — kept local (not +// zod-validated) to mirror the existing admin-users routes, which validate +// only where a body/query exists. +export interface UserIdParams { + id: string; +} diff --git a/src/modules/credentials/credential.service.ts b/src/modules/credentials/credential.service.ts index ed474fb..fcb40f8 100644 --- a/src/modules/credentials/credential.service.ts +++ b/src/modules/credentials/credential.service.ts @@ -17,6 +17,7 @@ import { import { withLock } from "../../utils/lock.js"; import { invokeContract } from "../../stellar/transactions.js"; import { createMintAuthorization } from "../../stellar/signatures.js"; +import { stellarClient } from "../../stellar/client.js"; import { config } from "../../config/index.js"; import { logger } from "../../utils/logger.js"; import crypto from "node:crypto"; @@ -25,6 +26,8 @@ import type { BatchMintResultItem, CredentialListItem, MintResult, + AdminUserCredentialItem, + AdminCredentialVerification, } from "./credential.types.js"; import { auditLog } from "../../audit/index.js"; import { @@ -274,6 +277,79 @@ export class CredentialService { return rows; } + + /** + * All credentials for a user, joined with course titles and each mint + * transaction verified against Stellar Horizon so admins see the live + * on-chain state instead of trusting the stored hash (#410). Cached for + * 30s — the per-credential Horizon calls make uncached loads expensive, + * while 30s matches the read-frequency of a support/verification flow + * without going meaningfully stale. + */ + async getAdminUserCredentials(userId: string): Promise { + const namespace = "credentials"; + const cacheKeyString = cacheKey(namespace, "admin-list", userId); + + const cached = await cacheGet( + namespace, + cacheKeyString, + ); + if (cached) return cached; + + // Reuse the user-facing list() join shape — same table relationships, + // same column set — but do NOT cache-share a key with it: the admin view + // is a different consumer with a different TTL (30s vs 60s). + const rows = await db + .select({ + id: credentials.id, + score: credentials.score, + nftAssetCode: credentials.nftAssetCode, + mintTxHash: credentials.mintTxHash, + revoked: credentials.revoked, + mintedAt: credentials.mintedAt, + courseTitle: courses.title, + }) + .from(credentials) + .innerJoin(courses, eq(credentials.courseId, courses.id)) + .where(eq(credentials.userId, userId)) + .orderBy(desc(credentials.mintedAt)); + + const items: AdminUserCredentialItem[] = await Promise.all( + rows.map(async (row): Promise => { + let verification: AdminCredentialVerification; + if (!row.mintTxHash) { + verification = { kind: "none", status: "unknown" }; + } else if (row.mintTxHash === "pending_indexer_confirmation") { + // Written by the bad-seq recovery path in mint(); nothing to + // look up on Horizon yet — same convention as reward getTransactions. + verification = { kind: "on_chain", status: "pending", ledger: null, confirmations: null }; + } else { + const v = await stellarClient.getHorizonTransaction(row.mintTxHash); + verification = { + kind: "on_chain", + status: v.status, + ledger: v.ledger, + confirmations: v.confirmations, + }; + } + + return { + id: row.id, + courseTitle: row.courseTitle, + score: row.score, + nftAssetCode: row.nftAssetCode, + mintTxHash: row.mintTxHash, + revoked: row.revoked, + mintedAt: row.mintedAt, + verification, + }; + }), + ); + + await cacheSet(cacheKeyString, items, 30); + + return items; + } } export const credentialService = new CredentialService(); diff --git a/src/modules/credentials/credential.types.ts b/src/modules/credentials/credential.types.ts index 576f541..81d98ff 100644 --- a/src/modules/credentials/credential.types.ts +++ b/src/modules/credentials/credential.types.ts @@ -54,3 +54,28 @@ export interface BatchMintResultItem { message: string; }; } + +export interface AdminUserCredentialItem { + id: string; + courseTitle: string; + score: number; + nftAssetCode: string | null; + mintTxHash: string | null; + revoked: boolean; + mintedAt: Date; + verification: + | { kind: "none"; status: "not_minted" | "unknown" } + | { kind: "on_chain"; status: "confirmed" | "pending" | "failed"; ledger: number | null; confirmations: number | null }; +} + +// ─── Admin: user credentials listing (#410) ──────────────────────────────── + +// Verification state resolved per credential in getAdminUserCredentials: +// "not_minted" for rows with no mint tx yet, otherwise the live Horizon +// verification of the stored mint tx hash. Keep in sync with the union on +// AdminUserCredentialItem above. +export type AdminCredentialVerification = + AdminUserCredentialItem["verification"]; + +// Body/route schemas for #410 live in admin.types.ts (admin_users params use +// the plain :id param shape shared by the module). diff --git a/tests/unit/admin/admin-user-credentials.test.ts b/tests/unit/admin/admin-user-credentials.test.ts new file mode 100644 index 0000000..b98bedf --- /dev/null +++ b/tests/unit/admin/admin-user-credentials.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../../src/config/database.js", () => { + const mockDb = { select: vi.fn() }; + return { db: mockDb }; +}); + +vi.mock("../../../src/utils/logger.js", () => ({ + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, +})); + +vi.mock("../../../src/audit/index.js", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../../../src/cache/index.js", () => ({ + cacheGet: vi.fn().mockResolvedValue(null), + cacheSet: vi.fn().mockResolvedValue(undefined), + cacheDel: vi.fn().mockResolvedValue(undefined), + cacheInvalidatePattern: vi.fn().mockResolvedValue(undefined), + cacheKey: (...parts: (string | number)[]) => parts.join(":"), + cacheKeyPattern: (...parts: (string | number)[]) => `${parts.join(":")}:*`, +})); + +vi.mock("../../../src/stellar/client.js", () => ({ + stellarClient: { getHorizonTransaction: vi.fn() }, +})); + +vi.mock("../../../src/utils/lock.js", () => ({ + withLock: vi.fn(async (_key: string, fn: () => Promise) => fn()), +})); + +vi.mock("../../../src/config/index.js", () => ({ + config: {}, +})); + +vi.mock("../../../src/metrics/index.js", () => ({ + stellarTxDurationSeconds: { observe: vi.fn() }, + credentialsMintedTotal: { inc: vi.fn() }, +})); + +vi.mock("../../../src/stellar/transactions.js", () => ({ + invokeContract: vi.fn(), +})); + +vi.mock("../../../src/services/webhook-dispatcher.js", () => ({ + dispatchWebhook: vi.fn(), +})); + +vi.mock("../../../src/services/retry-queue.js", () => ({ + enqueueReward: vi.fn(), +})); + +import { db } from "../../../src/config/database.js"; +import { stellarClient } from "../../../src/stellar/client.js"; +import { credentialService } from "../../../src/modules/credentials/credential.service.js"; + +const mockDb = vi.mocked(db); +const mockHorizon = vi.mocked(stellarClient.getHorizonTransaction); + +function credentialsSelectChain(rows: unknown[]) { + const chain: any = {}; + chain.select = vi.fn().mockReturnValue(chain); + chain.from = vi.fn().mockReturnValue(chain); + chain.innerJoin = vi.fn().mockReturnValue(chain); + chain.where = vi.fn().mockReturnValue(chain); + // Terminal call — the awaited value. + chain.orderBy = vi.fn().mockResolvedValue(rows); + return chain; +} + +describe("CredentialService.getAdminUserCredentials (#410)", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockHorizon.mockReset(); + }); + + it("returns rows with on-chain verification resolved per credential", async () => { + const rows = [ + { + id: "cred-1", + score: 9, + nftAssetCode: "CLABCD1234", + mintTxHash: "txhash-1", + revoked: false, + mintedAt: new Date("2026-08-20T00:00:00Z"), + courseTitle: "Stellar 101", + }, + { + id: "cred-2", + score: 8, + nftAssetCode: null, + mintTxHash: null, + revoked: false, + mintedAt: new Date("2026-08-25T00:00:00Z"), + courseTitle: "Soroban Deep Dive", + }, + ]; + mockDb.select.mockReturnValue(credentialsSelectChain(rows)); + mockHorizon.mockResolvedValueOnce({ + status: "confirmed", + ledger: 12345, + confirmations: 7, + }); + + const items = await credentialService.getAdminUserCredentials("user-1"); + + // One Horizon call for the row with a real hash, none for the null one. + expect(mockHorizon).toHaveBeenCalledTimes(1); + expect(mockHorizon).toHaveBeenCalledWith("txhash-1"); + + expect(items).toHaveLength(2); + expect(items[0]).toMatchObject({ + id: "cred-1", + courseTitle: "Stellar 101", + verification: { + kind: "on_chain", + status: "confirmed", + ledger: 12345, + confirmations: 7, + }, + }); + // A row with no mint tx yet has nothing to verify on-chain — marked unknown. + expect(items[1]).toMatchObject({ + id: "cred-2", + verification: { kind: "none", status: "unknown" }, + }); + }); + + it("maps the pending_indexer_confirmation placeholder to pending without a Horizon lookup", async () => { + const rows = [ + { + id: "cred-p", + score: 7, + nftAssetCode: "CLPEND01", + mintTxHash: "pending_indexer_confirmation", + revoked: false, + mintedAt: new Date("2026-09-01T00:00:00Z"), + courseTitle: "Migrations", + }, + ]; + mockDb.select.mockReturnValue(credentialsSelectChain(rows)); + + const items = await credentialService.getAdminUserCredentials("user-2"); + + // The placeholder is not a real hash — nothing to look up on Horizon. + expect(mockHorizon).not.toHaveBeenCalled(); + expect(items[0].verification).toEqual({ + kind: "on_chain", + status: "pending", + ledger: null, + confirmations: null, + }); + }); + + it("returns the cached payload without touching the database", async () => { + const { cacheGet } = await import("../../../src/cache/index.js"); + const mockedCacheGet = vi.mocked(cacheGet); + mockedCacheGet.mockResolvedValueOnce([ + { + id: "cached-1", + courseTitle: "Cached Course", + score: 10, + nftAssetCode: "CACHED01", + mintTxHash: "tx-cached", + revoked: false, + mintedAt: new Date(), + verification: { kind: "on_chain", status: "confirmed", ledger: 1, confirmations: 2 }, + }, + ] as any); + + const items = await credentialService.getAdminUserCredentials("user-3"); + + expect(mockDb.select).not.toHaveBeenCalled(); + expect(mockHorizon).not.toHaveBeenCalled(); + expect(items).toHaveLength(1); + expect(items[0].courseTitle).toBe("Cached Course"); + + mockedCacheGet.mockReset(); + }); + + it("caches the result for 30s", async () => { + const { cacheSet } = await import("../../../src/cache/index.js"); + const rows = [ + { + id: "cred-4", + score: 6, + nftAssetCode: null, + mintTxHash: null, + revoked: true, + mintedAt: new Date(), + courseTitle: "Revoked Course", + }, + ]; + mockDb.select.mockReturnValue(credentialsSelectChain(rows)); + + await credentialService.getAdminUserCredentials("user-4"); + + const call = vi.mocked(cacheSet).mock.calls.at(-1); + // cacheSet(key, value, ttlSeconds) — the TTL is the third argument. + expect(call?.[2]).toBe(30); + }); +});