Skip to content
Merged
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
181 changes: 149 additions & 32 deletions tests/integration/auth-jwt-validation.test.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,164 @@
import express from "express";
import crypto from "crypto";
import jwt from "jsonwebtoken";
import { Networks } from "stellar-sdk";
import request from "supertest";

import { createErrorMiddleware } from "../../src/middleware/error.middleware";
import { logger } from "../../src/observability/logger";
import { createInvoiceRouter } from "../../src/routes/invoice.routes";
import { createApp } from "../../src/app";
import { AuthService } from "../../src/services/auth.service";
import type {
ChallengeRepositoryContract,
UserRepositoryContract,
} from "../../src/services/auth.service";
import { User } from "../../src/models/User.model";
import { KYCStatus, UserType } from "../../src/types/enums";

describe("JWT authentication validation", () => {
it("rejects invoice requests without an Authorization header", async () => {
const invoiceService = {
getInvoicesBySellerId: jest.fn(),
// ── In-memory repositories ────────────────────────────────────────────────────

type InMemoryUser = User;

interface InMemoryChallenge {
id: string;
stellarAddress: string;
nonceHash: string;
message: string;
network: string;
issuedAt: Date;
expiresAt: Date;
consumedAt: Date | null;
}

class InMemoryUserRepository implements UserRepositoryContract {
private readonly users = new Map<string, InMemoryUser>();

async findById(id: string) {
return this.users.get(id) ?? null;
}

async findByStellarAddress(stellarAddress: string) {
return (
[...this.users.values()].find(
(user) => user.stellarAddress === stellarAddress,
) ?? null
);
}

async save(user: Partial<InMemoryUser>) {
const now = new Date();
const entity: InMemoryUser = {
id: crypto.randomUUID(),
stellarAddress: user.stellarAddress ?? "",
email: user.email ?? null,
userType: user.userType ?? UserType.INVESTOR,
kycStatus: user.kycStatus ?? KYCStatus.PENDING,
createdAt: user.createdAt ?? now,
updatedAt: user.updatedAt ?? now,
deletedAt: user.deletedAt ?? null,
invoices: user.invoices ?? [],
investments: user.investments ?? [],
transactions: user.transactions ?? [],
kycVerifications: user.kycVerifications ?? [],
notifications: user.notifications ?? [],
};

const app = express();
app.use(express.json());
app.use(
"/api/v1/invoices",
createInvoiceRouter({
invoiceService: invoiceService as any,
config: {
ipfs: {
maxFileSizeMB: 10,
allowedMimeTypes: ["application/pdf"],
uploadRateLimit: {
windowMs: 60_000,
maxUploads: 5,
},
},
kyc: {
skipVerification: true,
},
} as any,
}),
this.users.set(entity.id, entity);
return entity;
}
}

class InMemoryChallengeRepository implements ChallengeRepositoryContract {
readonly challenges = new Map<string, InMemoryChallenge>();

async create(input: InMemoryChallenge) {
const challenge: InMemoryChallenge = {
id: crypto.randomUUID(),
stellarAddress: input.stellarAddress,
nonceHash: input.nonceHash,
message: input.message,
network: input.network,
issuedAt: input.issuedAt,
expiresAt: input.expiresAt,
consumedAt: null,
};

this.challenges.set(challenge.id, challenge);
return challenge;
}

async findByAddressAndNonceHash(stellarAddress: string, nonceHash: string) {
return (
[...this.challenges.values()].find(
(challenge) =>
challenge.stellarAddress === stellarAddress &&
challenge.nonceHash === nonceHash,
) ?? null
);
}

async consume(id: string, consumedAt: Date) {
const challenge = this.challenges.get(id);

if (!challenge || challenge.consumedAt) {
return false;
}

challenge.consumedAt = consumedAt;
return true;
}
}

// ── Test helpers ──────────────────────────────────────────────────────────────

const VALID_JWT_SECRET = "valid-test-secret";

function createTestApp() {
const authService = new AuthService({
userRepository: new InMemoryUserRepository(),
challengeRepository: new InMemoryChallengeRepository(),
config: {
jwt: {
secret: VALID_JWT_SECRET,
expiresIn: "15m",
},
auth: {
challengeTtlMs: 60_000,
},
stellar: {
network: "testnet",
networkPassphrase: Networks.TESTNET,
},
},
});

return createApp({ authService });
}

// ── Tests ─────────────────────────────────────────────────────────────────────

describe("JWT authentication validation", () => {
it("rejects GET /api/v1/auth/me when the JWT is signed with an invalid secret key", async () => {
const app = createTestApp();

// Forge a token signed with the wrong secret
const forgedToken = jwt.sign(
{
sub: "GFORGED_STELLAR_ADDRESS",
stellarAddress: "GFORGED_STELLAR_ADDRESS",
userId: crypto.randomUUID(),
},
"invalid-secret-key",
{ expiresIn: "15m" },
);
app.use(createErrorMiddleware(logger));

const response = await request(app).get("/api/v1/invoices").expect(401);
const response = await request(app)
.get("/api/v1/auth/me")
.set("Authorization", `Bearer ${forgedToken}`)
.expect(401);

expect(response.body).toMatchObject({
success: false,
error: {
message: "Authorization token is required.",
message: "Invalid or expired token.",
},
});
expect(invoiceService.getInvoicesBySellerId).not.toHaveBeenCalled();
});
});
Loading