Skip to content
Draft
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
9 changes: 9 additions & 0 deletions apps/web-next/.env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,15 @@ FACADE_USE_STUBS=true
# PYMTHOUSE_M2M_CLIENT_SECRET=...
# PYMTHOUSE_BASE_URL=http://localhost:3001 # optional; marketplace link origin when unset elsewhere
#
# Staging (pymthouse) reference — issuer origin https://staging.pymthouse.com.
# Issuer URL is the OIDC mount; the SDK resolves …/.well-known/openid-configuration from it.
# The public + M2M client id are the same public app_… id. The M2M client SECRET is
# provided ONLY via the PYMTHOUSE_M2M_CLIENT_SECRET env/secret store — NEVER commit it.
# PYMTHOUSE_ISSUER_URL=https://staging.pymthouse.com/api/v1/oidc
# PYMTHOUSE_PUBLIC_CLIENT_ID=app_2d89999406f9be57dd0233de
# PYMTHOUSE_M2M_CLIENT_ID=app_2d89999406f9be57dd0233de
# PYMTHOUSE_M2M_CLIENT_SECRET= # set in the secret store only — do not write the value here
#
# Device-flow third-party initiate login: register initiate_login_uri in PymtHouse app settings as
# {NEXT_PUBLIC_APP_URL}/login
# PymtHouse redirects here with ?iss=&target_link_uri=&login_hint= ; NAAP validates iss/origin and
Expand Down
2 changes: 2 additions & 0 deletions apps/web-next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.0.18",
"ajv": "^8.18.0",
"ajv-formats": "^2.1.1",
"autoprefixer": "^10.4.27",
"eslint": "^9.16.0",
"eslint-config-next": "15.5.12",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
/** @vitest-environment node */

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
import type { AuthUser } from '@naap/types';

import { GET, POST } from './route';
import { AdapterNotImplementedError } from '@/lib/billing/adapter';

const isFeatureEnabled = vi.fn();
vi.mock('@/lib/feature-flags', () => ({
isFeatureEnabled: (...a: unknown[]) => isFeatureEnabled(...a),
}));

const getBillingProviderAdapter = vi.fn();
vi.mock('@/lib/billing/registry', () => ({
getBillingProviderAdapter: (...a: unknown[]) => getBillingProviderAdapter(...a),
}));

const validateSession = vi.fn();
vi.mock('@/lib/api/auth', () => ({
validateSession: (...a: unknown[]) => validateSession(...a),
}));

vi.mock('@/lib/api/csrf', () => ({ validateCSRF: vi.fn(() => null) }));
vi.mock('@/lib/api/rate-limit', () => ({ enforceRateLimit: vi.fn(() => null) }));

function authUser(overrides: Partial<AuthUser> = {}): AuthUser {
return {
id: 'user-1',
email: 'u@example.com',
displayName: null,
avatarUrl: null,
address: null,
roles: [],
permissions: [],
...overrides,
};
}

function makeAdapter(overrides: Record<string, unknown> = {}) {
return {
slug: 'pymthouse',
isConfigured: vi.fn(() => true),
validate: vi.fn(),
getPlans: vi.fn(),
getUsageForExternalUser: vi.fn(async () => ({ requestCount: 1 })),
getAppUsage: vi.fn(async () => ({ totals: { requestCount: 0 } })),
mintSignerSession: vi.fn(async () => ({
accessToken: 'tok-abc',
tokenType: 'Bearer',
expiresIn: 3600,
scope: 'sign:job',
})),
receiveCuratedOrchestrators: vi.fn(),
getCapabilityManifest: vi.fn(),
...overrides,
};
}

function req(
url: string,
init?: { method?: string; headers?: Record<string, string> },
): NextRequest {
return new NextRequest(url, {
method: init?.method,
headers: { cookie: 'naap_auth_token=tok', ...(init?.headers ?? {}) },
});
}

function params(provider: string, path: string[]) {
return { params: Promise.resolve({ provider, path }) };
}

