diff --git a/backend/package.json b/backend/package.json index 9ef65588..41b5a239 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,11 +7,7 @@ "dev": "nodemon src/index.ts", "build": "tsc", "start": "node dist/index.js", -{ - "scripts": { "test": "node --require ts-node/register --test tests/**/*.test.ts" - } -} }, "keywords": [], "author": "", @@ -51,4 +47,4 @@ "ts-node": "^10.9.2", "typescript": "^6.0.3" } -} +} \ No newline at end of file diff --git a/backend/src/index.ts b/backend/src/index.ts index d4a6bbe3..c9a5f02b 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -4,6 +4,7 @@ import cookieParser from "cookie-parser"; import crypto from "crypto"; import dotenv from "dotenv"; import { prisma, connectWithRetry, startPoolHealthCheck } from "./config/db"; +import { redis } from "./config/redis"; import { trace } from "./config/tracing"; import { intakeRateLimit } from "./middleware/intakeRateLimit"; import { sqlInjectionGuard } from "./middleware/sanitize"; @@ -43,9 +44,9 @@ app.use(express.json()); // CSRF protection middleware (double-submit cookie pattern) const csrfMiddleware = (req: Request, res: Response, next: NextFunction) => { // Skip CSRF for GET/HEAD/OPTIONS and auth challenge/verify routes - if (["GET", "HEAD", "OPTIONS"].includes(req.method) || - (req.path.startsWith("/api/v1/auth/") && - (req.path.endsWith("/challenge") || req.path.endsWith("/verify")))) { + if (["GET", "HEAD", "OPTIONS"].includes(req.method) || + (req.path.startsWith("/api/v1/auth/") && + (req.path.endsWith("/challenge") || req.path.endsWith("/verify")))) { return next(); } @@ -63,7 +64,7 @@ const csrfMiddleware = (req: Request, res: Response, next: NextFunction) => { // Route to get CSRF token app.get("/api/v1/auth/csrf", (req: Request, res: Response) => { const csrfToken = crypto.randomBytes(32).toString("hex"); - + // Set CSRF cookie (HttpOnly false so frontend can read it, SameSite strict) res.cookie(CSRF_COOKIE_NAME, csrfToken, { httpOnly: false, @@ -162,6 +163,12 @@ process.on("SIGTERM", async () => { stopStorageCleanup(); try { await prisma.$disconnect(); + try { + await redis.quit(); + logger.info("Redis connection closed"); + } catch (err: any) { + logger.debug("Error closing Redis on shutdown:", err?.message || err); + } logger.info("Database connection closed"); process.exit(0); } catch (error) { @@ -179,6 +186,13 @@ process.on("SIGTERM", async () => { async function bootstrap(): Promise { try { await connectWithRetry(); + // Ensure Redis client is connected (lazyConnect enabled in config) + try { + await redis.connect(); + console.log("[redis] connected (bootstrap)"); + } catch (err: any) { + console.warn("[redis] could not connect at startup:", err?.message || err); + } startPoolHealthCheck(); startStorageCleanup(); app.listen(port, () => { diff --git a/backend/src/routes/pool-enhanced.ts b/backend/src/routes/pool-enhanced.ts index c3b6dbdb..087ac29e 100644 --- a/backend/src/routes/pool-enhanced.ts +++ b/backend/src/routes/pool-enhanced.ts @@ -60,7 +60,7 @@ router.get("/health", async (req: Request, res: Response) => { const uptime = stats.uptimeSeconds || 0; // Determine overall health status - const isPrimary Healthy = stats.primaryStatus === "healthy"; + const isPrimaryHealthy = stats.primaryStatus === "healthy"; const hasHealthyReplicas = (stats.replicaStatuses || []).some( (s: string) => s === "healthy" ); @@ -95,8 +95,8 @@ router.get("/health", async (req: Request, res: Response) => { connectionPoolUtilization: stats.totalConnections && stats.totalConnections > 0 ? ((stats.totalConnections - (stats.idleConnections || 0)) / - stats.totalConnections) * - 100 + stats.totalConnections) * + 100 : 0, uptime: uptime, timestamp: Date.now(), @@ -141,8 +141,7 @@ router.get("/stats", async (req: Request, res: Response) => { ``, `# HELP lance_pool_active_connections Active database pool connections`, `# TYPE lance_pool_active_connections gauge`, - `lance_pool_active_connections ${ - (stats.totalConnections || 0) - (stats.idleConnections || 0) + `lance_pool_active_connections ${(stats.totalConnections || 0) - (stats.idleConnections || 0) }`, ``, `# HELP lance_pool_waiting_requests Requests waiting for connection`, @@ -155,8 +154,7 @@ router.get("/stats", async (req: Request, res: Response) => { ``, `# HELP lance_pool_health_checks_total Total health checks performed`, `# TYPE lance_pool_health_checks_total counter`, - `lance_pool_health_checks_total ${ - (stats.successfulHealthChecks || 0) + (stats.failedHealthChecks || 0) + `lance_pool_health_checks_total ${(stats.successfulHealthChecks || 0) + (stats.failedHealthChecks || 0) }`, ``, `# HELP lance_pool_health_check_failures_total Failed health checks`, diff --git a/backend/test/run-tests.ts b/backend/test/run-tests.ts new file mode 100644 index 00000000..c8a8f55e --- /dev/null +++ b/backend/test/run-tests.ts @@ -0,0 +1,96 @@ +import assert from "assert"; +import jwt from "jsonwebtoken"; +import crypto from "crypto"; + +// Ensure ts-node can run this file even if project modules expect env vars +process.env.JWT_SECRET = process.env.JWT_SECRET || "test-secret"; + +// Lightweight mock Redis implementation +class MockRedis { + store: Map = new Map(); + lastSetArgs: any = null; + async connect() { } + async quit() { } + async set(key: string, value: string, ...args: any[]) { + // Parse args for EX and NX + let exSeconds: number | undefined = undefined; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === "EX") { + exSeconds = Number(args[i + 1]); + } + } + const expiresAt = exSeconds ? Math.floor(Date.now() / 1000) + exSeconds : undefined; + this.store.set(key, { value, expiresAt }); + this.lastSetArgs = { key, value, exSeconds, args }; + return "OK"; + } + async get(key: string) { + const v = this.store.get(key); + if (!v) return null; + if (v.expiresAt && v.expiresAt < Math.floor(Date.now() / 1000)) { + this.store.delete(key); + return null; + } + return v.value; + } +} + +async function main() { + // Replace the real Redis client with our mock before importing modules + const redisModulePath = "../src/config/redis"; + const redisModule = require(redisModulePath); + const mock = new MockRedis(); + redisModule.redis = mock; + + const auth = require("../src/routes/auth"); + const authGuardModule = require("../src/middleware/authGuard"); + + // Test 1: blacklistToken stores the key and isTokenBlacklisted reads it + const jti = "test-jti-1"; + const expiresAt = Math.floor(Date.now() / 1000) + 60; // expires in 60s + await auth.blacklistToken(jti, expiresAt); + assert(mock.lastSetArgs, "redis.set should have been called"); + assert.strictEqual(mock.lastSetArgs.key, `jwt:blacklist:${jti}`); + + const blacklisted = await auth.isTokenBlacklisted(jti); + assert.strictEqual(blacklisted, true, "Token should be reported blacklisted"); + + // Test 2: TTL matches remaining seconds approximately + const recordedTtl = mock.lastSetArgs.exSeconds; + const expected = expiresAt - Math.floor(Date.now() / 1000); + assert(Math.abs(recordedTtl - expected) <= 2, `TTL should match remaining lifetime (got ${recordedTtl}, expected ${expected})`); + + // Test 3: authGuard rejects a request when token jti is blacklisted + // Create an access token with the same jti and appropriate claims + const secret = process.env.JWT_SECRET as string; + const token = jwt.sign({ address: "GABCDEFGHIJKLMNOPQRSTUVWXYYYYYYYYYYYYYYYYYYYYY" }, secret, { + jwtid: jti, + subject: "GABCDEFGHIJKLMNOPQRSTUVWXYYYYYYYYYYYYYYYYYYYYY", + expiresIn: 60, // 1 minute + issuer: "lance-marketplace", + audience: "lance-frontend", + }); + + // Build fake req/res/next + const req: any = { headers: { authorization: `Bearer ${token}` }, cookies: {} }; + let statusSet: number | null = null; + let jsonBody: any = null; + const res: any = { + status(code: number) { statusSet = code; return this; }, + json(body: any) { jsonBody = body; return this; } + }; + let nextCalled = false; + const next = () => { nextCalled = true; }; + + await authGuardModule.authGuard(req, res, next); + assert.strictEqual(statusSet, 401, "authGuard should respond 401 for blacklisted token"); + assert(jsonBody && jsonBody.error && jsonBody.error.includes("revoked" || "revoked"), "Expected revoked message"); + + console.log("ALL TESTS PASSED"); +} + +main().catch((err) => { + console.error("Tests failed:", err); + process.exit(1); +}); diff --git a/contracts/job_registry/src/lib.rs b/contracts/job_registry/src/lib.rs index dd4a1042..0ade30b2 100644 --- a/contracts/job_registry/src/lib.rs +++ b/contracts/job_registry/src/lib.rs @@ -60,6 +60,21 @@ pub struct JobAssignedIndexEvent { pub final_amount: i128, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BidPlacedIndexEvent { + pub job_id: u64, + pub bidder: Address, + pub amount: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct JobStorageReclaimedEvent { + pub job_id: u64, + pub reclaimer: Address, +} + /* ----------------------------------------------------------------- 3. Smart Contract Implementation ----------------------------------------------------------------- */