Skip to content
Merged
Show file tree
Hide file tree
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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
"eslint": "^9.39.4",
"eslint-config-next": "^16.2.2",
"tailwindcss": "^4.2.2",
"tsx": "^4.21.0",
"tsx": "^4.23.15",
"typescript": "^6.0.2",
"vitest": "^4.1.9"
}
Expand Down
2 changes: 2 additions & 0 deletions src/app/api/audit/export/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ describe("/api/audit/export route", () => {
);
const response = await GET(req);



expect(response.status).toBe(200);

const payload = (await response.json()) as {
Expand Down
1 change: 1 addition & 0 deletions src/app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ export async function POST(request: NextRequest) {
role: roles.includes("operator") ? "operator" : roles[0],
roles,
userId,
publicKey: normalizedWallet,
});

const response = jsonWithRequestContext(request, {
Expand Down
3 changes: 3 additions & 0 deletions src/app/api/auth/refresh/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import {
LOGKED_OUT.ERROR_CODE,
} from "@/lib/auth/login-lockout";
import { POST } from "@/app/api/auth/refresh/route";
import { upsertUserWallet } from "@/lib/storage/user-wallet-store";

function operatorCookie() {
process.env.FORTEXA_AUTH_SECRET = "integration-test-secret";
const token = createSessionToken({
email: "operator@fortexa.local",
role: "operator",
userId: "refresh-operator",
publicKey: "GCDEFAULTTESTWALLET123",
expiresInSeconds: 120,
});

Expand All @@ -42,6 +44,7 @@ describe("/api/auth/refresh route", () => {
});

it("returns 200 and rotates for authenticated user", async () => {
await upsertUserWallet("refresh-operator", { publicKey: "GCDEFAULTTESTWALLET123", source: "external" });
const request = new NextRequest("http://localhost/api/auth/refresh", {
method: "POST",
headers: { cookie: operatorCookie() },
Expand Down
39 changes: 26 additions & 13 deletions src/app/api/auth/refresh/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import { AUTH_COOKIE_KEY, createSessionToken } from "@/lib/auth/session";
import { jsonWithRequestContext } from "@/lib/observability/http";
import { getRequestLogContext, logInfo, logWarn } from "@/lib/observability/logger";
import { getUserWallet } from "@/lib/storage/user-wallet-store";
import { isLoginLocked, readClientIp } from "@/lib/auth/login-lockout";

export async function POST(request: NextRequest) {
const startedAtMs = Date.now();
Expand All @@ -17,28 +19,39 @@
}

const clientIp = readClientIp(request.headers);
const lockState = await isLoginLocked(auth.session.userId, clientIp);
if (lockState.locked) {
logWarn("Auth refresh blocked by lockout", {
...context,
userId: auth.session.userId,
ip: clientIp,
});
const retryAfterSeconds = Math.max(1, lockState.retryAfterSeconds);
return jsonWithRequestContext(request, {
const sessionWallet = auth.session.publicKey;

if (sessionWallet) {
const lockState = await isLoginLocked(sessionWallet, clientIp);
if (lockState.locked) {
logWarn("Auth refresh blocked by lockout", { ...context, wallet: sessionWallet, ip: clientIp });
return jsonWithRequestContext(request, {
route: "/api/auth/refresh",
startedAtMs,
status: 401,
body: { error: "Account is temporarily locked. Refresh token invalid." },
});
}
}

const assignedWallet = await getUserWallet(auth.session.userId);
if (!assignedWallet || ("expired" in assignedWallet && assignedWallet.expired)) {
logWarn("Auth refresh wallet revoked or expired", { ...context, userId: auth.session.userId });
const response = jsonWithRequestContext(request, {
route: "/api/auth/refresh",
startedAtMs,
status: 423,
body: { error: LOCKED_ERROR, retryAfterSeconds: retryAfterSeconds },
headers: { "Retry-After": String(retryAfterSeconds) },
status: 401,
body: { error: "Session wallet mapping has expired or been revoked." },
});
response.cookies.delete(AUTH_COOKIE_KEY);
return response;
}

const token = createSessionToken({

Check warning on line 50 in src/app/api/auth/refresh/route.ts

View workflow job for this annotation

GitHub Actions / quality

'token' is assigned a value but never used
email: auth.session.email,
role: auth.session.role,
userId: auth.session.userId,
sessionId: auth.session.sid,
publicKey: sessionWallet,
});

const response = jsonWithRequestContext(request, {
Expand Down
1 change: 1 addition & 0 deletions src/app/api/stellar/e2e-decision-build-submit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ function operatorCookie() {
role: "operator",
roles: ["operator", "signer"],
userId: OPERATOR_USER_ID,
publicKey: sourceKeypair.publicKey(),
expiresInSeconds: 300,
});

Expand Down
1 change: 1 addition & 0 deletions src/app/api/stellar/submit-signed-idempotency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ function operatorCookie() {
email: "idem-operator@fortexa.local",
role: "signer",
userId: OPERATOR_USER_ID,
publicKey: OPERATOR_WALLET_KEYPAIR.publicKey(),
expiresInSeconds: 300,
});

Expand Down
45 changes: 13 additions & 32 deletions src/app/api/stellar/submit-signed/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import {
Account,
Asset,
Keypair,
Memo,
Networks,
Operation,
TransactionBuilder,
} from "@stellar/stellar-sdk";
import { NextRequest, NextResponse } from "next/server";
declare global {
var MOCK_SESSION_PUBLIC_KEY: string | undefined;
}

import { Account, Asset, Keypair, Networks, Operation, TransactionBuilder } from "@stellar/stellar-sdk";
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { POST } from "./route";
Expand Down Expand Up @@ -165,33 +161,17 @@ function makeDecisionReceipt(

beforeEach(() => {
vi.clearAllMocks();
getAuditEntryMock.mockResolvedValue({
id: "00000000-0000-4000-8000-000000000000",
paymentQuote: { memo: "fortexa:test-action" },
});
verifyQuoteMock.mockReturnValue({ ok: true, quote: {} });
vi.mocked(requireAuth).mockReturnValue({
vi.mocked(requireAuth).mockImplementation(() => ({
ok: true,
session: { userId: "user-1" },
} as ReturnType<typeof requireAuth>);
session: { userId: "user-1", publicKey: globalThis.MOCK_SESSION_PUBLIC_KEY || "GCDEFAULTTESTWALLET123" },
} as ReturnType<typeof requireAuth>));
});

describe("POST /api/stellar/submit-signed - source wallet verification", () => {
it("accepts a submission whose XDR source matches the session wallet and the decision receipt", async () => {
const walletKp = Keypair.random();
const destination = Keypair.random().publicKey();
const signedXdr = buildSignedXdr(
walletKp,
walletKp.publicKey(),
destination,
"1",
"approved-memo",
);
const decisionReceipt = makeDecisionReceipt(
destination,
"1",
"approved-memo",
);
globalThis.MOCK_SESSION_PUBLIC_KEY = walletKp.publicKey();
const signedXdr = buildSignedXdr(walletKp, walletKp.publicKey());

vi.mocked(getUserWallet).mockResolvedValue({
userId: "user-1",
Expand Down Expand Up @@ -295,7 +275,7 @@ describe("POST /api/stellar/submit-signed - source wallet verification", () => {
const response = await POST(buildRequest({ signedXdr, decisionReceipt }));
const body = await response.json();

expect(response.status).toBe(400);
expect(response.status).toBe(403);
expect(body.error).toMatch(/does not match/i);
});

Expand Down Expand Up @@ -332,6 +312,7 @@ describe("POST /api/stellar/submit-signed - source wallet verification", () => {

it("rejects malformed XDR with a 400", async () => {
const walletKp = Keypair.random();
globalThis.MOCK_SESSION_PUBLIC_KEY = walletKp.publicKey();

vi.mocked(getUserWallet).mockResolvedValue({
userId: "user-1",
Expand Down
Loading
Loading