CI (SIAX Cloud) / integration (push) Has been skipped
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / security (push) Successful in 14s
masterplan-lock / check (pull_request) Successful in 4s
CI (SIAX Cloud) / security (pull_request) Successful in 14s
Security Scan (SIAX Cloud) / security (push) Successful in 30s
CI (SIAX Cloud) / quality (push) Successful in 35s
CI (SIAX Cloud) / integration (pull_request) Successful in 25s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 54s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m31s
101 lines
4.8 KiB
TypeScript
101 lines
4.8 KiB
TypeScript
/**
|
|
* Regression tests for the anonymous-access finding on `/api/orders` (GET)
|
|
* and `/api/admin/metrics` (GET): a live compliance audit confirmed both
|
|
* responded HTTP 200 with zero authentication (the order queue + financial
|
|
* pipeline aggregates were readable by anyone). Both are outside
|
|
* middleware.ts's matcher (`/api/0pt/:path*` only), so each handler now
|
|
* calls `requireRouteAuth` (src/auth/require-route-auth.ts -> the same
|
|
* fail-closed `resolveAuthContext`/Zitadel verifier `/api/0pt/*` relies on)
|
|
* directly.
|
|
*
|
|
* No DATABASE_URL is set in this test env, so lib/orders-store.ts falls back
|
|
* to its in-memory store -- these tests only exercise the auth gate, not
|
|
* order persistence.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { NextRequest } from 'next/server';
|
|
import { GET as ordersGET } from '@/app/api/orders/route';
|
|
import { GET as orderGET } from '@/app/api/orders/[id]/route';
|
|
import { GET as adminMetricsGET } from '@/app/api/admin/metrics/route';
|
|
import { POST as depositPOST } from '@/app/api/payments/[order_id]/deposit/route';
|
|
|
|
function req(url: string, headers: Record<string, string> = {}): NextRequest {
|
|
return new NextRequest(new URL(url, 'http://localhost'), { headers });
|
|
}
|
|
|
|
describe('anonymous-access regression: /api/orders (GET) and /api/admin/metrics (GET)', () => {
|
|
const saved = { auth: process.env.AUTH_ENFORCE, node: process.env.NODE_ENV };
|
|
|
|
beforeEach(() => {
|
|
// Default posture: AUTH_ENFORCE unset => enforced (fail-closed), matching
|
|
// production and matching how resolveAuthContext's own tests run.
|
|
delete process.env.AUTH_ENFORCE;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (saved.auth === undefined) delete process.env.AUTH_ENFORCE;
|
|
else process.env.AUTH_ENFORCE = saved.auth;
|
|
if (saved.node === undefined) delete process.env.NODE_ENV;
|
|
else process.env.NODE_ENV = saved.node;
|
|
});
|
|
|
|
it('REGRESSION: GET /api/orders 401s with no Authorization header (was: 200 + full order list)', async () => {
|
|
const res = await ordersGET(req('/api/orders'));
|
|
expect(res.status).toBe(401);
|
|
const body = await res.json();
|
|
expect(body.error).toBe('OPT_UNAUTHENTICATED');
|
|
});
|
|
|
|
it('GET /api/orders 401s on an invalid/unparseable bearer token', async () => {
|
|
const res = await ordersGET(req('/api/orders', { authorization: 'Bearer not.a.real.jwt' }));
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('GET /api/orders/{id} rejects anonymous and spoofed-tenant reads before loading an order', async () => {
|
|
const params = { params: Promise.resolve({ id: 'order-unknown' }) };
|
|
expect((await orderGET(req('/api/orders/order-unknown'), params)).status).toBe(401);
|
|
expect((await orderGET(req('/api/orders/order-unknown', {
|
|
'x-0pt-verified-tenant': 'tenant_attacker',
|
|
}), params)).status).toBe(401);
|
|
});
|
|
|
|
it('deposit checkout rejects anonymous callers before checking Stripe credentials or order id', async () => {
|
|
const request = req('/api/payments/order-unknown/deposit');
|
|
const result = await depositPOST(request, { params: Promise.resolve({ order_id: 'order-unknown' }) });
|
|
expect(result.status).toBe(401);
|
|
});
|
|
|
|
it('REGRESSION: GET /api/admin/metrics 401s with no Authorization header (was: 200 + financial aggregates)', async () => {
|
|
const res = await adminMetricsGET(req('/api/admin/metrics'));
|
|
expect(res.status).toBe(401);
|
|
const body = await res.json();
|
|
expect(body.error).toBe('OPT_UNAUTHENTICATED');
|
|
});
|
|
|
|
it('a bare X-Tenant-Id header alone (no bearer token) does not grant access to either route', async () => {
|
|
// Guards against a shortcut implementation that trusts a client-supplied
|
|
// tenant/verified header instead of re-verifying the token.
|
|
const ordersRes = await ordersGET(req('/api/orders', { 'x-tenant-id': 'tenant_attacker' }));
|
|
expect(ordersRes.status).toBe(401);
|
|
|
|
const metricsRes = await adminMetricsGET(req('/api/admin/metrics', { 'x-tenant-id': 'tenant_attacker' }));
|
|
expect(metricsRes.status).toBe(401);
|
|
});
|
|
|
|
it('a spoofed x-0pt-verified-tenant header alone does not grant access (never trusted directly)', async () => {
|
|
// These routes sit outside middleware.ts's matcher, so nothing strips or
|
|
// overwrites this header before the handler sees it -- proving the
|
|
// handler re-verifies via the real JWT check rather than trusting it.
|
|
const res = await ordersGET(req('/api/orders', { 'x-0pt-verified-tenant': 'tenant_attacker' }));
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('AUTH_ENFORCE=false cannot mint/read payable orders without a verified buyer', async () => {
|
|
process.env.AUTH_ENFORCE = 'false';
|
|
const ordersRes = await ordersGET(req('/api/orders'));
|
|
expect(ordersRes.status).toBe(401);
|
|
const metricsRes = await adminMetricsGET(req('/api/admin/metrics'));
|
|
expect(metricsRes.status).toBe(200);
|
|
});
|
|
});
|