mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-03 18:38:32 +00:00
480 lines
17 KiB
TypeScript
480 lines
17 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/>.
|
|
*/
|
|
|
|
import type { RequestHandler } from 'express';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
import { extension, extensionStore } from './extensions.ts';
|
|
import {
|
|
clientsContainers,
|
|
configContainer,
|
|
controllersContainers,
|
|
driversContainers,
|
|
servicesContainers,
|
|
storesContainers,
|
|
} from './exports.ts';
|
|
|
|
/**
|
|
* The `extension.import('client')` proxy does NOT return `undefined` for
|
|
* clients that were never registered — it hands back a placeholder proxy that
|
|
* throws on property access. Extensions reaching for an OPTIONAL client (e.g.
|
|
* the ClickHouse analytics client, absent on plain self-hosts) must therefore
|
|
* probe for a real method behind a try/catch rather than trusting truthiness.
|
|
*
|
|
* These tests lock it so a future proxy change can't silently reintroduce the
|
|
* "truthy-but-throws" footgun.
|
|
*/
|
|
describe('extension.import("client") optional client access', () => {
|
|
const clients = extension.import('client') as Record<
|
|
string,
|
|
{ query?: unknown } | undefined
|
|
>;
|
|
|
|
afterEach(() => {
|
|
delete clientsContainers.notRegistered;
|
|
delete clientsContainers.optionalThing;
|
|
});
|
|
|
|
it('returns a truthy-but-throwing placeholder for an unregistered client', () => {
|
|
delete clientsContainers.notRegistered;
|
|
const placeholder = clients.notRegistered;
|
|
// Truthy — so a bare `if (clients.x)` check is NOT safe.
|
|
expect(placeholder).toBeTruthy();
|
|
// ...and accessing a method on it throws.
|
|
expect(() => (placeholder as { query: unknown }).query).toThrow();
|
|
});
|
|
|
|
it('the try/catch probe pattern reports absence safely', () => {
|
|
delete clientsContainers.optionalThing;
|
|
const probe = () => {
|
|
try {
|
|
return typeof clients.optionalThing?.query === 'function'
|
|
? clients.optionalThing
|
|
: null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
expect(probe()).toBeNull();
|
|
});
|
|
|
|
it('the same probe returns a registered client exposing the method', () => {
|
|
const fake = { query: async () => undefined };
|
|
clientsContainers.optionalThing =
|
|
fake as unknown as (typeof clientsContainers)[string];
|
|
const probe = () => {
|
|
try {
|
|
return typeof clients.optionalThing?.query === 'function'
|
|
? clients.optionalThing
|
|
: null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
// The import proxy method-binds, so the result is a binding proxy over
|
|
// `fake` rather than the raw reference (identity is intentionally not
|
|
// preserved). What the probe pattern locks is that a registered client
|
|
// surfaces a callable method.
|
|
const result = probe();
|
|
expect(result).not.toBeNull();
|
|
expect(typeof (result as { query: unknown }).query).toBe('function');
|
|
});
|
|
});
|
|
|
|
// ── Registry writers ─────────────────────────────────────────────────
|
|
|
|
describe('extension registry writers', () => {
|
|
class Dummy {}
|
|
|
|
afterEach(() => {
|
|
for (const registry of [
|
|
extensionStore.clients,
|
|
extensionStore.stores,
|
|
extensionStore.services,
|
|
extensionStore.controllers,
|
|
extensionStore.drivers,
|
|
] as Record<string, unknown>[]) {
|
|
delete registry.dummy;
|
|
}
|
|
extensionStore.globalMiddlewares.length = 0;
|
|
});
|
|
|
|
it('records each layer registration under its own registry', () => {
|
|
extension.registerClient('dummy', Dummy as never);
|
|
extension.registerStore('dummy', Dummy as never);
|
|
extension.registerService('dummy', Dummy as never);
|
|
extension.registerController('dummy', Dummy as never);
|
|
extension.registerDriver('dummy', Dummy as never);
|
|
|
|
expect(extensionStore.clients.dummy).toBe(Dummy);
|
|
expect(extensionStore.stores.dummy).toBe(Dummy);
|
|
expect(extensionStore.services.dummy).toBe(Dummy);
|
|
expect(extensionStore.controllers.dummy).toBe(Dummy);
|
|
expect(extensionStore.drivers.dummy).toBe(Dummy);
|
|
});
|
|
|
|
it('lets a later registration replace an earlier one under the same name', () => {
|
|
class First {}
|
|
class Second {}
|
|
extension.registerService('dummy', First as never);
|
|
extension.registerService('dummy', Second as never);
|
|
expect(extensionStore.services.dummy).toBe(Second);
|
|
});
|
|
|
|
it('appends global middleware in registration order', () => {
|
|
const a = (() => undefined) as unknown as RequestHandler;
|
|
const b = (() => undefined) as unknown as RequestHandler;
|
|
extension.registerGlobalMiddleware(a);
|
|
extension.registerGlobalMiddleware(b);
|
|
expect(extensionStore.globalMiddlewares).toEqual([a, b]);
|
|
});
|
|
|
|
it('exposes the live server config object', () => {
|
|
expect(extension.config).toBe(configContainer);
|
|
});
|
|
});
|
|
|
|
// ── Event subscription ───────────────────────────────────────────────
|
|
|
|
describe('extension.on', () => {
|
|
const key = 'test.extension.event' as never;
|
|
|
|
afterEach(() => {
|
|
delete (extensionStore.events as Record<string, unknown>)[
|
|
key as unknown as string
|
|
];
|
|
});
|
|
|
|
it('creates the listener bucket on first subscribe and appends after that', () => {
|
|
const first = () => undefined;
|
|
const second = () => undefined;
|
|
|
|
extension.on(key, first);
|
|
expect(extensionStore.events[key as unknown as string]).toEqual([
|
|
first,
|
|
]);
|
|
|
|
extension.on(key, second);
|
|
expect(extensionStore.events[key as unknown as string]).toEqual([
|
|
first,
|
|
second,
|
|
]);
|
|
});
|
|
});
|
|
|
|
// ── Route registration ───────────────────────────────────────────────
|
|
|
|
describe('extension route helpers', () => {
|
|
const handler = (() => undefined) as unknown as RequestHandler;
|
|
const other = (() => undefined) as unknown as RequestHandler;
|
|
|
|
afterEach(() => {
|
|
extensionStore.routeHandlers.length = 0;
|
|
});
|
|
|
|
const VERBS = [
|
|
'get',
|
|
'post',
|
|
'put',
|
|
'delete',
|
|
'patch',
|
|
'head',
|
|
'options',
|
|
'all',
|
|
] as const;
|
|
|
|
it.each(VERBS)(
|
|
'%s(path, handler) records the verb with empty options',
|
|
(verb) => {
|
|
extension[verb]('/thing', handler);
|
|
expect(extensionStore.routeHandlers).toEqual([
|
|
{ method: verb, path: '/thing', options: {}, handler },
|
|
]);
|
|
},
|
|
);
|
|
|
|
it.each(VERBS)('%s(path, options, handler) carries the options', (verb) => {
|
|
const options = { subdomain: 'api', requireAuth: true } as const;
|
|
extension[verb]('/thing', options, handler);
|
|
expect(extensionStore.routeHandlers[0]).toEqual({
|
|
method: verb,
|
|
path: '/thing',
|
|
options,
|
|
handler,
|
|
});
|
|
});
|
|
|
|
it('throws when a verb is registered without a handler', () => {
|
|
expect(() =>
|
|
(extension.get as unknown as (p: string, o: unknown) => void)(
|
|
'/thing',
|
|
{ subdomain: 'api' },
|
|
),
|
|
).toThrow("extension.get('/thing', ...) missing handler");
|
|
expect(extensionStore.routeHandlers).toHaveLength(0);
|
|
});
|
|
|
|
it('preserves registration order across verbs', () => {
|
|
extension.get('/a', handler);
|
|
extension.post('/b', other);
|
|
expect(
|
|
extensionStore.routeHandlers.map(
|
|
(r) => `${r.method} ${String(r.path)}`,
|
|
),
|
|
).toEqual(['get /a', 'post /b']);
|
|
});
|
|
|
|
describe('use', () => {
|
|
it('use(handler) registers pathless global middleware', () => {
|
|
extension.use(handler);
|
|
expect(extensionStore.routeHandlers[0]).toEqual({
|
|
method: 'use',
|
|
options: {},
|
|
handler,
|
|
});
|
|
expect(extensionStore.routeHandlers[0].path).toBeUndefined();
|
|
});
|
|
|
|
it('use(options, handler) stays pathless and keeps the options', () => {
|
|
const options = { bodyJson: true } as const;
|
|
extension.use(options, handler);
|
|
expect(extensionStore.routeHandlers[0]).toEqual({
|
|
method: 'use',
|
|
options,
|
|
handler,
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
['string', '/mount'],
|
|
['regexp', /^\/mount/u],
|
|
['array', ['/a', '/b']],
|
|
])('use(%s path, handler) keeps the path', (_label, path) => {
|
|
extension.use(path as never, handler);
|
|
expect(extensionStore.routeHandlers[0]).toMatchObject({
|
|
method: 'use',
|
|
path,
|
|
options: {},
|
|
handler,
|
|
});
|
|
});
|
|
|
|
it('use(path, options, handler) keeps both', () => {
|
|
const options = { subdomain: 'api' } as const;
|
|
extension.use('/mount', options, handler);
|
|
expect(extensionStore.routeHandlers[0]).toEqual({
|
|
method: 'use',
|
|
path: '/mount',
|
|
options,
|
|
handler,
|
|
});
|
|
});
|
|
|
|
it('substitutes empty options when the options argument is nullish', () => {
|
|
extension.use('/mount', undefined as never, handler);
|
|
expect(extensionStore.routeHandlers[0].options).toEqual({});
|
|
|
|
extensionStore.routeHandlers.length = 0;
|
|
extension.use(undefined as never, handler);
|
|
expect(extensionStore.routeHandlers[0]).toEqual({
|
|
method: 'use',
|
|
options: {},
|
|
handler,
|
|
});
|
|
});
|
|
|
|
it('throws when no handler can be found in any argument position', () => {
|
|
expect(() => extension.use('/mount', {} as never)).toThrow(
|
|
'extension.use(...) missing handler',
|
|
);
|
|
expect(() =>
|
|
(extension.use as unknown as (o: unknown) => void)({}),
|
|
).toThrow('extension.use(...) missing handler');
|
|
expect(extensionStore.routeHandlers).toHaveLength(0);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── Import proxy ─────────────────────────────────────────────────────
|
|
|
|
describe('extension.import', () => {
|
|
const containers = {
|
|
store: storesContainers,
|
|
service: servicesContainers,
|
|
controller: controllersContainers,
|
|
driver: driversContainers,
|
|
client: clientsContainers,
|
|
} as Record<string, Record<string, unknown>>;
|
|
|
|
afterEach(() => {
|
|
for (const container of Object.values(containers)) {
|
|
delete container.fixture;
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
['client', 'clients'],
|
|
['store', 'stores'],
|
|
['service', 'services'],
|
|
['controller', 'controllers'],
|
|
['driver', 'drivers'],
|
|
])(
|
|
'resolves a registered %s under both the singular and plural name',
|
|
(singular, plural) => {
|
|
const instance = { value: 7 };
|
|
containers[singular].fixture = instance;
|
|
|
|
const viaSingular = (
|
|
extension.import(singular as never) as Record<
|
|
string,
|
|
{ value: number }
|
|
>
|
|
).fixture;
|
|
const viaPlural = (
|
|
extension.import(plural as never) as Record<
|
|
string,
|
|
{ value: number }
|
|
>
|
|
).fixture;
|
|
|
|
expect(viaSingular.value).toBe(7);
|
|
expect(viaPlural.value).toBe(7);
|
|
},
|
|
);
|
|
|
|
it.each(['client', 'store', 'service', 'controller', 'driver'])(
|
|
'hands back a throwing placeholder for an unregistered %s',
|
|
(layer) => {
|
|
const proxy = extension.import(layer as never) as Record<
|
|
string,
|
|
Record<string, unknown>
|
|
>;
|
|
const placeholder = proxy.fixture;
|
|
expect(placeholder).toBeTruthy();
|
|
expect(() => placeholder.anything).toThrow(
|
|
`extension.import('${layer}:fixture') missing property 'anything'`,
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each(['client', 'store', 'service', 'controller', 'driver'])(
|
|
'resolves a %s captured before the layer was constructed',
|
|
(layer) => {
|
|
// Extension modules are imported before PuterServer builds the
|
|
// layers (see server.ts), so a deep capture at module scope --
|
|
// `const db = extension.import('client').db` in cfFileCache --
|
|
// reads a name that is still absent. The placeholder has to
|
|
// re-resolve on access, or that reference never works.
|
|
const captured = (
|
|
extension.import(layer as never) as Record<
|
|
string,
|
|
Record<string, () => string>
|
|
>
|
|
).fixture;
|
|
|
|
containers[layer].fixture = { read: () => 'live-instance' };
|
|
|
|
expect(captured.read()).toBe('live-instance');
|
|
},
|
|
);
|
|
|
|
it.each(['store', 'service', 'controller', 'driver'])(
|
|
'does not resolve an unregistered %s name against the client registry',
|
|
(layer) => {
|
|
// The placeholder used to read from `clientsContainers`
|
|
// regardless of the layer, so `import('store').db` handed back
|
|
// the database *client*.
|
|
clientsContainers.fixture = {
|
|
query: () => 'leaked',
|
|
} as never;
|
|
|
|
const proxy = extension.import(layer as never) as Record<
|
|
string,
|
|
Record<string, unknown>
|
|
>;
|
|
expect(() => proxy.fixture.query).toThrow(
|
|
`extension.import('${layer}:fixture') missing property 'query'`,
|
|
);
|
|
},
|
|
);
|
|
|
|
it('binds methods reached through a pre-registration placeholder', () => {
|
|
// cfFileCache's exact shape: `const db = extension.import('client').db`
|
|
// at module scope, then `db.read(...)` at request time. The placeholder
|
|
// has to bind, or `this` is the proxy and the private-field read throws.
|
|
class Db {
|
|
#dsn = 'primary';
|
|
read() {
|
|
return this.#dsn;
|
|
}
|
|
}
|
|
const captured = (
|
|
extension.import('client') as unknown as Record<
|
|
string,
|
|
{ read: () => string }
|
|
>
|
|
).db;
|
|
|
|
clientsContainers.db = new Db() as never;
|
|
|
|
expect(captured.read()).toBe('primary');
|
|
delete (clientsContainers as Record<string, unknown>).db;
|
|
});
|
|
|
|
it('binds methods so a detached reference still works', () => {
|
|
// Extensions routinely destructure a method off the import; without
|
|
// binding, `this` would be undefined at call time.
|
|
class Service {
|
|
#secret = 'private-state';
|
|
read() {
|
|
return this.#secret;
|
|
}
|
|
get viaGetter() {
|
|
return this.#secret;
|
|
}
|
|
}
|
|
servicesContainers.fixture = new Service() as never;
|
|
|
|
const svc = (
|
|
extension.import('service') as unknown as Record<
|
|
string,
|
|
{ read: () => string; viaGetter: string }
|
|
>
|
|
).fixture;
|
|
const { read } = svc;
|
|
|
|
expect(read()).toBe('private-state');
|
|
expect(svc.viaGetter).toBe('private-state');
|
|
// Binding trades away reference identity — pin that expectation.
|
|
expect(svc.read).not.toBe(svc.read);
|
|
});
|
|
|
|
it('returns non-object layer values untouched', () => {
|
|
servicesContainers.fixture = 'plain-string' as never;
|
|
const svc = extension.import('service') as unknown as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
expect(svc.fixture).toBe('plain-string');
|
|
});
|
|
|
|
it('returns undefined for a layer name it does not know', () => {
|
|
expect(extension.import('nonsense' as never)).toBeUndefined();
|
|
});
|
|
});
|