beforeEach(() => {
vi.clearAllMocks();
isFeatureEnabled.mockResolvedValue(true);
validateSession.mockResolvedValue(authUser());
getBillingProviderAdapter.mockReturnValue(makeAdapter());
});

describe('generic /api/v1/billing/{provider}/* — flag OFF (zero regression)', () => {
it('is a no-op 404 when provider_adapters is OFF', async () => {
isFeatureEnabled.mockResolvedValue(false);
const res = await GET(
req('http://localhost/api/v1/billing/pymthouse/usage'),
params('pymthouse', ['usage']),
);
expect(res.status).toBe(404);
expect(getBillingProviderAdapter).not.toHaveBeenCalled();
expect(validateSession).not.toHaveBeenCalled();
});
});

describe('generic billing route — flag ON', () => {
it('404s for an unknown provider', async () => {
getBillingProviderAdapter.mockReturnValue(undefined);
const res = await GET(
req('http://localhost/api/v1/billing/nope/usage'),
params('nope', ['usage']),
);
expect(res.status).toBe(404);
});

it('404s for an invalid provider slug', async () => {
const res = await GET(
req('http://localhost/api/v1/billing/Bad_Slug/usage'),
params('Bad_Slug', ['usage']),
);
expect(res.status).toBe(404);
expect(getBillingProviderAdapter).not.toHaveBeenCalled();
});

it('401 without an auth token', async () => {
const res = await GET(
new NextRequest('http://localhost/api/v1/billing/pymthouse/usage'),
params('pymthouse', ['usage']),
);
expect(res.status).toBe(401);
});

it('delegates usage scope=me to the adapter', async () => {
const adapter = makeAdapter();
getBillingProviderAdapter.mockReturnValue(adapter);
const res = await GET(
req('http://localhost/api/v1/billing/pymthouse/usage?scope=me'),
params('pymthouse', ['usage']),
);
expect(res.status).toBe(200);
expect(adapter.getUsageForExternalUser).toHaveBeenCalledWith(
expect.objectContaining({ externalUserId: 'user-1' }),
);
});

it('403 for app scope when not system:admin', async () => {
const res = await GET(
req('http://localhost/api/v1/billing/pymthouse/usage?scope=app'),
params('pymthouse', ['usage']),
);
expect(res.status).toBe(403);
});

it('delegates token mint to the adapter and returns the session', async () => {
const adapter = makeAdapter();
getBillingProviderAdapter.mockReturnValue(adapter);
const res = await POST(
req('http://localhost/api/v1/billing/pymthouse/token', { method: 'POST' }),
params('pymthouse', ['token']),
);
expect(res.status).toBe(200);
expect(adapter.mintSignerSession).toHaveBeenCalledWith(
expect.objectContaining({ externalUserId: 'user-1' }),
);
const json = await res.json();
expect(json.data.access_token).toBe('tok-abc');
});

it('maps AdapterNotImplementedError to 501', async () => {
const adapter = makeAdapter({
mintSignerSession: vi.fn(async () => {
throw new AdapterNotImplementedError('pymthouse', 'mintSignerSession');
}),
});
getBillingProviderAdapter.mockReturnValue(adapter);
const res = await POST(
req('http://localhost/api/v1/billing/pymthouse/token', { method: 'POST' }),
params('pymthouse', ['token']),
);
expect(res.status).toBe(501);
});

it('404s for an unsupported operation', async () => {
const res = await GET(
req('http://localhost/api/v1/billing/pymthouse/unknown-op'),
params('pymthouse', ['unknown-op']),
);
expect(res.status).toBe(404);
});

it('400 when the provider is not configured', async () => {
getBillingProviderAdapter.mockReturnValue(makeAdapter({ isConfigured: vi.fn(() => false) }));
const res = await GET(
req('http://localhost/api/v1/billing/pymthouse/usage'),
params('pymthouse', ['usage']),
);
expect(res.status).toBe(400);
});
});
Loading
Loading