Files
puter/src/backend/server.test.ts
T
404oopsandClaude Fable 5.1 c8f4906af4 fix(ai): keep per-provider failures when every chat route fails (#3836)
When the chat fallback chain is exhausted, the driver already records
each attempt (model, provider, status, code, message, timeout) in the
error's `fields.attempts`, but the alarm keyed on the classified message
alone and the alarm client printed the error at inspect depth 2, so the
log and Slack line read `internal_error:All providers failed` with
nothing about which providers failed or why. Deduped repeats printed
only a count.

- The HTTP alarm gate now attaches an HttpError's `fields` to the alarm
  under a single `details` key. One key can't shadow the gate's own
  request fields, and a repeat from another thrower on a shared id
  replaces it instead of merging into it.
- The chat driver logs one warn line per exhausted chain with the
  completion id, the resolved route, the classified code and the
  attempts as JSON, so every occurrence is greppable by trace even when
  the alarm dedupes it. Chains marked `noAlarm` don't log.
- Docs: `fields` reaches both the client and the alarm, so it has to be
  safe to show the caller.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 14:56:11 -07:00

588 lines
21 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 type { Request, RequestHandler, Response } from 'express';
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from 'vitest';
import { HttpError } from './core/http/HttpError.ts';
import { extensionStore } from './extensions.ts';
import { PuterServer } from './server.ts';
import { allocateEphemeralPort, 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',
): 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();
});
/**
* 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('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);
}
});
});
/**
* 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,
} as unknown as IConfig,
{ listen: true },
);
});
afterAll(async () => {
await server?.shutdown();
});
it('lets an unknown host through when custom domains are enabled', async () => {
const res = await rawRequest(port, '/', {
host: 'my-own-domain.example',
});
expect(res.status).not.toBe(400);
});
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);
});
});
/**
* 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: 'get',
path: '/plain',
options: {},
handler: (() => {
throw new Error('kaboom');
}) 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) => {
const alarm = vi
.spyOn(server.clients.alarm, 'create')
.mockImplementation(() => undefined);
const res = await rawRequest(port, path, { host: 'puter.localhost' });
expect(res.status).toBe(500);
const raised = alarm.mock.calls.find((c) =>
String(c[0]).startsWith(`http_500:GET:${path}:`),
);
expect(raised).toBeTruthy();
return {
id: raised![0] as string,
fields: raised![2] as Record<string, unknown>,
};
};
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');
});
});