mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-04 13:03:37 +00:00
Follow-up to #4408. The 503 returned when a process holds too many large bodies is load shedding, so it records an info alarm instead of a critical one. One user can hold at most 128 MiB of large bodies at once; past that the request gets 429 with Retry-After, so a single account can't take a process's whole budget, e.g. with slow uploads. Through /drivers/call only puter-chat-completion takes a body over 50 MiB; other drivers keep the default limit and get 413. The check reads the parsed body's interface, since route matching ignores case and trailing slashes.
1603 lines
56 KiB
TypeScript
1603 lines
56 KiB
TypeScript
/**
|
|
* Copyright (C) 2024-present Puter Technologies Inc.
|
|
*
|
|
* This file is part of Puter.
|
|
*
|
|
* Puter is free software: you can redistribute it and/or modify it under the
|
|
* terms of the GNU Affero General Public License as published by the Free
|
|
* Software Foundation, either version 3 of the License, or (at your option) any
|
|
* later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful, but WITHOUT
|
|
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
|
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
* details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see
|
|
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
|
*/
|
|
|
|
import http from 'node:http';
|
|
import net from 'node:net';
|
|
import type { Request, RequestHandler, Response } from 'express';
|
|
import uaParser from 'ua-parser-js';
|
|
import {
|
|
afterAll,
|
|
afterEach,
|
|
beforeAll,
|
|
beforeEach,
|
|
describe,
|
|
expect,
|
|
it,
|
|
vi,
|
|
} from 'vitest';
|
|
import { Controller, Get } from './core/http/decorators.ts';
|
|
import { HttpError } from './core/http/HttpError.ts';
|
|
import { extension, extensionStore } from './extensions.ts';
|
|
import { PuterServer } from './server.ts';
|
|
import {
|
|
allocateEphemeralPort,
|
|
createTestUser,
|
|
setupTestServer,
|
|
} from './testUtil.ts';
|
|
import type { IConfig } from './types';
|
|
|
|
/**
|
|
* `fetch` refuses to set a `Host` header (it is a forbidden header name), and
|
|
* the gates under test key on exactly that — so drive them with the raw http
|
|
* client instead.
|
|
*/
|
|
interface RawResponse {
|
|
status: number;
|
|
headers: Record<string, string | string[] | undefined>;
|
|
body: string;
|
|
}
|
|
|
|
const rawRequest = (
|
|
port: number,
|
|
path: string,
|
|
headers: Record<string, string> = {},
|
|
method = 'GET',
|
|
body?: string,
|
|
): Promise<RawResponse> =>
|
|
new Promise((resolve, reject) => {
|
|
const req = http.request(
|
|
{ host: '127.0.0.1', port, path, method, headers },
|
|
(res) => {
|
|
let body = '';
|
|
res.setEncoding('utf8');
|
|
res.on('data', (chunk) => (body += chunk));
|
|
res.on('end', () =>
|
|
resolve({
|
|
status: res.statusCode ?? 0,
|
|
headers: res.headers,
|
|
body,
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
req.on('error', reject);
|
|
req.end(body);
|
|
});
|
|
|
|
/**
|
|
* These run against a real listening server so the always-on middleware stack
|
|
* (host validation, CORS, IP gate) is exercised end to end — those gates are
|
|
* installed imperatively on the express app and have no other entry point.
|
|
*/
|
|
describe('PuterServer host header validation', () => {
|
|
let server: PuterServer;
|
|
let port: number;
|
|
|
|
beforeAll(async () => {
|
|
port = await allocateEphemeralPort();
|
|
server = await setupTestServer(
|
|
{
|
|
port,
|
|
domain: 'puter.localhost',
|
|
origin: `http://puter.localhost:${port}`,
|
|
api_base_url: `http://api.puter.localhost:${port}`,
|
|
// The gate under test is skipped entirely when hosts are
|
|
// unrestricted (the OSS default).
|
|
allow_all_host_values: false,
|
|
allow_no_host_header: false,
|
|
custom_domains_enabled: false,
|
|
enable_ip_validation: true,
|
|
} as unknown as IConfig,
|
|
{ listen: true },
|
|
);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server?.shutdown();
|
|
});
|
|
|
|
const request = (
|
|
path: string,
|
|
headers: Record<string, string> = {},
|
|
method = 'GET',
|
|
) => rawRequest(port, path, headers, method);
|
|
|
|
it('accepts the configured main domain and its subdomains', async () => {
|
|
for (const host of [
|
|
`puter.localhost:${port}`,
|
|
`api.puter.localhost:${port}`,
|
|
`anything.puter.localhost:${port}`,
|
|
]) {
|
|
const res = await request('/healthcheck', { host });
|
|
expect(res.status).not.toBe(400);
|
|
}
|
|
});
|
|
|
|
it('accepts the hosting domains and the `at.` alias derived from them', async () => {
|
|
for (const host of [
|
|
`foo.site.puter.localhost:${port}`,
|
|
`foo.host.puter.localhost:${port}`,
|
|
`foo.app.puter.localhost:${port}`,
|
|
`foo.dev.puter.localhost:${port}`,
|
|
`someone.at.site.puter.localhost:${port}`,
|
|
]) {
|
|
const res = await request('/', { host });
|
|
expect(res.status).not.toBe(400);
|
|
}
|
|
});
|
|
|
|
it('rejects a host outside every configured domain', async () => {
|
|
const res = await request('/', { host: 'evil.example.com' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body).toBe('Invalid Host header.');
|
|
});
|
|
|
|
it('rejects a lookalike suffix that only ends with the domain text', async () => {
|
|
const res = await request('/', { host: 'notputer.localhost' });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('lets /healthcheck through on any host', async () => {
|
|
const res = await request('/healthcheck', {
|
|
host: 'evil.example.com',
|
|
});
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it('reflects the caller origin and allows credentials only on the api subdomain', async () => {
|
|
const apiRes = await request('/healthcheck', {
|
|
host: `api.puter.localhost:${port}`,
|
|
origin: 'https://third-party.example',
|
|
});
|
|
expect(apiRes.headers['access-control-allow-origin']).toBe(
|
|
'https://third-party.example',
|
|
);
|
|
expect(apiRes.headers['access-control-allow-credentials']).toBe('true');
|
|
expect(String(apiRes.headers.vary).toLowerCase()).toContain('origin');
|
|
|
|
const davRes = await request('/healthcheck', {
|
|
host: `dav.puter.localhost:${port}`,
|
|
origin: 'https://third-party.example',
|
|
});
|
|
expect(davRes.headers['access-control-allow-credentials']).toBe(
|
|
'false',
|
|
);
|
|
});
|
|
|
|
it('falls back to `*` when the request carries no Origin', async () => {
|
|
const res = await request('/healthcheck', {
|
|
host: `api.puter.localhost:${port}`,
|
|
});
|
|
expect(res.headers['access-control-allow-origin']).toBe('*');
|
|
expect(res.headers['access-control-allow-credentials']).toBeUndefined();
|
|
});
|
|
|
|
it('advertises the WebDAV verbs and headers the clients need', async () => {
|
|
const res = await request('/healthcheck', {
|
|
host: `puter.localhost:${port}`,
|
|
});
|
|
const methods = String(
|
|
res.headers['access-control-allow-methods'] ?? '',
|
|
);
|
|
expect(methods).toContain('PROPFIND');
|
|
expect(methods).toContain('MKCOL');
|
|
const headers = String(
|
|
res.headers['access-control-allow-headers'] ?? '',
|
|
);
|
|
expect(headers).toContain('Authorization');
|
|
expect(headers).toContain('Lock-Token');
|
|
expect(res.headers['access-control-allow-private-network']).toBe(
|
|
'true',
|
|
);
|
|
});
|
|
|
|
it('lets the dav subdomain answer its own OPTIONS', async () => {
|
|
// A DAV client opens a mount with OPTIONS and reads `DAV:` to decide
|
|
// the host speaks WebDAV at all. The blanket preflight reply is a bare
|
|
// 200 with no such header, which makes macOS abandon the mount before
|
|
// it ever sends credentials — so this request has to reach the
|
|
// controller instead.
|
|
const res = await request(
|
|
'/some-user',
|
|
{ host: `dav.puter.localhost:${port}` },
|
|
'OPTIONS',
|
|
);
|
|
expect(res.headers['dav']).toContain('1');
|
|
expect(res.headers['dav']).toContain('2');
|
|
});
|
|
|
|
// The DAV controller declares one route per verb, so these check what only
|
|
// a real server can: that express materializes the WebDAV verbs, that the
|
|
// catch-all matches the root collection as well as deep paths, and that it
|
|
// stays on the `dav` subdomain.
|
|
it('routes every WebDAV verb on the dav subdomain, root included', async () => {
|
|
const dav = { host: `dav.puter.localhost:${port}` };
|
|
for (const [method, path] of [
|
|
['PROPFIND', '/'],
|
|
['PROPFIND', '/some-user/Documents'],
|
|
['PROPPATCH', '/some-user/a.txt'],
|
|
['MKCOL', '/some-user/new-folder'],
|
|
['LOCK', '/some-user/a.txt'],
|
|
// Not a verb the controller implements; the catch-all that answers
|
|
// 405 has to authenticate first, like every other route.
|
|
['SEARCH', '/some-user'],
|
|
] as const) {
|
|
const res = await request(path, dav, method);
|
|
// Unauthenticated, so the reply is the auth challenge — what
|
|
// matters is that it came from the DAV controller and not from the
|
|
// 404 handler.
|
|
expect(res.status, `${method} ${path}`).toBe(401);
|
|
expect(res.headers['www-authenticate']).toContain('Basic');
|
|
}
|
|
});
|
|
|
|
it('leaves WebDAV verbs on other hosts alone', async () => {
|
|
// The DAV catch-all matches any path, so the subdomain gate is the only
|
|
// thing keeping it off the main domain.
|
|
const res = await request(
|
|
'/',
|
|
{ host: `puter.localhost:${port}` },
|
|
'PROPFIND',
|
|
);
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(405);
|
|
expect(res.headers['www-authenticate']).toBeUndefined();
|
|
});
|
|
|
|
it('answers a browser CORS preflight on the dav subdomain', async () => {
|
|
// Browsers send this credential-less probe before any cross-origin DAV
|
|
// verb and abandon the request unless it comes back 2xx.
|
|
const res = await request(
|
|
'/some-user/.vscode/settings.json',
|
|
{
|
|
host: `dav.puter.localhost:${port}`,
|
|
origin: 'https://code.puter.localhost',
|
|
'access-control-request-method': 'PROPFIND',
|
|
'access-control-request-headers': 'authorization,depth',
|
|
},
|
|
'OPTIONS',
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers['dav']).toContain('1');
|
|
expect(res.headers['access-control-max-age']).toBe('86400');
|
|
expect(String(res.headers['access-control-allow-headers'])).toContain(
|
|
'Depth',
|
|
);
|
|
expect(res.headers['access-control-allow-origin']).toBe(
|
|
'https://code.puter.localhost',
|
|
);
|
|
// A browser client reads ETags and lock tokens off the reply, so they
|
|
// have to be exposed to it.
|
|
expect(String(res.headers['access-control-expose-headers'])).toContain(
|
|
'ETag',
|
|
);
|
|
});
|
|
|
|
it('still short-circuits OPTIONS preflight off the dav subdomain', async () => {
|
|
const res = await request(
|
|
'/some-path',
|
|
{ host: `api.puter.localhost:${port}` },
|
|
'OPTIONS',
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers['dav']).toBeUndefined();
|
|
});
|
|
|
|
it('lets a browser cache a preflight off the dav subdomain', async () => {
|
|
const res = await request(
|
|
'/drivers/call',
|
|
{
|
|
host: `api.puter.localhost:${port}`,
|
|
origin: 'https://third-party.example',
|
|
'access-control-request-method': 'POST',
|
|
'access-control-request-headers': 'authorization,content-type',
|
|
},
|
|
'OPTIONS',
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toBe('OK');
|
|
expect(res.headers['access-control-max-age']).toBe('86400');
|
|
expect(res.headers['access-control-allow-origin']).toBe(
|
|
'https://third-party.example',
|
|
);
|
|
expect(res.headers['access-control-allow-credentials']).toBe('true');
|
|
});
|
|
|
|
it('pins X-Frame-Options on the main domain only', async () => {
|
|
const main = await request('/healthcheck', {
|
|
host: 'puter.localhost',
|
|
});
|
|
expect(main.headers['x-frame-options']).toBe('SAMEORIGIN');
|
|
|
|
const api = await request('/healthcheck', {
|
|
host: `api.puter.localhost:${port}`,
|
|
});
|
|
expect(api.headers['x-frame-options']).toBeUndefined();
|
|
});
|
|
|
|
it('blocks a request the ip.validate listeners veto', async () => {
|
|
const handler = (_key: unknown, data: unknown) => {
|
|
(data as { allow: boolean }).allow = false;
|
|
};
|
|
server.clients.event.on('ip.validate', handler as never);
|
|
try {
|
|
const res = await request('/healthcheck', {
|
|
host: `puter.localhost:${port}`,
|
|
});
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toBe('Forbidden');
|
|
} finally {
|
|
server.clients.event.off('ip.validate', handler as never);
|
|
}
|
|
});
|
|
|
|
it('never marks a vetoed preflight as cacheable', async () => {
|
|
const handler = (_key: unknown, data: unknown) => {
|
|
(data as { allow: boolean }).allow = false;
|
|
};
|
|
server.clients.event.on('ip.validate', handler as never);
|
|
try {
|
|
const res = await request(
|
|
'/drivers/call',
|
|
{
|
|
host: `api.puter.localhost:${port}`,
|
|
origin: 'https://third-party.example',
|
|
'access-control-request-method': 'POST',
|
|
},
|
|
'OPTIONS',
|
|
);
|
|
expect(res.status).toBe(403);
|
|
expect(res.headers['access-control-max-age']).toBeUndefined();
|
|
} finally {
|
|
server.clients.event.off('ip.validate', handler as never);
|
|
}
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Express reads subdomains relative to a fixed label count, so a root domain
|
|
* deeper than two labels is the case that breaks: `puter` reads as an active
|
|
* subdomain of the root origin itself, which bounces every root request into
|
|
* the user-subdomain 404.
|
|
*/
|
|
describe('PuterServer subdomain routing on a multi-label root domain', () => {
|
|
let server: PuterServer;
|
|
let port: number;
|
|
|
|
beforeAll(async () => {
|
|
port = await allocateEphemeralPort();
|
|
server = await setupTestServer(
|
|
{
|
|
port,
|
|
domain: 'puter.example.localhost',
|
|
origin: `http://puter.example.localhost:${port}`,
|
|
api_base_url: `http://api.puter.example.localhost:${port}`,
|
|
static_hosting_domain: 'site.puter.example.localhost',
|
|
static_hosting_domain_alt: 'host.puter.example.localhost',
|
|
private_app_hosting_domain: 'app.puter.example.localhost',
|
|
private_app_hosting_domain_alt: 'dev.puter.example.localhost',
|
|
} as unknown as IConfig,
|
|
{ listen: true },
|
|
);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server?.shutdown();
|
|
});
|
|
|
|
// Host headers here carry no port: the gate under test compares the
|
|
// host against `domain`, which is how it arrives from a proxy in practice.
|
|
it('serves the root origin instead of treating it as a user subdomain', async () => {
|
|
const res = await rawRequest(port, '/', {
|
|
host: 'puter.example.localhost',
|
|
});
|
|
expect(res.status).not.toBe(404);
|
|
expect(res.headers.location).toBeUndefined();
|
|
});
|
|
|
|
it('still 404s a user subdomain of that domain', async () => {
|
|
const res = await rawRequest(port, '/some/path', {
|
|
host: 'alice.puter.example.localhost',
|
|
});
|
|
expect(res.status).toBe(404);
|
|
expect(res.headers.location).toBeUndefined();
|
|
});
|
|
|
|
it('still recognizes reserved subdomains of that domain', async () => {
|
|
const res = await rawRequest(port, '/healthcheck', {
|
|
host: 'api.puter.example.localhost',
|
|
origin: 'https://third-party.example',
|
|
});
|
|
expect(res.headers.location).toBeUndefined();
|
|
expect(res.headers['access-control-allow-credentials']).toBe('true');
|
|
});
|
|
});
|
|
|
|
describe('PuterServer host header validation — permissive modes', () => {
|
|
let server: PuterServer;
|
|
let port: number;
|
|
|
|
beforeAll(async () => {
|
|
port = await allocateEphemeralPort();
|
|
server = await setupTestServer(
|
|
{
|
|
port,
|
|
domain: 'puter.localhost',
|
|
origin: `http://puter.localhost:${port}`,
|
|
allow_all_host_values: false,
|
|
allow_no_host_header: false,
|
|
custom_domains_enabled: true,
|
|
allow_nipio_domains: true,
|
|
static_hosting_domain: 'site.puter.localhost',
|
|
} as unknown as IConfig,
|
|
{ listen: true },
|
|
);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server?.shutdown();
|
|
});
|
|
|
|
it('hands an unknown host to site hosting when custom domains are enabled', async () => {
|
|
const res = await rawRequest(port, '/', {
|
|
host: 'my-own-domain.example',
|
|
});
|
|
// No site claims it, so it 404s instead of reaching the GUI.
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toBe('Site not found');
|
|
});
|
|
|
|
it('accepts nip.io hosts when they are opted in', async () => {
|
|
const res = await rawRequest(port, '/healthcheck', {
|
|
host: '127-0-0-1.nip.io',
|
|
});
|
|
expect(res.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe('PuterServer host header validation — custom domains with every host accepted', () => {
|
|
let server: PuterServer;
|
|
let port: number;
|
|
|
|
beforeAll(async () => {
|
|
port = await allocateEphemeralPort();
|
|
server = await setupTestServer(
|
|
{
|
|
port,
|
|
domain: 'puter.localhost',
|
|
origin: `http://puter.localhost:${port}`,
|
|
allow_all_host_values: true,
|
|
custom_domains_enabled: true,
|
|
static_hosting_domain: 'site.puter.localhost',
|
|
} as unknown as IConfig,
|
|
{ listen: true },
|
|
);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server?.shutdown();
|
|
});
|
|
|
|
it('still hands a host outside our domains to site hosting', async () => {
|
|
const res = await rawRequest(port, '/', {
|
|
host: 'www.my-own-domain.example',
|
|
});
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toBe('Site not found');
|
|
});
|
|
|
|
it('leaves our own domain to the GUI', async () => {
|
|
const res = await rawRequest(port, '/healthcheck', {
|
|
host: `puter.localhost:${port}`,
|
|
});
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it('never treats an IP or localhost as a custom domain', async () => {
|
|
for (const host of [
|
|
`127.0.0.1:${port}`,
|
|
`[::1]:${port}`,
|
|
`localhost:${port}`,
|
|
]) {
|
|
const res = await rawRequest(port, '/healthcheck', { host });
|
|
expect(res.status, host).toBe(200);
|
|
const root = await rawRequest(port, '/', { host });
|
|
expect(root.body, host).not.toBe('Site not found');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('PuterServer request parsing', () => {
|
|
let server: PuterServer;
|
|
let port: number;
|
|
let token: string;
|
|
const routeCount = { before: 0 };
|
|
|
|
beforeAll(async () => {
|
|
routeCount.before = extensionStore.routeHandlers.length;
|
|
extension.get('/ua-probe', (req: Request, res: Response) => {
|
|
const first = req.ua;
|
|
res.json({ ua: first ?? null, memoized: req.ua === first });
|
|
});
|
|
extension.get('/ua-assign', (req: Request, res: Response) => {
|
|
req.ua = { browser: { name: 'set' }, os: {}, device: {} };
|
|
res.json({ ua: req.ua });
|
|
});
|
|
extension.post('/raw-probe', (req: Request, res: Response) => {
|
|
res.json({
|
|
isBuffer: Buffer.isBuffer(req.rawBody),
|
|
raw: req.rawBody?.toString('base64') ?? null,
|
|
body: req.body,
|
|
});
|
|
});
|
|
port = await allocateEphemeralPort();
|
|
server = await setupTestServer(
|
|
{
|
|
port,
|
|
domain: 'puter.localhost',
|
|
origin: `http://puter.localhost:${port}`,
|
|
} as unknown as IConfig,
|
|
{ listen: true },
|
|
);
|
|
({ token } = await createTestUser(server, {
|
|
username: 'large_body_user',
|
|
password: 'large-body-password',
|
|
}));
|
|
});
|
|
|
|
afterAll(async () => {
|
|
extensionStore.routeHandlers.length = routeCount.before;
|
|
await server?.shutdown();
|
|
});
|
|
|
|
const host = () => `puter.localhost:${port}`;
|
|
|
|
const MiB = 1024 * 1024;
|
|
const postJson = (
|
|
path: string,
|
|
payload: string,
|
|
headers: Record<string, string> = {},
|
|
) =>
|
|
rawRequest(
|
|
port,
|
|
path,
|
|
{
|
|
host: `api.puter.localhost:${port}`,
|
|
'content-type': 'application/json',
|
|
'content-length': String(Buffer.byteLength(payload)),
|
|
...headers,
|
|
},
|
|
'POST',
|
|
payload,
|
|
);
|
|
|
|
it.each([
|
|
'/drivers/call',
|
|
'/puterai/openai/v1/chat/completions',
|
|
'/puterai/anthropic/v1/messages',
|
|
'/puterai/anthropic/v1/messages/count_tokens',
|
|
])(
|
|
'keeps the default JSON limit on %s without a credential',
|
|
async (path) => {
|
|
const payload = JSON.stringify({ text: 'x'.repeat(51 * MiB) });
|
|
expect((await postJson(path, payload)).status).toBe(413);
|
|
},
|
|
);
|
|
|
|
it('parses a chat call above 50 MiB for a signed-in caller', async () => {
|
|
const payload = JSON.stringify({
|
|
interface: 'puter-chat-completion',
|
|
method: 'no-such-method',
|
|
pad: 'x'.repeat(51 * MiB),
|
|
});
|
|
for (const contentType of [
|
|
'application/json',
|
|
'text/plain;actually=json',
|
|
]) {
|
|
const res = await postJson('/drivers/call', payload, {
|
|
'content-type': contentType,
|
|
authorization: `Bearer ${token}`,
|
|
});
|
|
// The controller read `method` out of the body.
|
|
expect(res.status, contentType).toBe(404);
|
|
expect(res.body).toContain('no-such-method');
|
|
}
|
|
});
|
|
|
|
it('keeps the default JSON limit for other drivers', async () => {
|
|
const payload = JSON.stringify({
|
|
interface: 'puter-kvstore',
|
|
method: 'get',
|
|
pad: 'x'.repeat(51 * MiB),
|
|
});
|
|
// Routing matches these spellings too.
|
|
for (const path of ['/drivers/call', '/Drivers/Call/']) {
|
|
const res = await postJson(path, payload, {
|
|
authorization: `Bearer ${token}`,
|
|
});
|
|
expect(res.status, path).toBe(413);
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
['/drivers/call', 128],
|
|
['/puterai/anthropic/v1/messages', 128],
|
|
['/raw-probe', 50],
|
|
])('rejects bodies above the JSON limit on %s', async (path, limitMiB) => {
|
|
const payload = JSON.stringify({ text: 'x'.repeat(limitMiB * MiB) });
|
|
const res = await postJson(path, payload, {
|
|
authorization: `Bearer ${token}`,
|
|
});
|
|
expect(res.status).toBe(413);
|
|
});
|
|
|
|
it('parses the user agent the first time a handler reads it', async () => {
|
|
const agent =
|
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36';
|
|
const res = await rawRequest(port, '/ua-probe', {
|
|
host: host(),
|
|
'user-agent': agent,
|
|
});
|
|
const body = JSON.parse(res.body);
|
|
expect(body.ua).toEqual(JSON.parse(JSON.stringify(uaParser(agent))));
|
|
expect(body.ua.browser.name).toBe('Chrome');
|
|
expect(body.memoized).toBe(true);
|
|
});
|
|
|
|
it('leaves it unset without a User-Agent header', async () => {
|
|
const res = await rawRequest(port, '/ua-probe', { host: host() });
|
|
expect(JSON.parse(res.body)).toEqual({ ua: null, memoized: true });
|
|
});
|
|
|
|
it('lets a handler overwrite it', async () => {
|
|
const res = await rawRequest(port, '/ua-assign', {
|
|
host: host(),
|
|
'user-agent': 'curl/8.0',
|
|
});
|
|
expect(JSON.parse(res.body).ua.browser).toEqual({ name: 'set' });
|
|
});
|
|
|
|
it('keeps the exact JSON bytes next to the parsed body', async () => {
|
|
const payload = '{ "a" : "\u00e9\u2713",\n "n": [1, 2] }';
|
|
for (const contentType of [
|
|
'application/json',
|
|
'text/plain;actually=json',
|
|
]) {
|
|
const res = await rawRequest(
|
|
port,
|
|
'/raw-probe',
|
|
{ host: host(), 'content-type': contentType },
|
|
'POST',
|
|
payload,
|
|
);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.isBuffer, contentType).toBe(true);
|
|
expect(Buffer.from(body.raw, 'base64').toString('utf8')).toBe(
|
|
payload,
|
|
);
|
|
expect(body.body).toEqual({ a: '\u00e9\u2713', n: [1, 2] });
|
|
}
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A claimed subdomain answers where an unclaimed one 404s, ahead of everything
|
|
* after that point in the global stack, and only past its `authorize`. Driven
|
|
* end to end because the blanket OPTIONS responder, CORS and `subdomain: '*'`
|
|
* routes only exist on the app.
|
|
*/
|
|
describe('PuterServer claimed subdomains', () => {
|
|
let server: PuterServer;
|
|
let port: number;
|
|
const seen: string[] = [];
|
|
|
|
// Everything except what varies per connection or per second.
|
|
const comparable = (res: RawResponse) => {
|
|
const headers = { ...res.headers };
|
|
delete headers.date;
|
|
delete headers.connection;
|
|
delete headers['keep-alive'];
|
|
return { status: res.status, headers, body: res.body };
|
|
};
|
|
|
|
@Controller('/ctl')
|
|
class ClaimTestController {
|
|
@Get('/hello', { subdomain: 'claimtest' })
|
|
hello(_req: Request, res: Response) {
|
|
res.send('from controller');
|
|
}
|
|
|
|
@Get('/teapot', { subdomain: 'claimtest' })
|
|
teapot() {
|
|
throw new HttpError(418, 'Short and stout');
|
|
}
|
|
}
|
|
|
|
const routeCount = { before: 0 };
|
|
|
|
beforeAll(async () => {
|
|
extension.claimSubdomain('claimtest', {
|
|
authorize: (req) => {
|
|
seen.push(`${req.method} ${req.path}`);
|
|
if (req.headers['x-test'] === 'deny') return false;
|
|
if (req.headers['x-test'] === 'crash') {
|
|
throw new Error('store down');
|
|
}
|
|
if (req.headers['x-test'] === 'forbid') {
|
|
throw new HttpError(403, 'Not you');
|
|
}
|
|
return true;
|
|
},
|
|
});
|
|
extension.registerController('claimTest', ClaimTestController as never);
|
|
routeCount.before = extensionStore.routeHandlers.length;
|
|
extension.get(
|
|
'/hello',
|
|
{ subdomain: 'claimtest' },
|
|
(_req: Request, res: Response) => {
|
|
res.send('from extension');
|
|
},
|
|
);
|
|
extension.get('/everywhere', { subdomain: '*' }, (_req, res) => {
|
|
res.send('everywhere');
|
|
});
|
|
extension.get('/hello', (_req: Request, res: Response) => {
|
|
res.send('root hello');
|
|
});
|
|
port = await allocateEphemeralPort();
|
|
server = await setupTestServer(
|
|
{
|
|
port,
|
|
domain: 'puter.localhost',
|
|
origin: `http://puter.localhost:${port}`,
|
|
static_hosting_domain: 'site.puter.localhost',
|
|
} as unknown as IConfig,
|
|
{ listen: true },
|
|
);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
extensionStore.claimedSubdomains.delete('claimtest');
|
|
delete (extensionStore.controllers as Record<string, unknown>)
|
|
.claimTest;
|
|
extensionStore.routeHandlers.length = routeCount.before;
|
|
await server?.shutdown();
|
|
});
|
|
|
|
afterEach(() => {
|
|
seen.length = 0;
|
|
});
|
|
|
|
const claimed = (
|
|
path: string,
|
|
headers: Record<string, string> = {},
|
|
method = 'GET',
|
|
) =>
|
|
rawRequest(
|
|
port,
|
|
path,
|
|
{ ...headers, host: 'claimtest.puter.localhost' },
|
|
method,
|
|
);
|
|
|
|
const expectHidden = async (
|
|
path: string,
|
|
headers: Record<string, string> = {},
|
|
method = 'GET',
|
|
) => {
|
|
const res = await claimed(path, headers, method);
|
|
const unclaimed = await rawRequest(
|
|
port,
|
|
path,
|
|
{ ...headers, host: 'unclaimed.puter.localhost' },
|
|
method,
|
|
);
|
|
expect(res.status).toBe(404);
|
|
expect(comparable(res)).toEqual(comparable(unclaimed));
|
|
};
|
|
|
|
it('serves decorated and extension routes once authorized, before CORS', async () => {
|
|
const fromController = await claimed('/ctl/hello', {
|
|
origin: 'https://elsewhere.example',
|
|
});
|
|
expect(fromController.status).toBe(200);
|
|
expect(fromController.body).toBe('from controller');
|
|
expect(
|
|
fromController.headers['access-control-allow-origin'],
|
|
).toBeUndefined();
|
|
|
|
const fromExtension = await claimed('/hello');
|
|
expect(fromExtension.body).toBe('from extension');
|
|
expect(seen).toEqual(['GET /ctl/hello', 'GET /hello']);
|
|
});
|
|
|
|
it('answers an unauthorized request exactly like an unclaimed subdomain', async () => {
|
|
for (const path of ['/hello', '/ctl/hello', '/', '/healthcheck']) {
|
|
await expectHidden(path, { 'x-test': 'deny' });
|
|
}
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
try {
|
|
await expectHidden('/hello', { 'x-test': 'crash' });
|
|
} finally {
|
|
warn.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('sends an HttpError from authorize through the error handler', async () => {
|
|
const res = await claimed('/hello', { 'x-test': 'forbid' });
|
|
expect(res.status).toBe(403);
|
|
expect(JSON.parse(res.body)).toMatchObject({ message: 'Not you' });
|
|
});
|
|
|
|
it("never serves the main app's routes, even when authorized", async () => {
|
|
for (const path of [
|
|
'/everywhere',
|
|
'/healthcheck',
|
|
'/version',
|
|
'/nope',
|
|
]) {
|
|
await expectHidden(path);
|
|
}
|
|
expect(seen).toContain('GET /everywhere');
|
|
const elsewhere = await rawRequest(port, '/everywhere', {
|
|
host: 'puter.localhost',
|
|
});
|
|
expect(elsewhere.body).toBe('everywhere');
|
|
});
|
|
|
|
it("keeps the claim's routes off every other host", async () => {
|
|
const root = await rawRequest(port, '/hello', {
|
|
host: 'puter.localhost',
|
|
});
|
|
expect(root.body).toBe('root hello');
|
|
for (const host of [
|
|
'api.puter.localhost',
|
|
'unclaimed.puter.localhost',
|
|
]) {
|
|
for (const path of ['/hello', '/ctl/hello']) {
|
|
const res = await rawRequest(port, path, { host });
|
|
expect(res.status, `${host}${path}`).toBe(404);
|
|
}
|
|
}
|
|
const rootController = await rawRequest(port, '/ctl/hello', {
|
|
host: 'puter.localhost',
|
|
});
|
|
expect(rootController.body).not.toBe('from controller');
|
|
});
|
|
|
|
it('routes OPTIONS through authorize instead of the blanket 200', async () => {
|
|
await expectHidden('/hello', { 'x-test': 'deny' }, 'OPTIONS');
|
|
const res = await claimed('/hello', {}, 'OPTIONS');
|
|
expect(res.headers.allow).toBe('GET, HEAD');
|
|
expect(seen).toContain('OPTIONS /hello');
|
|
});
|
|
|
|
it("sends a route's error through the error handler", async () => {
|
|
const res = await claimed('/ctl/teapot');
|
|
expect(res.status).toBe(418);
|
|
expect(JSON.parse(res.body)).toMatchObject({
|
|
message: 'Short and stout',
|
|
});
|
|
});
|
|
|
|
it('treats a host carrying a port like the same host without one', async () => {
|
|
const ported = await rawRequest(port, '/hello', {
|
|
host: `claimtest.puter.localhost:${port}`,
|
|
});
|
|
expect(ported.body).toBe('from extension');
|
|
|
|
const unclaimed = await rawRequest(port, '/healthcheck', {
|
|
host: `unclaimed.puter.localhost:${port}`,
|
|
});
|
|
expect(unclaimed.status).toBe(404);
|
|
expect(
|
|
unclaimed.headers['access-control-allow-origin'],
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it('leaves a deeper host under the claimed label alone', async () => {
|
|
const res = await rawRequest(port, '/hello', {
|
|
host: 'claimtest.x.puter.localhost',
|
|
});
|
|
const unclaimed = await rawRequest(port, '/hello', {
|
|
host: 'unclaimed.x.puter.localhost',
|
|
});
|
|
expect(seen).toEqual([]);
|
|
expect(comparable(res)).toEqual(comparable(unclaimed));
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A main-pipeline claim is served like a reserved subdomain, with ordinary
|
|
* routes.
|
|
*/
|
|
describe('PuterServer main-pipeline claims', () => {
|
|
let server: PuterServer;
|
|
let port: number;
|
|
const routeCount = { before: 0 };
|
|
|
|
beforeAll(async () => {
|
|
extension.claimSubdomain('mainclaim', { mainPipeline: true });
|
|
routeCount.before = extensionStore.routeHandlers.length;
|
|
extension.get(
|
|
'/hello',
|
|
{ subdomain: 'mainclaim' },
|
|
(_req: Request, res: Response) => {
|
|
res.send('main hello');
|
|
},
|
|
);
|
|
extension.get(
|
|
'/signed-in',
|
|
{
|
|
subdomain: 'mainclaim',
|
|
requireAuth: true,
|
|
rateLimit: { limit: 5, window: 1000, key: 'user' },
|
|
},
|
|
(_req: Request, res: Response) => {
|
|
res.send('signed in');
|
|
},
|
|
);
|
|
extension.get('/everywhere', { subdomain: '*' }, (_req, res) => {
|
|
res.send('everywhere');
|
|
});
|
|
port = await allocateEphemeralPort();
|
|
server = await setupTestServer(
|
|
{
|
|
port,
|
|
domain: 'puter.localhost',
|
|
origin: `http://puter.localhost:${port}`,
|
|
static_hosting_domain: 'site.puter.localhost',
|
|
} as unknown as IConfig,
|
|
{ listen: true },
|
|
);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
extensionStore.claimedSubdomains.delete('mainclaim');
|
|
extensionStore.routeHandlers.length = routeCount.before;
|
|
await server?.shutdown();
|
|
});
|
|
|
|
it('serves its routes and the main app on the claimed host', async () => {
|
|
const host = 'mainclaim.puter.localhost';
|
|
expect((await rawRequest(port, '/hello', { host })).body).toBe(
|
|
'main hello',
|
|
);
|
|
const everywhere = await rawRequest(port, '/everywhere', {
|
|
host,
|
|
origin: 'https://elsewhere.example',
|
|
});
|
|
const reserved = await rawRequest(port, '/everywhere', {
|
|
host: 'api.puter.localhost',
|
|
origin: 'https://elsewhere.example',
|
|
});
|
|
expect(everywhere.body).toBe('everywhere');
|
|
for (const res of [everywhere, reserved]) {
|
|
expect(res.headers['access-control-allow-origin']).toBe(
|
|
'https://elsewhere.example',
|
|
);
|
|
}
|
|
});
|
|
|
|
it('runs auth for its routes', async () => {
|
|
const res = await rawRequest(port, '/signed-in', {
|
|
host: 'mainclaim.puter.localhost',
|
|
});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('keeps its routes off other hosts', async () => {
|
|
for (const host of ['api.puter.localhost', 'other.puter.localhost']) {
|
|
const res = await rawRequest(port, '/hello', { host });
|
|
expect(res.status, host).toBe(404);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('PuterServer claimed subdomain route options', () => {
|
|
const noop = ((_req: Request, res: Response) =>
|
|
res.end()) as unknown as RequestHandler;
|
|
|
|
beforeEach(() => {
|
|
extension.claimSubdomain('bootcheck', { authorize: () => true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
extensionStore.claimedSubdomains.delete('bootcheck');
|
|
extensionStore.routeHandlers.length = 0;
|
|
});
|
|
|
|
it('refuses options that need the main pipeline', async () => {
|
|
for (const [option, value] of [
|
|
['requireAuth', true],
|
|
['requireUserActor', true],
|
|
['allowAccessToken', true],
|
|
['guiOriginOnly', true],
|
|
['antiCsrf', true],
|
|
['captcha', true],
|
|
['requireCredits', true],
|
|
['requireVerified', true],
|
|
['noUserSession', true],
|
|
] as const) {
|
|
extensionStore.routeHandlers.length = 0;
|
|
extensionStore.routeHandlers.push({
|
|
method: 'get',
|
|
path: '/gated',
|
|
options: { subdomain: 'bootcheck', [option]: value },
|
|
handler: noop,
|
|
});
|
|
await expect(setupTestServer()).rejects.toThrow(
|
|
`route GET /gated: '${option}' is not available on a claimed subdomain`,
|
|
);
|
|
}
|
|
});
|
|
|
|
it('hands each claimed route to checkRoute and fails the boot when it throws', async () => {
|
|
const seenRoutes: string[] = [];
|
|
extensionStore.claimedSubdomains.delete('bootcheck');
|
|
extension.claimSubdomain('bootcheck', {
|
|
authorize: () => true,
|
|
checkRoute: (options, label) => {
|
|
seenRoutes.push(`${label} ${String(options.subdomain)}`);
|
|
if (!options.middleware?.length) {
|
|
throw new Error('needs a gate');
|
|
}
|
|
},
|
|
});
|
|
extensionStore.routeHandlers.push({
|
|
method: 'get',
|
|
path: '/gated',
|
|
options: {
|
|
subdomain: 'bootcheck',
|
|
middleware: [(_req, _res, next) => next()],
|
|
},
|
|
handler: noop,
|
|
});
|
|
const server = await setupTestServer();
|
|
await server.shutdown();
|
|
expect(seenRoutes).toEqual(['route GET /gated bootcheck']);
|
|
|
|
extensionStore.routeHandlers.push({
|
|
method: 'get',
|
|
path: '/open',
|
|
options: { subdomain: 'bootcheck' },
|
|
handler: noop,
|
|
});
|
|
await expect(setupTestServer()).rejects.toThrow(
|
|
'route GET /open: needs a gate',
|
|
);
|
|
});
|
|
|
|
it('refuses a claimed label written in another case', async () => {
|
|
for (const subdomain of ['BootCheck', ['api', 'BOOTCHECK']]) {
|
|
extensionStore.routeHandlers.length = 0;
|
|
extensionStore.routeHandlers.push({
|
|
method: 'get',
|
|
path: '/cased',
|
|
options: { subdomain },
|
|
handler: noop,
|
|
});
|
|
await expect(setupTestServer()).rejects.toThrow(
|
|
"route GET /cased: claimed subdomain 'bootcheck' must be written in lowercase",
|
|
);
|
|
}
|
|
});
|
|
|
|
it('refuses a claimed subdomain mixed with others', async () => {
|
|
extensionStore.routeHandlers.push({
|
|
method: 'get',
|
|
path: '/mixed',
|
|
options: { subdomain: ['bootcheck', 'api'] },
|
|
handler: noop,
|
|
});
|
|
await expect(setupTestServer()).rejects.toThrow(
|
|
/route GET \/mixed: claimed subdomain 'bootcheck' must be the route's only subdomain/,
|
|
);
|
|
});
|
|
|
|
it('refuses limits keyed on anything but the IP', async () => {
|
|
for (const options of [
|
|
{ rateLimit: { limit: 1, window: 1000 } },
|
|
{ rateLimit: { limit: 1, window: 1000, key: 'user' as const } },
|
|
{ concurrent: { limit: 1, key: 'fingerprint' as const } },
|
|
]) {
|
|
extensionStore.routeHandlers.length = 0;
|
|
extensionStore.routeHandlers.push({
|
|
method: 'get',
|
|
path: '/limited',
|
|
options: { subdomain: 'bootcheck', ...options },
|
|
handler: noop,
|
|
});
|
|
await expect(setupTestServer()).rejects.toThrow(
|
|
/limits on a claimed subdomain must key on 'ip'/,
|
|
);
|
|
}
|
|
});
|
|
|
|
it('boots with the options a claimed host supports', async () => {
|
|
extensionStore.routeHandlers.push({
|
|
method: 'post',
|
|
path: '/fine',
|
|
options: {
|
|
subdomain: 'bootcheck',
|
|
bodyJson: { limit: '1kb' },
|
|
rateLimit: { limit: 5, window: 1000, key: 'ip' },
|
|
concurrent: { limit: 2, key: 'ip' },
|
|
middleware: [(_req, _res, next) => next()],
|
|
requireAuth: false,
|
|
},
|
|
handler: noop,
|
|
});
|
|
const server = await setupTestServer();
|
|
await server.shutdown();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A route option is a declaration, so a malformed one has to be a boot failure
|
|
* naming the route: the alternative is a gate that reads as "subscribers only"
|
|
* to whoever edits the file next while admitting everybody. Extension routes
|
|
* run through the same materializer as controller routes, which makes them the
|
|
* cheap way to drive it.
|
|
*/
|
|
describe('PuterServer route option validation', () => {
|
|
const noop = (() => undefined) as unknown as RequestHandler;
|
|
|
|
afterEach(() => {
|
|
extensionStore.routeHandlers.length = 0;
|
|
});
|
|
|
|
it('refuses to boot on a requireSubscription that names nothing', async () => {
|
|
extensionStore.routeHandlers.push({
|
|
method: 'get',
|
|
path: '/plan-gated',
|
|
options: { requireSubscription: [] },
|
|
handler: noop,
|
|
});
|
|
|
|
await expect(setupTestServer()).rejects.toThrow(
|
|
/route GET \/plan-gated: requireSubscription: expected at least one subscription id/,
|
|
);
|
|
});
|
|
|
|
it('refuses to boot on a requireReputation that names no tier', async () => {
|
|
extensionStore.routeHandlers.push({
|
|
method: 'get',
|
|
path: '/reputation-gated',
|
|
options: { requireReputation: ' ' },
|
|
handler: noop,
|
|
});
|
|
|
|
await expect(setupTestServer()).rejects.toThrow(
|
|
/route GET \/reputation-gated: requireReputation: expected a non-empty tier name/,
|
|
);
|
|
});
|
|
|
|
it('boots with the requirement switched off, and leaves the route open', async () => {
|
|
extensionStore.routeHandlers.push({
|
|
method: 'get',
|
|
path: '/plan-open',
|
|
options: { requireSubscription: false },
|
|
handler: ((_req: Request, res: Response) =>
|
|
res.json({ ok: true })) as unknown as RequestHandler,
|
|
});
|
|
|
|
const listenPort = await allocateEphemeralPort();
|
|
const server = await setupTestServer(
|
|
{
|
|
port: listenPort,
|
|
domain: 'puter.localhost',
|
|
origin: `http://puter.localhost:${listenPort}`,
|
|
} as unknown as IConfig,
|
|
{ listen: true },
|
|
);
|
|
try {
|
|
// `false` declares nothing: no plan gate, and no auth gate
|
|
// dragged in behind it.
|
|
const res = await rawRequest(listenPort, '/plan-open', {
|
|
host: 'puter.localhost',
|
|
});
|
|
expect(res.status).toBe(200);
|
|
} finally {
|
|
await server.shutdown();
|
|
}
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Dedup and repeat throttling mean a responder sees only an alarm's latest
|
|
* occurrence, so what a thrower attached in `fields` has to travel with each
|
|
* one — and a plain Error has to keep raising the same alarm without it.
|
|
*/
|
|
describe('PuterServer HTTP alarm gate', () => {
|
|
let server: PuterServer;
|
|
let port: number;
|
|
|
|
const attempts = [
|
|
{ model: 'm', provider: 'a', error: 'boom' },
|
|
{ model: 'm', provider: 'b', status: 502, error: 'bad gateway' },
|
|
];
|
|
|
|
beforeAll(async () => {
|
|
extensionStore.routeHandlers.push(
|
|
{
|
|
method: 'get',
|
|
path: '/explode',
|
|
options: {},
|
|
handler: (() => {
|
|
throw new HttpError(500, 'All providers failed', {
|
|
legacyCode: 'internal_error',
|
|
// Same names as the gate's own fields, which must
|
|
// stay the HTTP status and the thrown error.
|
|
fields: { attempts, status: 'theirs', error: 'theirs' },
|
|
});
|
|
}) as unknown as RequestHandler,
|
|
},
|
|
{
|
|
method: 'post',
|
|
path: '/explode-with-secrets',
|
|
options: {},
|
|
handler: (() => {
|
|
throw new Error('kaboom');
|
|
}) as unknown as RequestHandler,
|
|
},
|
|
{
|
|
method: 'get',
|
|
path: '/plain',
|
|
options: {},
|
|
handler: (() => {
|
|
throw new Error('kaboom');
|
|
}) as unknown as RequestHandler,
|
|
},
|
|
{
|
|
method: 'get',
|
|
path: '/credits-exhausted',
|
|
options: {},
|
|
handler: (() => {
|
|
throw new HttpError(503, 'AI provider out of credits', {
|
|
legacyCode: 'upstream_credits_exhausted',
|
|
fields: {
|
|
attempts: [
|
|
{
|
|
model: 'm',
|
|
provider: 'a',
|
|
status: 402,
|
|
error: 'Insufficient credits.',
|
|
},
|
|
],
|
|
},
|
|
});
|
|
}) as unknown as RequestHandler,
|
|
},
|
|
{
|
|
method: 'get',
|
|
path: '/model-unavailable',
|
|
options: {},
|
|
handler: (() => {
|
|
throw new HttpError(400, 'Model not available: m', {
|
|
legacyCode: 'upstream_bad_request',
|
|
alarmSeverity: 'info',
|
|
});
|
|
}) as unknown as RequestHandler,
|
|
},
|
|
{
|
|
method: 'get',
|
|
path: '/upstream-bad-request',
|
|
options: {},
|
|
handler: (() => {
|
|
throw new HttpError(400, 'bad tool schema', {
|
|
legacyCode: 'upstream_bad_request',
|
|
});
|
|
}) as unknown as RequestHandler,
|
|
},
|
|
);
|
|
port = await allocateEphemeralPort();
|
|
server = await setupTestServer(
|
|
{
|
|
port,
|
|
domain: 'puter.localhost',
|
|
origin: `http://puter.localhost:${port}`,
|
|
} as unknown as IConfig,
|
|
{ listen: true },
|
|
);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
extensionStore.routeHandlers.length = 0;
|
|
await server?.shutdown();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
const raisedFor = async (path: string, status = 500) => {
|
|
const alarm = vi
|
|
.spyOn(server.clients.alarm, 'create')
|
|
.mockImplementation(() => undefined);
|
|
const res = await rawRequest(port, path, { host: 'puter.localhost' });
|
|
expect(res.status).toBe(status);
|
|
const raised = alarm.mock.calls.find((c) =>
|
|
String(c[0]).startsWith(`http_${status}:GET:${path}:`),
|
|
);
|
|
expect(raised).toBeTruthy();
|
|
return {
|
|
id: raised![0] as string,
|
|
fields: raised![2] as Record<string, unknown>,
|
|
severity: raised![3] as string,
|
|
};
|
|
};
|
|
|
|
it("attaches an HttpError's fields as `details` without touching its own", async () => {
|
|
const { id, fields } = await raisedFor('/explode');
|
|
expect(id).toBe(
|
|
'http_500:GET:/explode:internal_error:All providers failed',
|
|
);
|
|
expect(fields.details).toEqual({
|
|
attempts,
|
|
status: 'theirs',
|
|
error: 'theirs',
|
|
});
|
|
expect(fields.status).toBe(500);
|
|
expect(fields.error).toBeInstanceOf(HttpError);
|
|
});
|
|
|
|
it('raises the same alarm for a plain Error, with no details', async () => {
|
|
const { id, fields } = await raisedFor('/plain');
|
|
expect(id).toBe('http_500:GET:/plain:kaboom');
|
|
expect(fields.status).toBe(500);
|
|
expect(fields.error).toBeInstanceOf(Error);
|
|
expect(fields).not.toHaveProperty('details');
|
|
});
|
|
|
|
it('keeps request body values, query values and the user row out of the alarm', async () => {
|
|
const alarm = vi
|
|
.spyOn(server.clients.alarm, 'create')
|
|
.mockImplementation(() => undefined);
|
|
const res = await rawRequest(
|
|
port,
|
|
'/explode-with-secrets?auth_token=query-secret',
|
|
{ host: 'puter.localhost', 'content-type': 'application/json' },
|
|
'POST',
|
|
JSON.stringify({ username: 'u', password: 'body-secret' }),
|
|
);
|
|
expect(res.status).toBe(500);
|
|
const raised = alarm.mock.calls.find((c) =>
|
|
String(c[0]).startsWith('http_500:POST:/explode-with-secrets:'),
|
|
);
|
|
expect(raised).toBeTruthy();
|
|
const [, message, fields] = raised! as unknown as [
|
|
string,
|
|
string,
|
|
Record<string, unknown>,
|
|
];
|
|
const { error: _error, ...rest } = fields;
|
|
const serialized = `${message} ${JSON.stringify(rest)}`;
|
|
expect(serialized).not.toContain('body-secret');
|
|
expect(serialized).not.toContain('query-secret');
|
|
expect(fields).not.toHaveProperty('body');
|
|
expect(fields.path).toBe('/explode-with-secrets');
|
|
expect(fields.bodyKeys).toEqual(['username', 'password']);
|
|
expect(fields.queryKeys).toEqual(['auth_token']);
|
|
});
|
|
|
|
it('raises a warning when an upstream account is out of credits', async () => {
|
|
const { id, fields, severity } = await raisedFor(
|
|
'/credits-exhausted',
|
|
503,
|
|
);
|
|
expect(id).toBe(
|
|
'http_503:GET:/credits-exhausted:upstream_credits_exhausted:AI provider out of credits',
|
|
);
|
|
expect(severity).toBe('warning');
|
|
expect(fields.details).toEqual({
|
|
attempts: [
|
|
{
|
|
model: 'm',
|
|
provider: 'a',
|
|
status: 402,
|
|
error: 'Insufficient credits.',
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("alarms a 4xx at the thrower's alarmSeverity", async () => {
|
|
const { id, severity } = await raisedFor('/model-unavailable', 400);
|
|
expect(id).toBe(
|
|
'http_400:GET:/model-unavailable:upstream_bad_request:Model not available: m',
|
|
);
|
|
expect(severity).toBe('info');
|
|
});
|
|
|
|
it('does not alarm an upstream 4xx without one', async () => {
|
|
const alarm = vi
|
|
.spyOn(server.clients.alarm, 'create')
|
|
.mockImplementation(() => undefined);
|
|
const res = await rawRequest(port, '/upstream-bad-request', {
|
|
host: 'puter.localhost',
|
|
});
|
|
expect(res.status).toBe(400);
|
|
expect(alarm).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A proxy in front pools upstream connections, so this server closing an idle
|
|
* one first surfaces as a 502 to its clients. Run with a short timeout so the
|
|
* close is observable; what matters is that the configured value reaches the
|
|
* socket at all.
|
|
*/
|
|
describe('PuterServer keep-alive timeout', () => {
|
|
let server: PuterServer;
|
|
let port: number;
|
|
|
|
beforeAll(async () => {
|
|
port = await allocateEphemeralPort();
|
|
server = await setupTestServer(
|
|
{ port, keep_alive_timeout: 300 } as unknown as IConfig,
|
|
{ listen: true },
|
|
);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server?.shutdown();
|
|
});
|
|
|
|
it('closes an idle keep-alive connection at the configured timeout', async () => {
|
|
const socket = net.connect(port, '127.0.0.1');
|
|
await new Promise<void>((resolve, reject) => {
|
|
socket.once('connect', resolve);
|
|
socket.once('error', reject);
|
|
});
|
|
socket.write(
|
|
'GET /healthcheck HTTP/1.1\r\nHost: puter.localhost\r\n\r\n',
|
|
);
|
|
await new Promise<void>((resolve) => socket.once('data', resolve));
|
|
|
|
let timer: NodeJS.Timeout;
|
|
const closed = await Promise.race([
|
|
new Promise<boolean>((resolve) =>
|
|
socket.once('close', () => resolve(true)),
|
|
),
|
|
new Promise<boolean>((resolve) => {
|
|
timer = setTimeout(() => resolve(false), 3000);
|
|
}),
|
|
]);
|
|
clearTimeout(timer!);
|
|
socket.destroy();
|
|
expect(closed).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('PuterServer lifecycle hooks', () => {
|
|
it('runs drain-start and prepare-shutdown bottom-up, once, before tearing down', async () => {
|
|
const port = await allocateEphemeralPort();
|
|
const server = await setupTestServer({ port } as unknown as IConfig, {
|
|
listen: true,
|
|
});
|
|
|
|
const order: string[] = [];
|
|
const probe = (label: string) => ({
|
|
onServerDrainStart: (info?: { drainMs?: number }) => {
|
|
order.push(`drain:${label}:${info?.drainMs}`);
|
|
},
|
|
onServerPrepareShutdown: () => {
|
|
order.push(`prepare:${label}`);
|
|
},
|
|
onServerShutdown: () => {
|
|
order.push(`shutdown:${label}`);
|
|
},
|
|
});
|
|
for (const [layer, label] of [
|
|
[server.clients, 'client'],
|
|
[server.stores, 'store'],
|
|
[server.services, 'service'],
|
|
[server.controllers, 'controller'],
|
|
[server.drivers, 'driver'],
|
|
] as const) {
|
|
(layer as Record<string, unknown>).hookOrderProbe = probe(label);
|
|
}
|
|
|
|
await server.prepareShutdown({ drainMs: 1_000 });
|
|
await server.shutdown();
|
|
|
|
expect(order).toEqual([
|
|
'drain:client:1000',
|
|
'drain:store:1000',
|
|
'drain:service:1000',
|
|
'drain:controller:1000',
|
|
'drain:driver:1000',
|
|
'prepare:client',
|
|
'prepare:store',
|
|
'prepare:service',
|
|
'prepare:controller',
|
|
'prepare:driver',
|
|
'shutdown:driver',
|
|
'shutdown:controller',
|
|
'shutdown:service',
|
|
'shutdown:store',
|
|
'shutdown:client',
|
|
]);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Each layer's `onServerShutdown` has to run while the layers beneath it (the
|
|
* ones it writes through) are still up, so teardown goes top-down: drivers,
|
|
* controllers, services, stores, clients — the reverse of construction order.
|
|
*/
|
|
describe('PuterServer shutdown order', () => {
|
|
it('tears layers down top-down: drivers, controllers, services, stores, clients', async () => {
|
|
const port = await allocateEphemeralPort();
|
|
const server = await setupTestServer({ port } as unknown as IConfig, {
|
|
listen: true,
|
|
});
|
|
|
|
const order: string[] = [];
|
|
const probe = (label: string) => ({
|
|
onServerShutdown: () => {
|
|
order.push(label);
|
|
},
|
|
});
|
|
(server.drivers as Record<string, unknown>).shutdownOrderProbe =
|
|
probe('driver');
|
|
(server.controllers as Record<string, unknown>).shutdownOrderProbe =
|
|
probe('controller');
|
|
(server.services as Record<string, unknown>).shutdownOrderProbe =
|
|
probe('service');
|
|
(server.stores as Record<string, unknown>).shutdownOrderProbe =
|
|
probe('store');
|
|
(server.clients as Record<string, unknown>).shutdownOrderProbe =
|
|
probe('client');
|
|
|
|
await server.shutdown();
|
|
|
|
expect(order).toEqual([
|
|
'driver',
|
|
'controller',
|
|
'service',
|
|
'store',
|
|
'client',
|
|
]);
|
|
});
|
|
|
|
// close() only drops connections idle at that moment; one mid-request
|
|
// stays open until PuterServer severs it after the prepare hooks.
|
|
it('finishes while a connection is mid-request', async () => {
|
|
const port = await allocateEphemeralPort();
|
|
const server = await setupTestServer({ port } as unknown as IConfig, {
|
|
listen: true,
|
|
});
|
|
|
|
const socket = net.connect(port, '127.0.0.1');
|
|
socket.on('error', () => {});
|
|
await new Promise<void>((resolve) => socket.once('connect', resolve));
|
|
socket.write('GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n');
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
|
|
try {
|
|
await server.shutdown();
|
|
} finally {
|
|
socket.destroy();
|
|
}
|
|
}, 15_000);
|
|
});
|