init
This commit is contained in:
commit
c54e888458
67 files changed
+5933
No files matched your search
@@ -0,0 +1,6 @@
|
||||
import { describeAuth } from './auth';
|
||||
import './setup';
|
||||
|
||||
// `describeAuth()` registers its own `describe(...)` block. setup.ts's
|
||||
// top-level beforeAll/afterAll fire once for the file.
|
||||
describeAuth();
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Sequential auth + profile + API key flow against the running hosted-gateway
|
||||
* stack. Mirrors cipherdolls/core/test/auth.ts shape: one `describeAuth()` that
|
||||
* exercises each persona end-to-end, with state shared across `it()` blocks
|
||||
* via module-level `let` bindings.
|
||||
*
|
||||
* What this covers:
|
||||
* - Email + password signup (with verification token echoed back in dev/test).
|
||||
* - Email verification → account activation → 50 xBZZ welcome grant credited.
|
||||
* - JWT-authed /users/me and /accounts/me reads.
|
||||
* - API key creation, listing, JWT-or-key auth interchange, deletion.
|
||||
* - /api-keys returns the persona's keys only.
|
||||
* - Login round-trip returns a fresh JWT for the same user.
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import {
|
||||
api,
|
||||
get,
|
||||
post,
|
||||
del,
|
||||
auth,
|
||||
signupEmail,
|
||||
verifyEmail,
|
||||
loginEmail,
|
||||
createApiKey,
|
||||
waitForQueuesEmpty,
|
||||
} from './helpers';
|
||||
|
||||
const WELCOME_XBZZ_WEI = (50n * 10n ** 18n).toString();
|
||||
|
||||
export function describeAuth(): void {
|
||||
describe('auth + profile + api-keys (e2e)', () => {
|
||||
|
||||
/* ── Alice: full signup → verify → welcome grant ────────────── */
|
||||
|
||||
it('signs up Alice via email/password', async () => {
|
||||
const email = `alice-${Date.now()}@hosted.test`;
|
||||
const password = 'correct-horse-battery-staple';
|
||||
const { jwt, userId, verificationToken } = await signupEmail(email, password);
|
||||
expect(jwt).toBeTruthy();
|
||||
expect(userId).toBeTruthy();
|
||||
expect(verificationToken).toBeTruthy();
|
||||
auth.alice = { email, password, jwt, userId };
|
||||
(auth.alice as any).verificationToken = verificationToken;
|
||||
});
|
||||
|
||||
it('Alice can read /users/me with the issued JWT (email unverified)', async () => {
|
||||
const { status, body } = await get('/users/me', auth.alice.jwt!);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({
|
||||
id: auth.alice.userId,
|
||||
email: auth.alice.email,
|
||||
role: 'USER',
|
||||
emailVerified: false,
|
||||
authProvider: 'email',
|
||||
});
|
||||
});
|
||||
|
||||
it('Alice has no welcome grant yet (account pending email verify)', async () => {
|
||||
await waitForQueuesEmpty();
|
||||
const { status, body } = await get('/accounts/me', auth.alice.jwt!);
|
||||
expect(status).toBe(200);
|
||||
expect(body.balanceXbzzWei).toBe('0');
|
||||
expect(body.reservedXbzzWei).toBe('0');
|
||||
});
|
||||
|
||||
it('Alice verifies her email → balance credited with 50 xBZZ', async () => {
|
||||
const token = (auth.alice as any).verificationToken as string;
|
||||
await verifyEmail(token);
|
||||
await waitForQueuesEmpty();
|
||||
|
||||
const { status, body } = await get('/accounts/me', auth.alice.jwt!);
|
||||
expect(status).toBe(200);
|
||||
expect(body.balanceXbzzWei).toBe(WELCOME_XBZZ_WEI);
|
||||
expect(body.reservedXbzzWei).toBe('0');
|
||||
expect(body.availableXbzzWei).toBe(WELCOME_XBZZ_WEI);
|
||||
|
||||
const me = await get('/users/me', auth.alice.jwt!);
|
||||
expect(me.body.emailVerified).toBe(true);
|
||||
});
|
||||
|
||||
it('Alice can list her ledger and see the welcomeGrant row', async () => {
|
||||
const { status, body } = await get('/accounts/me/ledger', auth.alice.jwt!);
|
||||
expect(status).toBe(200);
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
expect(body.length).toBe(1);
|
||||
expect(body[0].kind).toBe('welcomeGrant');
|
||||
expect(body[0].deltaXbzzWei).toBe(WELCOME_XBZZ_WEI);
|
||||
});
|
||||
|
||||
/* ── API keys ─────────────────────────────────────────────── */
|
||||
|
||||
it('Alice creates an API key', async () => {
|
||||
const { id, key } = await createApiKey(auth.alice.jwt!, 'alice-key');
|
||||
expect(id).toBeTruthy();
|
||||
expect(key.startsWith('t4t-live-')).toBe(true);
|
||||
auth.alice.apiKey = key;
|
||||
auth.alice.apiKeyId = id;
|
||||
});
|
||||
|
||||
it('Alice can read /users/me with her API key (no JWT)', async () => {
|
||||
const { status, body } = await get('/users/me', auth.alice.apiKey);
|
||||
expect(status).toBe(200);
|
||||
expect(body.id).toBe(auth.alice.userId);
|
||||
});
|
||||
|
||||
it('Alice lists /api-keys and sees only her key', async () => {
|
||||
const { status, body } = await get('/api-keys', auth.alice.jwt!);
|
||||
expect(status).toBe(200);
|
||||
expect(body.length).toBe(1);
|
||||
expect(body[0].id).toBe(auth.alice.apiKeyId);
|
||||
expect(body[0].name).toBe('alice-key');
|
||||
// Secret value is never echoed after creation.
|
||||
expect(body[0].key).toBeUndefined();
|
||||
});
|
||||
|
||||
/* ── Bob: isolation ───────────────────────────────────────── */
|
||||
|
||||
it('Bob signs up, verifies, and gets his own welcome grant', async () => {
|
||||
const email = `bob-${Date.now()}@hosted.test`;
|
||||
const password = 'tr0ub4dor&3';
|
||||
const signup = await signupEmail(email, password);
|
||||
await verifyEmail(signup.verificationToken);
|
||||
await waitForQueuesEmpty();
|
||||
const { id: apiKeyId, key: apiKey } = await createApiKey(signup.jwt, 'bob-key');
|
||||
auth.bob = {
|
||||
email,
|
||||
password,
|
||||
jwt: signup.jwt,
|
||||
userId: signup.userId,
|
||||
apiKey,
|
||||
apiKeyId,
|
||||
};
|
||||
|
||||
const accountRes = await get('/accounts/me', signup.jwt);
|
||||
expect(accountRes.body.balanceXbzzWei).toBe(WELCOME_XBZZ_WEI);
|
||||
});
|
||||
|
||||
it("Bob cannot see Alice's API keys", async () => {
|
||||
const { status, body } = await get('/api-keys', auth.bob.jwt!);
|
||||
expect(status).toBe(200);
|
||||
expect(body.length).toBe(1);
|
||||
expect(body[0].id).toBe(auth.bob.apiKeyId);
|
||||
});
|
||||
|
||||
it("Bob cannot delete Alice's API key", async () => {
|
||||
const { status } = await del(`/api-keys/${auth.alice.apiKeyId}`, auth.bob.jwt!);
|
||||
expect(status).toBe(404);
|
||||
});
|
||||
|
||||
/* ── Login round-trip ─────────────────────────────────────── */
|
||||
|
||||
it('Alice logs in with email + password and gets a fresh JWT', async () => {
|
||||
const { jwt, userId } = await loginEmail(auth.alice.email!, auth.alice.password!);
|
||||
expect(userId).toBe(auth.alice.userId);
|
||||
// The new JWT still resolves to the same user.
|
||||
const { status, body } = await get('/users/me', jwt);
|
||||
expect(status).toBe(200);
|
||||
expect(body.id).toBe(auth.alice.userId);
|
||||
});
|
||||
|
||||
it('login with wrong password is rejected', async () => {
|
||||
const { status } = await post('/auth/email/login', {
|
||||
email: auth.alice.email,
|
||||
password: 'WRONG',
|
||||
});
|
||||
expect(status).toBe(401);
|
||||
});
|
||||
|
||||
it('signup with an existing email is rejected (409)', async () => {
|
||||
const { status } = await post('/auth/email/signup', {
|
||||
email: auth.alice.email,
|
||||
password: 'another-password-123',
|
||||
});
|
||||
expect(status).toBe(409);
|
||||
});
|
||||
|
||||
/* ── Unauthed access ──────────────────────────────────────── */
|
||||
|
||||
it('rejects /users/me with no token (401)', async () => {
|
||||
const { status } = await api('GET', '/users/me');
|
||||
expect(status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects /accounts/me with garbage token (401)', async () => {
|
||||
const { status } = await get('/accounts/me', 'not-a-real-token');
|
||||
expect(status).toBe(401);
|
||||
});
|
||||
|
||||
/* ── Key deletion ─────────────────────────────────────────── */
|
||||
|
||||
it('Alice can delete her API key, and it stops working', async () => {
|
||||
const del1 = await del(`/api-keys/${auth.alice.apiKeyId}`, auth.alice.jwt!);
|
||||
expect(del1.status).toBe(200);
|
||||
|
||||
const meAfter = await get('/users/me', auth.alice.apiKey);
|
||||
expect(meAfter.status).toBe(401);
|
||||
|
||||
// JWT still works — we only revoked the key.
|
||||
const meJwt = await get('/users/me', auth.alice.jwt!);
|
||||
expect(meJwt.status).toBe(200);
|
||||
});
|
||||
});
|
||||
}
|
||||
Vendored
+122
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Test bootstrap: gives the operator wallet (and optionally the test provider)
|
||||
* xDAI + xBZZ on the anvil fork. The contracts live on Gnosis mainnet at the
|
||||
* addresses configured via env (managed by the t4t repo), so we don't deploy
|
||||
* anything — we just fund the test wallets against the existing tokens.
|
||||
*
|
||||
* Two cheatcodes:
|
||||
* - `anvil_setBalance(addr, wei)` seeds native xDAI
|
||||
* - `anvil_impersonateAccount(whale)` lets us send `xBZZ.transfer` as the whale
|
||||
*
|
||||
* The whale address is configurable via `XBZZ_WHALE` env (default below is a
|
||||
* known high-balance holder on Gnosis Chain). Refresh from Gnosisscan if the
|
||||
* default ever runs dry.
|
||||
*/
|
||||
import {
|
||||
createPublicClient,
|
||||
createTestClient,
|
||||
createWalletClient,
|
||||
http,
|
||||
parseEther,
|
||||
type Address,
|
||||
type Hex,
|
||||
} from 'viem';
|
||||
import { gnosis } from 'viem/chains';
|
||||
import { privateKeyToAccount } from 'viem/accounts';
|
||||
|
||||
const ERC20_TRANSFER_ABI = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'transfer',
|
||||
stateMutability: 'nonpayable',
|
||||
inputs: [
|
||||
{ name: 'to', type: 'address' },
|
||||
{ name: 'amount', type: 'uint256' },
|
||||
],
|
||||
outputs: [{ type: 'bool' }],
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'balanceOf',
|
||||
stateMutability: 'view',
|
||||
inputs: [{ name: 'a', type: 'address' }],
|
||||
outputs: [{ type: 'uint256' }],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export interface FundConfig {
|
||||
rpcUrl: string;
|
||||
xbzzAddress: Address;
|
||||
whale: Address;
|
||||
recipients: Array<{ address: Address; xdai: bigint; xbzz: bigint }>;
|
||||
}
|
||||
|
||||
export async function fundOperator(cfg: FundConfig): Promise<void> {
|
||||
const transport = http(cfg.rpcUrl);
|
||||
const pub = createPublicClient({ chain: gnosis, transport });
|
||||
const test = createTestClient({ chain: gnosis, transport, mode: 'anvil' });
|
||||
|
||||
// xDAI via setBalance — instant, no impersonation needed.
|
||||
for (const r of cfg.recipients) {
|
||||
await test.setBalance({ address: r.address, value: r.xdai });
|
||||
}
|
||||
|
||||
// xBZZ via impersonation + transfer. The whale must have ≥ Σ xbzz on chain.
|
||||
await test.impersonateAccount({ address: cfg.whale });
|
||||
// Make sure the whale itself has xDAI to pay gas on the transfer.
|
||||
await test.setBalance({ address: cfg.whale, value: parseEther('1') });
|
||||
|
||||
const whaleClient = createWalletClient({
|
||||
chain: gnosis,
|
||||
transport,
|
||||
account: cfg.whale,
|
||||
});
|
||||
for (const r of cfg.recipients) {
|
||||
const hash = await whaleClient.writeContract({
|
||||
address: cfg.xbzzAddress,
|
||||
abi: ERC20_TRANSFER_ABI,
|
||||
functionName: 'transfer',
|
||||
args: [r.address, r.xbzz],
|
||||
});
|
||||
await pub.waitForTransactionReceipt({ hash });
|
||||
}
|
||||
await test.stopImpersonatingAccount({ address: cfg.whale });
|
||||
|
||||
// Sanity-check the funding landed.
|
||||
for (const r of cfg.recipients) {
|
||||
const bal = (await pub.readContract({
|
||||
address: cfg.xbzzAddress,
|
||||
abi: ERC20_TRANSFER_ABI,
|
||||
functionName: 'balanceOf',
|
||||
args: [r.address],
|
||||
})) as bigint;
|
||||
if (bal < r.xbzz) {
|
||||
throw new Error(`funding failed: ${r.address} expected ${r.xbzz}, got ${bal}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: derive the operator address from `OPERATOR_PRIVATE_KEY` and
|
||||
* apply a standard funding amount (1k xBZZ + 1 xDAI).
|
||||
*/
|
||||
export async function fundOperatorFromEnv(extra: Address[] = []): Promise<void> {
|
||||
const operator = privateKeyToAccount(process.env.OPERATOR_PRIVATE_KEY as Hex).address;
|
||||
const recipients = [
|
||||
{ address: operator, xdai: parseEther('1'), xbzz: 1000n * 10n ** 18n },
|
||||
...extra.map((address) => ({
|
||||
address,
|
||||
xdai: parseEther('1'),
|
||||
xbzz: 1000n * 10n ** 18n,
|
||||
})),
|
||||
];
|
||||
await fundOperator({
|
||||
rpcUrl: process.env.GNOSIS_RPC_URL ?? 'http://localhost:8545',
|
||||
xbzzAddress: (process.env.XBZZ_ADDRESS ?? '0xdBF3Ea6F5beE45c02255B2c26a16F300502F68da') as Address,
|
||||
// The "t4t wallet" on Gnosis — a known funded address that's used as the
|
||||
// impersonation source on the fork. Holds xBZZ + xDAI on mainnet, so on
|
||||
// the fork we anvil_impersonateAccount it and transfer to the operator.
|
||||
whale: (process.env.XBZZ_WHALE ?? '0xaCfE00BeA3f8acC8e500c7Ea6421902014778a85') as Address,
|
||||
recipients,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { describeGateway } from './gateway';
|
||||
import './setup';
|
||||
|
||||
describeGateway();
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Chat-completion end-to-end. This is the heaviest spec — requires the full
|
||||
* gateway profile (anvil fork + Bee + a registered t4t-provider + mock-ollama)
|
||||
* AND a funded operator wallet on the fork.
|
||||
*
|
||||
* If the upstream prerequisites (provider registration, xBZZ funding) haven't
|
||||
* been set up the test surfaces the actual failure mode — `no provider matches
|
||||
* model`, 503 on gateway-context boot, etc. — rather than green-washing.
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import {
|
||||
api,
|
||||
get,
|
||||
post,
|
||||
auth,
|
||||
signupEmail,
|
||||
verifyEmail,
|
||||
createApiKey,
|
||||
waitForQueuesEmpty,
|
||||
} from './helpers';
|
||||
|
||||
export function describeGateway(): void {
|
||||
describe('chat completions (e2e)', () => {
|
||||
|
||||
it('signs Carol up + verifies + mints an API key', async () => {
|
||||
const email = `carol-${Date.now()}@hosted.test`;
|
||||
const signup = await signupEmail(email, 'a-strong-password-1234');
|
||||
await verifyEmail(signup.verificationToken);
|
||||
await waitForQueuesEmpty();
|
||||
const { id, key } = await createApiKey(signup.jwt, 'carol-key');
|
||||
auth.carol = {
|
||||
email,
|
||||
password: 'a-strong-password-1234',
|
||||
jwt: signup.jwt,
|
||||
userId: signup.userId,
|
||||
apiKey: key,
|
||||
apiKeyId: id,
|
||||
};
|
||||
});
|
||||
|
||||
it('GET /v1/models lists the catalogue (may be empty until a provider registers)', async () => {
|
||||
const { status, body } = await get('/v1/models', auth.carol.apiKey);
|
||||
// Auth alone gets us a 200; whether the catalogue is empty depends on
|
||||
// whether the t4t-provider container had time to register before this
|
||||
// request fired.
|
||||
expect(status).toBe(200);
|
||||
expect(body).toHaveProperty('object', 'list');
|
||||
expect(Array.isArray(body.data)).toBe(true);
|
||||
});
|
||||
|
||||
it('POST /v1/chat/completions returns a 200 with assistant content (mock-ollama)', async () => {
|
||||
// The mock-ollama always returns "pong (mock-ollama)" regardless of
|
||||
// input, so this assertion only verifies the route + on-chain settle
|
||||
// path, not LLM quality.
|
||||
const { status, body } = await post(
|
||||
'/v1/chat/completions',
|
||||
{
|
||||
model: 'qwen2.5:0.5b',
|
||||
messages: [{ role: 'user', content: 'ping' }],
|
||||
max_tokens: 32,
|
||||
},
|
||||
auth.carol.apiKey,
|
||||
);
|
||||
|
||||
if (status !== 200) {
|
||||
// Surface the upstream error so we know what to fix: provider not
|
||||
// registered, gateway context boot failed, insufficient balance, etc.
|
||||
console.error(`chat completion failed: status=${status} body=${JSON.stringify(body)}`);
|
||||
}
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toHaveProperty('choices');
|
||||
expect(body.choices[0]?.message?.content).toMatch(/pong/);
|
||||
});
|
||||
|
||||
it("debits Carol's balance after the JobClaimed watcher settles", async () => {
|
||||
// The on-chain claimJob fires from the provider after delivering. Our
|
||||
// worker's claimWatcher tails JobClaimed events and runs settleXbzz.
|
||||
// Poll for up to 30s.
|
||||
const deadline = Date.now() + 30_000;
|
||||
let balance = 50n * 10n ** 18n;
|
||||
while (Date.now() < deadline) {
|
||||
const { body } = await get('/accounts/me', auth.carol.jwt!);
|
||||
balance = BigInt(body.balanceXbzzWei);
|
||||
if (balance < 50n * 10n ** 18n) break;
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
expect(balance).toBeLessThan(50n * 10n ** 18n);
|
||||
|
||||
const ledger = await get('/accounts/me/ledger', auth.carol.jwt!);
|
||||
const settle = ledger.body.find((e: any) => e.kind === 'settle');
|
||||
expect(settle).toBeDefined();
|
||||
expect(BigInt(settle.deltaXbzzWei)).toBeLessThan(0n);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { api } from './helpers';
|
||||
|
||||
describe('health', () => {
|
||||
test('GET /health returns ok', async () => {
|
||||
const res = await api('/health');
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { ok: boolean };
|
||||
expect(body.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Shared E2E helpers. Tests run inside the dockerized stack — the api/worker
|
||||
* are reachable as service names on the compose network.
|
||||
*
|
||||
* Pattern lifted from cipherdolls/core/test/helpers.ts: a single `api(...)`
|
||||
* wrapper around fetch, a `waitForQueuesEmpty()` poller for the
|
||||
* model.x.create → BullMQ → processor pipeline, and persona-style auth
|
||||
* records keyed in a shared `auth` table.
|
||||
*/
|
||||
import IORedis from 'ioredis';
|
||||
|
||||
export const BASE_URL = process.env.HOSTED_GATEWAY_URL ?? 'http://localhost:8080';
|
||||
const REDIS_HOST = process.env.REDIS_HOST ?? 'localhost';
|
||||
const REDIS_PORT = parseInt(process.env.REDIS_PORT ?? '6379');
|
||||
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
|
||||
export interface ApiResult<T = any> {
|
||||
status: number;
|
||||
body: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue an HTTP request to the hosted-gateway api. Accepts either a JWT or an
|
||||
* API key (bare uuid OR the `t4t-live-<uuid>` form) as `auth`.
|
||||
*/
|
||||
export async function api<T = any>(
|
||||
method: HttpMethod,
|
||||
path: string,
|
||||
auth?: string,
|
||||
body?: any,
|
||||
): Promise<ApiResult<T>> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (auth) headers.Authorization = `Bearer ${auth}`;
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
let parsed: any = text;
|
||||
try { parsed = JSON.parse(text); } catch { /* keep raw */ }
|
||||
return { status: res.status, body: parsed as T };
|
||||
}
|
||||
|
||||
export const get = (path: string, auth?: string) => api('GET', path, auth);
|
||||
export const post = (path: string, body: any, auth?: string) => api('POST', path, auth, body);
|
||||
export const del = (path: string, auth?: string) => api('DELETE', path, auth);
|
||||
|
||||
/**
|
||||
* Wait until all BullMQ queues are empty AND stay empty for `settleMs` —
|
||||
* defends against cascades where one processor enqueues the next (eg
|
||||
* user.created → account.created → ledger welcomeGrant).
|
||||
*/
|
||||
export async function waitForQueuesEmpty(
|
||||
timeoutMs = 15_000,
|
||||
settleMs = 500,
|
||||
): Promise<void> {
|
||||
const redis = new IORedis(REDIS_PORT, REDIS_HOST, { maxRetriesPerRequest: null });
|
||||
const interval = 100;
|
||||
let elapsed = 0;
|
||||
let emptyFor = 0;
|
||||
try {
|
||||
while (elapsed < timeoutMs) {
|
||||
const queueKeys = [
|
||||
...(await redis.keys('bull:*:waiting')),
|
||||
...(await redis.keys('bull:*:active')),
|
||||
...(await redis.keys('bull:*:delayed')),
|
||||
];
|
||||
let total = 0;
|
||||
for (const key of queueKeys) {
|
||||
const type = await redis.type(key);
|
||||
if (type === 'list') total += await redis.llen(key);
|
||||
else if (type === 'zset') total += await redis.zcard(key);
|
||||
}
|
||||
if (total === 0) {
|
||||
emptyFor += interval;
|
||||
if (emptyFor >= settleMs) return;
|
||||
} else {
|
||||
emptyFor = 0;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, interval));
|
||||
elapsed += interval;
|
||||
}
|
||||
throw new Error(`queues not empty after ${timeoutMs}ms`);
|
||||
} finally {
|
||||
redis.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
Auth flow helpers — mirror cipherdolls' signIn / createApiKey shape,
|
||||
but for email/password (Phase 1 doesn't wire real Google OAuth in tests).
|
||||
──────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface AuthRecord {
|
||||
email: string;
|
||||
password: string;
|
||||
jwt: string;
|
||||
userId: string;
|
||||
apiKey: string;
|
||||
apiKeyId: string;
|
||||
}
|
||||
|
||||
/** Persona registry — populated by `describe*()` factories and read by
|
||||
* downstream specs. New personas added as the test suite grows. */
|
||||
export const auth: Record<string, Partial<AuthRecord>> = {
|
||||
alice: {},
|
||||
bob: {},
|
||||
carol: {},
|
||||
dave: {},
|
||||
};
|
||||
|
||||
export async function signupEmail(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ jwt: string; userId: string; verificationToken: string }> {
|
||||
const { status, body } = await post('/auth/email/signup', { email, password });
|
||||
if (status !== 200) throw new Error(`signup ${email} failed: ${status} ${JSON.stringify(body)}`);
|
||||
return { jwt: body.token, userId: body.user.id, verificationToken: body.verificationToken };
|
||||
}
|
||||
|
||||
export async function verifyEmail(token: string): Promise<void> {
|
||||
const { status, body } = await post('/auth/email/verify', { token });
|
||||
if (status !== 200) throw new Error(`verify failed: ${status} ${JSON.stringify(body)}`);
|
||||
}
|
||||
|
||||
export async function loginEmail(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ jwt: string; userId: string }> {
|
||||
const { status, body } = await post('/auth/email/login', { email, password });
|
||||
if (status !== 200) throw new Error(`login ${email} failed: ${status} ${JSON.stringify(body)}`);
|
||||
return { jwt: body.token, userId: body.user.id };
|
||||
}
|
||||
|
||||
export async function createApiKey(
|
||||
jwt: string,
|
||||
name: string,
|
||||
): Promise<{ id: string; key: string }> {
|
||||
const { status, body } = await post('/api-keys', { name }, jwt);
|
||||
if (status !== 200) throw new Error(`createApiKey failed: ${status} ${JSON.stringify(body)}`);
|
||||
return { id: body.id, key: body.key };
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { describeModels } from './models';
|
||||
import './setup';
|
||||
|
||||
describeModels();
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Lightweight /v1/models smoke test against the anvil-forked Gnosis chain.
|
||||
*
|
||||
* Validates:
|
||||
* - api boots and `getGatewayContext()` succeeds (operator wallet, Bee, PSS)
|
||||
* - `ModelDiscovery.list()` runs against the live ProviderRegistry on the fork
|
||||
* - the route returns the OpenAI `list` shape
|
||||
*
|
||||
* No chat completion, no on-chain writes, no t4t-provider container required.
|
||||
* If the fork has any mainnet provider that heartbeated within the last 10
|
||||
* min before the fork, the catalogue should be non-empty.
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import {
|
||||
auth,
|
||||
signupEmail,
|
||||
verifyEmail,
|
||||
createApiKey,
|
||||
get,
|
||||
waitForQueuesEmpty,
|
||||
} from './helpers';
|
||||
|
||||
export function describeModels(): void {
|
||||
describe('/v1/models (e2e against forked Gnosis)', () => {
|
||||
it('signs up Dave + mints an API key', async () => {
|
||||
const email = `dave-${Date.now()}@hosted.test`;
|
||||
const signup = await signupEmail(email, 'a-strong-password-1234');
|
||||
await verifyEmail(signup.verificationToken);
|
||||
await waitForQueuesEmpty();
|
||||
const { id, key } = await createApiKey(signup.jwt, 'dave-key');
|
||||
auth.dave = {
|
||||
email,
|
||||
password: 'a-strong-password-1234',
|
||||
jwt: signup.jwt,
|
||||
userId: signup.userId,
|
||||
apiKey: key,
|
||||
apiKeyId: id,
|
||||
};
|
||||
});
|
||||
|
||||
it('GET /v1/models returns the OpenAI list shape', async () => {
|
||||
const { status, body } = await get('/v1/models', auth.dave.apiKey);
|
||||
if (status !== 200) {
|
||||
console.error('models endpoint failed:', JSON.stringify(body));
|
||||
}
|
||||
expect(status).toBe(200);
|
||||
expect(body).toHaveProperty('object', 'list');
|
||||
expect(Array.isArray(body.data)).toBe(true);
|
||||
});
|
||||
|
||||
it('catalogue contains at least one model (mainnet has live providers)', async () => {
|
||||
const { body } = await get('/v1/models', auth.dave.apiKey);
|
||||
// Phase 1: tolerate an empty catalogue (no live mainnet providers
|
||||
// recently heartbeated). Log the count so the run surfaces it.
|
||||
console.log(`/v1/models returned ${body.data.length} models`);
|
||||
if (body.data.length > 0) {
|
||||
expect(body.data[0]).toHaveProperty('id');
|
||||
expect(body.data[0]).toHaveProperty('object', 'model');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* E2E test bootstrap. Runs inside the `bun` test container (see
|
||||
* devops/local/docker-compose-test.yaml) against the dockerized stack:
|
||||
*
|
||||
* - `prisma migrate reset` so the schema matches HEAD.
|
||||
* - `redis-cli FLUSHALL` clears queues + locks + nonce.
|
||||
* - Fund the operator wallet on the anvil fork with xBZZ + xDAI via
|
||||
* `fundOperatorFromEnv()` (anvil_setBalance + xBZZ whale impersonation).
|
||||
* The contracts themselves already exist on the fork — they're managed
|
||||
* by the t4t repo and live on Gnosis mainnet; the fork inherits them.
|
||||
* - `evm_snapshot` so each spec can revert to a clean fork.
|
||||
*/
|
||||
import { $ } from 'bun';
|
||||
import { redisConnection } from '../src/queue/connection';
|
||||
import { fundOperatorFromEnv } from './fixtures/fundOperator';
|
||||
|
||||
let snapshotId: string | null = null;
|
||||
|
||||
export async function setBeforeAll(): Promise<void> {
|
||||
// The compose `prisma` service applied migrations on stack-up. Postgres uses
|
||||
// tmpfs in the test stack so each compose run starts fresh — within one
|
||||
// run we just need to clear Redis (BullMQ jobs + the operator nonce).
|
||||
await redisConnection.flushall();
|
||||
|
||||
// Fund the operator wallet on the fork. Only needed for specs that settle a
|
||||
// chat completion on-chain (gateway / concurrency / cancellation). Auth /
|
||||
// accounts specs don't touch chain, so a funding failure is non-fatal here —
|
||||
// the chain specs will surface a clearer error later if they actually need it.
|
||||
try {
|
||||
await fundOperatorFromEnv();
|
||||
} catch (err: any) {
|
||||
console.warn(`[setup] fundOperatorFromEnv failed (non-fatal for non-chain specs): ${err.message}`);
|
||||
}
|
||||
|
||||
snapshotId = await anvilSnapshot().catch(() => null);
|
||||
}
|
||||
|
||||
export async function setAfterAll(): Promise<void> {
|
||||
if (snapshotId) await anvilRevert(snapshotId).catch(() => undefined);
|
||||
await redisConnection.quit();
|
||||
}
|
||||
|
||||
async function anvilSnapshot(): Promise<string> {
|
||||
const res = await fetch(process.env.GNOSIS_RPC_URL ?? 'http://localhost:8545', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', method: 'evm_snapshot', params: [], id: 1 }),
|
||||
});
|
||||
const json = (await res.json()) as { result?: string };
|
||||
if (!json.result) throw new Error('evm_snapshot returned no id');
|
||||
return json.result;
|
||||
}
|
||||
|
||||
async function anvilRevert(id: string): Promise<void> {
|
||||
await fetch(process.env.GNOSIS_RPC_URL ?? 'http://localhost:8545', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', method: 'evm_revert', params: [id], id: 1 }),
|
||||
});
|
||||
}
|
||||
|
||||
// Bun auto-invokes the top-level `beforeAll` / `afterAll` from the preloaded
|
||||
// setup file when called as `--preload`.
|
||||
import { afterAll, beforeAll } from 'bun:test';
|
||||
beforeAll(setBeforeAll);
|
||||
afterAll(setAfterAll);
|
||||
Reference in new issue
Block a user