Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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: 2 additions & 2 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"dev": "nodemon src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"test": "echo \"Error: no test specified\" && exit 1"
"test": "ts-node test/run-tests.ts"
},
"keywords": [],
"author": "",
Expand Down Expand Up @@ -47,4 +47,4 @@
"ts-node": "^10.9.2",
"typescript": "^6.0.3"
}
}
}
22 changes: 18 additions & 4 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -42,9 +43,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();
}

Expand All @@ -62,7 +63,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,
Expand Down Expand Up @@ -161,6 +162,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) {
Expand All @@ -178,6 +185,13 @@ process.on("SIGTERM", async () => {
async function bootstrap(): Promise<void> {
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, () => {
Expand Down
96 changes: 96 additions & 0 deletions backend/test/run-tests.ts
Original file line number Diff line number Diff line change
@@ -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<string, { value: string; expiresAt?: number }> = new Map();
lastSetArgs: any = null;
async connect() { }
async quit() { }
async set(key: string, value: string, ...args: any[]) {
// Parse args for EX <seconds> 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);
});