diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72452cd..af1d8ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,13 +84,14 @@ jobs: --health-timeout 5s --health-retries 5 env: + NODE_ENV: test DATABASE_URL: postgresql://chainlearn_test:test_password@localhost:5432/chainlearn_test REDIS_URL: redis://localhost:6379 JWT_SECRET: test-secret-key-that-is-at-least-64-characters-long-for-testing-purposes-only STELLAR_NETWORK: testnet STELLAR_HORIZON_URL: https://horizon-testnet.stellar.org STELLAR_SOROBAN_RPC_URL: https://soroban-testnet.stellar.org - STELLAR_PLATFORM_SECRET: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && secrets.STELLAR_PLATFORM_SECRET || '' }} + STELLAR_PLATFORM_SECRET: SBZVMB74Z76QZ3ZQY6ADDING6S5AIJWXE3MVRULCNPG7ZBJRYUX3CBNN STELLAR_QUIZ_CONTRACT_ID: CB6Q2YKQQHH7GV7CU5RZDYM5S5OE2GABYLG5IY6YO5XLBAALBQKXYB53 STELLAR_REWARD_CONTRACT_ID: CBAKHFY4SIBRIVYH2Y2QDUIUZPGYGS4B26YBHC6RLV5QZ7OHH5FOF55T STELLAR_CREDENTIAL_CONTRACT_ID: CD4ZJWLPGYLCYR7G5DZQ4EJWVMMF5VXU5Z2ECRSKGWV6GBV5S3F52K7 diff --git a/src/config/index.ts b/src/config/index.ts index b1710cc..4d8b207 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,4 +1,6 @@ import { z } from "zod"; +import "dotenv/config"; +import { logger } from "../utils/logger.js"; import dotenv from "dotenv"; // Test mode gets its own optional .env.test file (#475), gitignored like @@ -163,6 +165,12 @@ function loadConfig(): Env { // In test mode, warn but don't exit — tests mock what they need. // Merge with process.env so CI-provided values (DATABASE_URL, REDIS_URL, etc.) + // are preserved; only truly missing vars get test defaults. + logger.warn( + { + fieldErrors: result.error.flatten().fieldErrors, + }, + "Missing env vars in test mode (expected if mocking config)" // are preserved; only non-critical vars get obviously-fake test defaults. // Every field is passed through from process.env (populated above by // real environment variables, then .env.test, in that precedence) @@ -178,6 +186,14 @@ function loadConfig(): Env { DATABASE_URL: process.env.DATABASE_URL, REDIS_URL: process.env.REDIS_URL || "redis://localhost:6379", CORS_ORIGINS: process.env.CORS_ORIGINS, + JWT_SECRET: + process.env.JWT_SECRET || "test-secret-key-that-is-at-least-sixty-four-characters-long-for-tests", + STELLAR_HORIZON_URL: process.env.STELLAR_HORIZON_URL || "https://horizon-testnet.stellar.org", + STELLAR_SOROBAN_RPC_URL: process.env.STELLAR_SOROBAN_RPC_URL || "https://soroban-testnet.stellar.org", + STELLAR_PLATFORM_SECRET: process.env.STELLAR_PLATFORM_SECRET || "SBZVMB74Z76QZ3ZQY6ADDING6S5AIJWXE3MVRULCNPG7ZBJRYUX3CBNN", + STELLAR_QUIZ_CONTRACT_ID: process.env.STELLAR_QUIZ_CONTRACT_ID || "CB6Q2YKQQHH7GV7CU5RZDYM5S5OE2GABYLG5IY6YO5XLBAALBQKXYB53", + STELLAR_REWARD_CONTRACT_ID: process.env.STELLAR_REWARD_CONTRACT_ID || "CBAKHFY4SIBRIVYH2Y2QDUIUZPGYGS4B26YBHC6RLV5QZ7OHH5FOF55T", + STELLAR_CREDENTIAL_CONTRACT_ID: process.env.STELLAR_CREDENTIAL_CONTRACT_ID || "CD4ZJWLPGYLCYR7G5DZQ4EJWVMMF5VXU5Z2ECRSKGWV6GBV5S3F52K7", JWT_SECRET: process.env.JWT_SECRET, STELLAR_HORIZON_URL: process.env.STELLAR_HORIZON_URL || @@ -213,9 +229,11 @@ function loadConfig(): Env { process.env.STELLAR_CREDENTIAL_CONTRACT_ID || TEST_FALLBACKS.STELLAR_CREDENTIAL_CONTRACT_ID, }); } - console.error( - "Invalid environment variables:", - result.error.flatten().fieldErrors + logger.error( + { + fieldErrors: result.error.flatten().fieldErrors, + }, + "Invalid environment variables" ); process.exit(1); } diff --git a/src/database/schema.ts b/src/database/schema.ts index 72cdd00..dbd8548 100644 --- a/src/database/schema.ts +++ b/src/database/schema.ts @@ -510,6 +510,10 @@ export const webhookAttempts = pgTable( ] ); +// ─── Type exports for use in services ──────────────────────────────────────── + +export type Webhook = typeof webhooks.$inferSelect; +export type WebhookAttempt = typeof webhookAttempts.$inferSelect; // ─── Course Reports ───────────────────────────────────────────────────────── export const courseReports = pgTable( diff --git a/src/middleware/rate-limit.ts b/src/middleware/rate-limit.ts index 8790565..75b3d9d 100644 --- a/src/middleware/rate-limit.ts +++ b/src/middleware/rate-limit.ts @@ -1,6 +1,7 @@ import type { FastifyRateLimitOptions, RateLimitOptions } from "@fastify/rate-limit"; import type { FastifyRequest } from "fastify"; import { config } from "../config/index.js"; +import type { AuthenticatedRequest } from "./auth.js"; const errorResponseBuilder = ( _request: FastifyRequest, @@ -17,7 +18,7 @@ export function rateLimitOptions(): FastifyRateLimitOptions { timeWindow: config.RATE_LIMIT_WINDOW_MS, keyGenerator: (request: FastifyRequest) => { // Prefer authenticated user id, fall back to IP - const authReq = request as any; + const authReq = request as AuthenticatedRequest; return authReq.authUser?.id ?? request.ip; }, errorResponseBuilder, @@ -51,7 +52,7 @@ export const claimRateLimit: RateLimitOptions = { max: 10, timeWindow: "1 minute", keyGenerator: (request: FastifyRequest) => { - const authReq = request as any; + const authReq = request as AuthenticatedRequest; return authReq.authUser?.id ?? request.ip; }, errorResponseBuilder, @@ -65,7 +66,7 @@ export const batchMintRateLimit: RateLimitOptions = { max: 5, timeWindow: "1 minute", keyGenerator: (request: FastifyRequest) => { - const authReq = request as any; + const authReq = request as AuthenticatedRequest; return authReq.authUser?.id ?? request.ip; }, errorResponseBuilder, diff --git a/src/middleware/response-envelope.ts b/src/middleware/response-envelope.ts index 28468d4..b2c4c39 100644 --- a/src/middleware/response-envelope.ts +++ b/src/middleware/response-envelope.ts @@ -1,4 +1,5 @@ import type { FastifyRequest, FastifyReply } from "fastify"; +import type { VersionedRequest } from "../routes/versioning.js"; import { logger } from "../utils/logger.js"; export async function responseEnvelope( @@ -11,7 +12,7 @@ export async function responseEnvelope( const body = JSON.parse(payload); if (!body.meta) { body.meta = { - version: (request as any).apiVersion ?? "v1", + version: (request as VersionedRequest).apiVersion ?? "v1", timestamp: new Date().toISOString(), requestId: request.id, }; diff --git a/src/middleware/validation.ts b/src/middleware/validation.ts index 57987a2..6ee119e 100644 --- a/src/middleware/validation.ts +++ b/src/middleware/validation.ts @@ -29,7 +29,7 @@ export function validate(schemas: ValidationSchemas) { if (!result.success) { errors.querystring = formatZodErrors(result.error); } else { - request.query = result.data as any; + request.query = result.data as unknown as typeof request.query; } } @@ -38,7 +38,7 @@ export function validate(schemas: ValidationSchemas) { if (!result.success) { errors.params = formatZodErrors(result.error); } else { - request.params = result.data as any; + request.params = result.data as unknown as typeof request.params; } } diff --git a/src/modules/admin/webhook.service.ts b/src/modules/admin/webhook.service.ts index 26d7b18..b2c6ffe 100644 --- a/src/modules/admin/webhook.service.ts +++ b/src/modules/admin/webhook.service.ts @@ -1,8 +1,9 @@ import crypto from "node:crypto"; +import { eq, desc, count, sql } from "drizzle-orm"; import { eq, and, desc, count, lt, or, sql } from "drizzle-orm"; import { db } from "../../config/database.js"; import { webhooks, webhookAttempts } from "../../database/schema.js"; -import { NotFoundError, ConflictError } from "../../utils/errors.js"; +import { NotFoundError } from "../../utils/errors.js"; import { logger } from "../../utils/logger.js"; import { auditLog } from "../../audit/index.js"; import { decodeCursor, encodeCursor } from "../../utils/cursor-pagination.js"; @@ -11,7 +12,9 @@ import type { UpdateWebhookBody, WebhookResponse, WebhookAttemptResponse, + WebhookStats, } from "./webhook.types.js"; +import type { Webhook, WebhookAttempt } from "../../database/schema.js"; export class WebhookService { /** @@ -59,7 +62,7 @@ export class WebhookService { throw new NotFoundError("Webhook"); } - const updates: any = { + const updates: Partial = { updatedAt: new Date(), }; @@ -245,7 +248,7 @@ export class WebhookService { /** * Get webhook statistics (success/failure counts, etc.). */ - async getWebhookStats(webhookId: string): Promise { + async getWebhookStats(webhookId: string): Promise { const [webhook] = await db .select() .from(webhooks) @@ -281,7 +284,7 @@ export class WebhookService { }; } - private toResponse(webhook: any): WebhookResponse { + private toResponse(webhook: Webhook): WebhookResponse { return { id: webhook.id, url: webhook.url, @@ -292,7 +295,7 @@ export class WebhookService { }; } - private toAttemptResponse(attempt: any): WebhookAttemptResponse { + private toAttemptResponse(attempt: WebhookAttempt): WebhookAttemptResponse { return { id: attempt.id, webhookId: attempt.webhookId, diff --git a/src/modules/admin/webhook.types.ts b/src/modules/admin/webhook.types.ts index 2532d77..2225220 100644 --- a/src/modules/admin/webhook.types.ts +++ b/src/modules/admin/webhook.types.ts @@ -87,3 +87,12 @@ export interface WebhookAttemptResponse { retryCount: number; createdAt: Date; } + +export interface WebhookStats { + webhookId: string; + totalAttempts: number; + succeeded: number; + failed: number; + pending: number; + successRate: number; +} diff --git a/src/routes/versioning.ts b/src/routes/versioning.ts index 6080d1a..409a443 100644 --- a/src/routes/versioning.ts +++ b/src/routes/versioning.ts @@ -1,7 +1,11 @@ -import type { FastifyInstance } from "fastify"; +import type { FastifyInstance, FastifyRequest } from "fastify"; import { registerV1Routes } from "./v1/index.js"; import { responseEnvelope } from "../middleware/response-envelope.js"; +export interface VersionedRequest extends FastifyRequest { + apiVersion: string; +} + export function cacheControlHeader( url: string, statusCode: number = 200, @@ -31,7 +35,7 @@ export async function registerVersionedRoutes(app: FastifyInstance) { app.register( async function v1(app) { app.addHook("onRequest", async (request) => { - (request as any).apiVersion = "v1"; + (request as VersionedRequest).apiVersion = "v1"; }); app.addHook("onSend", async (request, reply, payload) => { const header = cacheControlHeader(request.url, reply.statusCode); diff --git a/src/services/webhook-dispatcher.ts b/src/services/webhook-dispatcher.ts index a07b61f..6e9911c 100644 --- a/src/services/webhook-dispatcher.ts +++ b/src/services/webhook-dispatcher.ts @@ -1,8 +1,9 @@ import crypto from "node:crypto"; -import { eq } from "drizzle-orm"; +import { eq, and, lte, isNull } from "drizzle-orm"; import { db } from "../config/database.js"; import { webhooks, webhookAttempts } from "../database/schema.js"; import { logger } from "../utils/logger.js"; +import type { WebhookPayload } from "../modules/admin/webhook.types.js"; import { getRequestId } from "../utils/request-context.js"; import type { WebhookPayload, WebhookEventType } from "../modules/admin/webhook.types.js"; @@ -159,7 +160,7 @@ export async function dispatchWebhook( .values({ webhookId: webhook.id, event: payload.event, - payload: payload as any, + payload: payload as unknown as Record, statusCode: result.statusCode ?? null, errorMessage: result.error ?? null, succeededAt: result.success ? new Date() : null, @@ -233,11 +234,11 @@ export async function processWebhookRetries(): Promise { .select() .from(webhookAttempts) .where( - (col: any) => - col("next_retry_at") && - col("next_retry_at") <= now && - !col("succeeded_at") && - !col("failed_at") + and( + lte(webhookAttempts.nextRetryAt, now), + isNull(webhookAttempts.succeededAt), + isNull(webhookAttempts.failedAt) + ) ); if (readyForRetry.length === 0) return; diff --git a/src/stellar/client.ts b/src/stellar/client.ts index 348358d..fa7cef1 100644 --- a/src/stellar/client.ts +++ b/src/stellar/client.ts @@ -97,6 +97,19 @@ export class StellarClient { ); logger.info({ requestId, hash: result.hash }, "Transaction submitted successfully"); return result; + } catch (err: unknown) { + const unknownErr = err as { response?: { data?: { extras?: { result_codes?: unknown; envelope_xdr?: string } } } }; + const extras = unknownErr.response?.data?.extras; + if (extras) { + logger.error( + { resultCodes: extras.result_codes, envelope: extras.envelope_xdr }, + "Transaction failed", + ); + } + throw new StellarError( + extras?.result_codes + ? `Tx failed: ${JSON.stringify(extras.result_codes)}` + : "Transaction submission failed", } catch (err) { const clientError = toStellarClientError(err, "Transaction submission failed"); logger.error( @@ -152,6 +165,14 @@ export class StellarClient { "read" ); return true; + } catch (err: unknown) { + const unknownErr = err as { response?: { status?: number }; status?: number; message?: string }; + const status = unknownErr.response?.status ?? unknownErr.status; + if (status === 404) return false; + logger.error({ err, publicKey }, "accountExists check failed"); + throw new StellarError( + `Could not verify account ${publicKey}: ${unknownErr.message ?? String(err)}`, + ); } catch (err) { if (getHttpStatus(err) === 404) return false; const clientError = toStellarClientError(err, `Could not verify account ${publicKey}`); @@ -184,6 +205,12 @@ export class StellarClient { return { status: "SUCCESS" }; } return { status: "FAILED" }; + } catch (err: unknown) { + const unknownErr = err as { response?: { status?: number }; status?: number }; + const status = unknownErr.response?.status ?? unknownErr.status; + if (status === 404) return { status: "NOT_FOUND" }; + logger.error({ err, txHash }, "getTransaction failed"); + throw new StellarError(`Could not fetch transaction ${txHash}`); } catch (err) { if (getHttpStatus(err) === 404) return { status: "NOT_FOUND" }; const clientError = toStellarClientError(err, `Could not fetch transaction ${txHash}`); @@ -252,6 +279,7 @@ export class StellarClient { try { // Use a shorter timeout for health checks (3s) to fail fast if RPC is unreachable await withTimeout(this.soroban.getLatestLedger(), 3_000); + } catch (err: unknown) { } catch (err) { const message = err instanceof Error ? err.message : String(err); logger.warn({ message }, "Soroban RPC health check failed"); diff --git a/src/stellar/transactions.ts b/src/stellar/transactions.ts index f2583f0..e1fc842 100644 --- a/src/stellar/transactions.ts +++ b/src/stellar/transactions.ts @@ -4,7 +4,6 @@ import { getNetworkPassphrase, getSorobanServer, } from "../config/stellar.js"; -import { config } from "../config/index.js"; import { stellarClient } from "./client.js"; import { logger } from "../utils/logger.js"; import { StellarError, StellarClientError } from "../utils/errors.js"; @@ -27,6 +26,23 @@ function isBadSeqError(err: StellarError): boolean { return true; } + // Robust detection: check Horizon response structure + const unknownErr = err as unknown; + if ( + unknownErr && + typeof unknownErr === "object" && + "response" in unknownErr + ) { + const response = (unknownErr as { response?: { status?: number; data?: { extras?: { result_codes?: { transaction?: string } } } } }).response; + if (response?.status === 400) { + const resultCodes = response?.data?.extras?.result_codes; + if (resultCodes?.transaction === "tx_bad_seq") { + return true; + } + } + } + + return false; // Fallback string matching, for a StellarError that didn't go through // toStellarClientError (e.g. constructed directly elsewhere). return err.message.includes("bad_seq") || err.message.includes("tx_bad_seq"); @@ -101,6 +117,7 @@ export async function invokeContract( const result = await stellarClient.submitTransaction(preparedTx); return result.hash; + } catch (err: unknown) { } catch (err) { if (err instanceof StellarError && isBadSeqError(err)) { await sequenceCache.invalidate(keypair.publicKey()); diff --git a/tests/e2e/quiz-feedback.test.ts b/tests/e2e/quiz-feedback.test.ts index 03f06ca..944f429 100644 --- a/tests/e2e/quiz-feedback.test.ts +++ b/tests/e2e/quiz-feedback.test.ts @@ -1,5 +1,4 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import type { FastifyInstance } from "fastify"; import { buildApp } from "../../src/server.js"; describe("Quiz Feedback Customization API (Issue #322)", () => {