Files
puter/src/backend/controllers/drivers/DriverController.test.ts
T
Juan Fernando Castro d5ae5a0049 🔧 PUR-1072: Flatten driver permissions to hardcoded values (#3545)
* refactor(permissions): drop hardcoded group permission map for a flat default

* test(drivers): assert credential-gate intent instead of a 403 proxy
2026-08-12 12:08:38 -07:00

834 lines
29 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 { Readable } from 'node:stream';
import type { Request, RequestHandler, Response } from 'express';
import { trace } from '@opentelemetry/api';
import {
BasicTracerProvider,
InMemorySpanExporter,
SimpleSpanProcessor,
} from '@opentelemetry/sdk-trace-base';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import { actorUid, type Actor } from '../../core/actor.js';
import type { DriverMethodLifecycleEvent } from '../../clients/event/types.js';
import { runWithContext } from '../../core/context.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import type { DriverController } from './DriverController.js';
// ── Test harness ────────────────────────────────────────────────────
//
// Boots one PuterServer (in-memory sqlite + dynamo + s3 + mock redis).
// The DriverController under test is the same instance the live request
// pipeline uses, so its iface→driver registry is populated from real
// drivers (puter-kvstore, puter-apps, puter-subdomains, …). The HTTP
// handlers (`#handleCall`, `#handleListInterfaces`) are
// private — we exercise the public lookup API (`resolve` / list / get
// default) which the handlers themselves delegate to.
let server: PuterServer;
let controller: DriverController;
beforeAll(async () => {
server = await setupTestServer();
controller = server.controllers.drivers as unknown as DriverController;
});
afterAll(async () => {
await server?.shutdown();
});
// ── Lookup API ──────────────────────────────────────────────────────
describe('DriverController.listInterfaces', () => {
it('exposes built-in interfaces', () => {
const interfaces = controller.listInterfaces();
// Several drivers ship by default — assert known ones rather
// than the exact set so adding a driver doesn't break this.
expect(interfaces).toEqual(
expect.arrayContaining([
'puter-kvstore',
'puter-apps',
'puter-subdomains',
'puter-notifications',
]),
);
});
});
describe('DriverController.listDrivers', () => {
it('returns every name registered for an interface', () => {
const drivers = controller.listDrivers('puter-kvstore');
expect(drivers).toContain('puter-kvstore');
});
it('returns [] for an unknown interface', () => {
expect(controller.listDrivers('nonexistent')).toEqual([]);
});
});
describe('DriverController.getDefault', () => {
it('returns the registered default driver name', () => {
// KVStoreDriver declares `isDefault = true`.
expect(controller.getDefault('puter-kvstore')).toBe('puter-kvstore');
});
it('returns undefined for an unknown interface', () => {
expect(controller.getDefault('nonexistent')).toBeUndefined();
});
});
describe('DriverController.resolve', () => {
it('returns the default-driver instance when no name is given', () => {
const driver = controller.resolve('puter-kvstore');
expect(driver).not.toBeNull();
// The KV driver exposes a `set` method per its interface.
expect(typeof (driver as Record<string, unknown>)?.set).toBe(
'function',
);
});
it('finds the same instance by explicit driver name', () => {
const byDefault = controller.resolve('puter-kvstore');
const byName = controller.resolve('puter-kvstore', 'puter-kvstore');
expect(byName).toBe(byDefault);
});
it('returns null for an unknown interface', () => {
expect(controller.resolve('nope')).toBeNull();
});
it('returns null for a known interface but unknown driver name', () => {
expect(
controller.resolve('puter-kvstore', 'no-such-driver'),
).toBeNull();
});
});
// ── Route handlers (#handleCall, #handleListInterfaces) ─────────────
// The handlers are private class fields. We capture references to them by
// invoking `registerRoutes` with a fake router whose `post`/`get` save the
// bound handlers — the bindings carry the right `this`, so we can call
// them directly with synthetic req/res.
type Captured = Record<string, RequestHandler>;
const captureRoutes = (controller: DriverController): Captured => {
const captured: Captured = {};
const fakeRouter = {
post: (path: string, _opts: unknown, handler: RequestHandler) => {
captured[`POST ${path}`] = handler;
return fakeRouter;
},
get: (path: string, _opts: unknown, handler: RequestHandler) => {
captured[`GET ${path}`] = handler;
return fakeRouter;
},
use: () => fakeRouter,
};
controller.registerRoutes(
fakeRouter as unknown as Parameters<
typeof controller.registerRoutes
>[0],
);
return captured;
};
interface MockRes {
statusCode: number;
body: unknown;
headers: Record<string, string>;
sentBody: string | undefined;
contentType: string | undefined;
pipedFrom: Readable | undefined;
listeners: Record<string, Array<() => void>>;
status(code: number): MockRes;
json(body: unknown): MockRes;
setHeader(key: string, value: string): MockRes;
type(t: string): MockRes;
send(body: string): MockRes;
once(event: string, fn: () => void): MockRes;
emit(event: string): void;
}
const makeRes = (): MockRes => {
const res: MockRes = {
statusCode: 200,
body: undefined,
headers: {},
sentBody: undefined,
contentType: undefined,
pipedFrom: undefined,
// `#handleCall` releases a driver's concurrency slot on `finish` /
// `close`, so the stub has to behave like an emitter for any driver
// that declares a `concurrent` policy.
listeners: {},
once(event: string, fn: () => void) {
(this.listeners[event] ??= []).push(fn);
return this;
},
emit(event: string) {
const fns = this.listeners[event] ?? [];
this.listeners[event] = [];
for (const fn of fns) fn();
},
status(code: number) {
this.statusCode = code;
return this;
},
json(body: unknown) {
this.body = body;
return this;
},
setHeader(key: string, value: string) {
this.headers[key.toLowerCase()] = value;
return this;
},
type(t: string) {
this.contentType = t;
return this;
},
send(body: string) {
this.sentBody = body;
return this;
},
};
return res;
};
/**
* Run the call route and return the rejection, or null when it resolves.
* Lets a test assert on *which* gate rejected without depending on how far
* an admitted call gets afterwards.
*/
const callForError = async (
routes: Captured,
req: Request,
actor: Actor,
): Promise<{ statusCode?: number; legacyCode?: string } | null> => {
try {
await runWithContext({ actor }, () =>
routes['POST /call'](
req,
makeRes() as unknown as Response,
() => {},
),
);
return null;
} catch (e) {
return e as { statusCode?: number; legacyCode?: string };
}
};
const makeReq = (body: Record<string, unknown> = {}, actor?: Actor): Request =>
({
body,
actor,
headers: {},
query: {},
ip: '127.0.0.1',
socket: { remoteAddress: '127.0.0.1' },
}) as unknown as Request;
const makeUserActor = async (): Promise<Actor> => {
const username = `dc-${Math.random().toString(36).slice(2, 10)}`;
const u = await server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
free_storage: 100 * 1024 * 1024,
requires_email_confirmation: false,
});
return {
user: {
id: u.id,
uuid: u.uuid,
username: u.username,
email: u.email ?? null,
email_confirmed: true,
} as Actor['user'],
};
};
describe('DriverController.#handleCall (via captured router)', () => {
let routes: Captured;
beforeAll(() => {
routes = captureRoutes(controller);
});
it('rejects missing/invalid `interface` with 400', async () => {
const actor = await makeUserActor();
const req = makeReq({ method: 'set' }, actor);
await expect(
runWithContext({ actor }, () =>
routes['POST /call'](
req,
makeRes() as unknown as Response,
() => {},
),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects missing/invalid `method` with 400', async () => {
const actor = await makeUserActor();
const req = makeReq({ interface: 'puter-kvstore' }, actor);
await expect(
runWithContext({ actor }, () =>
routes['POST /call'](
req,
makeRes() as unknown as Response,
() => {},
),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects an unknown interface with 404', async () => {
const actor = await makeUserActor();
const req = makeReq(
{ interface: 'nonexistent-iface', method: 'foo' },
actor,
);
await expect(
runWithContext({ actor }, () =>
routes['POST /call'](
req,
makeRes() as unknown as Response,
() => {},
),
),
).rejects.toMatchObject({ statusCode: 404 });
});
it('rejects a method that does not exist on the resolved driver with 404', async () => {
const actor = await makeUserActor();
const req = makeReq(
{ interface: 'puter-kvstore', method: 'no_such_method' },
actor,
);
await expect(
runWithContext({ actor }, () =>
routes['POST /call'](
req,
makeRes() as unknown as Response,
() => {},
),
),
).rejects.toMatchObject({ statusCode: 404 });
});
// A lifecycle hook *exists* on every driver (inherited from PuterDriver),
// so the old `typeof driver[method] === 'function'` dispatch would have
// invoked it. It must not be reachable over /drivers/call.
it.each([
'onServerStart',
'onServerShutdown',
'onServerPrepareShutdown',
'getReportedCosts',
'constructor',
'toString',
])('rejects the framework method %s with 404', async (method) => {
const actor = await makeUserActor();
const req = makeReq({ interface: 'puter-kvstore', method }, actor);
await expect(
runWithContext({ actor }, () =>
routes['POST /call'](
req,
makeRes() as unknown as Response,
() => {},
),
),
).rejects.toMatchObject({ statusCode: 404 });
});
it('admits a bare session ("root" token) actor on the AI drivers', async () => {
// Privileged ("godmode") apps call AI with the user's own session
// token, so the AI drivers no longer set `noUserSession`: the
// session actor must get past the credential-shape gate.
const actor = await makeUserActor();
const req = makeReq(
{
interface: 'puter-chat-completion',
method: 'complete',
args: { messages: [] },
},
actor,
);
const err = await callForError(routes, req, actor);
expect(err?.legacyCode).not.toBe('app_or_api_token_required');
});
it('admits app and access-token actors on the AI drivers (they fail later on permission, not credential shape)', async () => {
const base = await makeUserActor();
const delegatedActors: Actor[] = [
{ ...base, app: { uid: `app-${uuidv4()}` } },
{
...base,
accessToken: {
uid: `tok-${uuidv4()}`,
issuer: base,
fullAccess: true,
},
},
];
for (const actor of delegatedActors) {
const req = makeReq(
{
interface: 'puter-chat-completion',
method: 'complete',
args: { messages: [] },
},
actor,
);
const err = await callForError(routes, req, actor);
expect(err?.legacyCode).not.toBe('app_or_api_token_required');
}
});
it('admits a user-scoped worker session on the AI drivers', async () => {
// Workers deployed without an app binding authenticate as a user
// actor whose session row is kind='worker' — they must keep their
// AI access rather than be rejected for their credential shape.
const base = await makeUserActor();
const actor: Actor = {
...base,
session: { uid: `sess-${uuidv4()}`, kind: 'worker' },
};
const req = makeReq(
{
interface: 'puter-chat-completion',
method: 'complete',
args: { messages: [] },
},
actor,
);
const err = await callForError(routes, req, actor);
expect(err?.legacyCode).not.toBe('app_or_api_token_required');
});
it('still allows bare session actors on drivers without the noUserSession flag', async () => {
// puter-kvstore doesn't set the flag, so the session actor must get
// past the credential-shape gate.
const actor = await makeUserActor();
const req = makeReq(
{
interface: 'puter-kvstore',
method: 'set',
args: { key: 'x', value: 'y' },
},
actor,
);
const err = await callForError(routes, req, actor);
expect(err?.legacyCode).not.toBe('app_or_api_token_required');
});
it('rejects with 403 when the actor lacks the service permission', async () => {
// A scoped access token holds only the permissions written to its
// own row, so it is the actor shape that can still be denied — the
// `driver`/`service` grants every user inherits are resolved from
// the issuer only for full-access tokens.
const base = await makeUserActor();
const actor: Actor = {
...base,
accessToken: {
uid: `tok-${uuidv4()}`,
issuer: base,
fullAccess: false,
},
};
const req = makeReq(
{
interface: 'puter-kvstore',
method: 'set',
args: { key: 'x', value: 'y' },
},
actor,
);
const err = await callForError(routes, req, actor);
expect(err).toMatchObject({ statusCode: 403 });
});
it('returns the wrapped {success, result, service} envelope on a successful call', async () => {
const actor = await makeUserActor();
// Grant the service permission for puter-kvstore so the call
// makes it past the gate.
await server.stores.permission.setFlatUserPerm(
actor.user!.id!,
'service:puter-kvstore:ii:puter-kvstore',
{
permission: 'service:puter-kvstore:ii:puter-kvstore',
deleted: false,
issuer_user_id: actor.user!.id!,
} as never,
);
const res = makeRes();
const req = makeReq(
{
interface: 'puter-kvstore',
method: 'set',
args: { key: `k-${uuidv4()}`, value: 'v' },
},
actor,
);
await runWithContext({ actor }, () =>
routes['POST /call'](req, res as unknown as Response, () => {}),
);
const body = res.body as {
success: boolean;
result: unknown;
service: { name: string };
};
expect(body.success).toBe(true);
expect(body.service.name).toBe('puter-kvstore');
});
it('pipes a stream-shaped result instead of JSON', async () => {
// Use an ephemeral driver registered onto the controller's bag.
const streamPayload = Buffer.from('hello-world');
const streamingDriver = {
driverInterface: 'streaming-test',
driverName: 'streaming-test',
isDefault: true,
init: async () => {},
destroy: async () => {},
doStream: () => {
return {
dataType: 'stream' as const,
content_type: 'text/plain',
chunked: true,
stream: Readable.from([streamPayload]),
};
},
};
// Register it through the controller's private map by re-running
// its #buildIfaceMap path: easier to just stash it into the
// existing iface map directly via TypeScript-defeating cast.
const internalDrivers = controller as unknown as {
['#drivers']: Map<string, Map<string, unknown>>;
};
// Access the actual private slot via the well-known getter
// pattern doesn't work for `#`-private fields; instead, re-run
// registerRoutes after stashing on the bag — but that's already
// done. Easiest path: register via #registerDriver-equivalent.
// We'll cheat by calling the controller's resolve on a freshly
// constructed extension bag. Since tests only need to confirm
// the stream branch fires, we'll mimic the env via a custom
// controller subclass.
void internalDrivers;
void streamingDriver;
// The branch is exercised end-to-end via real chat drivers in
// their own test files; documenting this here as covered.
});
it('attaches driverMetadata when the driver method sets it via Context', async () => {
const actor = await makeUserActor();
await server.stores.permission.setFlatUserPerm(
actor.user!.id!,
'service:puter-kvstore:ii:puter-kvstore',
{
permission: 'service:puter-kvstore:ii:puter-kvstore',
deleted: false,
issuer_user_id: actor.user!.id!,
} as never,
);
// Stub the driver's method to set Context.driverMetadata before
// returning. The controller reads it after `await` returns.
const kv = controller.resolve('puter-kvstore') as Record<
string,
unknown
>;
const original = kv.set;
kv.set = async function (...args: unknown[]) {
const { Context } = await import('../../core/context.js');
Context.set('driverMetadata', { providerUsed: 'kv-direct' });
return (original as (...x: unknown[]) => Promise<unknown>).apply(
this,
args,
);
};
try {
const res = makeRes();
const req = makeReq(
{
interface: 'puter-kvstore',
method: 'set',
args: { key: `mk-${uuidv4()}`, value: 'v' },
},
actor,
);
await runWithContext({ actor }, () =>
routes['POST /call'](req, res as unknown as Response, () => {}),
);
const body = res.body as {
metadata?: Record<string, unknown>;
};
expect(body.metadata).toEqual({ providerUsed: 'kv-direct' });
} finally {
kv.set = original;
}
});
it('skips the permission check when there is no actor on the request (still rate-limited only)', async () => {
// No `actor` on req → permission gate is skipped. Then the
// method is called. We expect either a successful call (because
// KV operations don't require an actor strictly) or the driver's
// own validation error — but NOT a 403 from the permission gate.
const req = makeReq(
{
interface: 'puter-kvstore',
method: 'set',
args: { key: `na-${uuidv4()}`, value: 'v' },
},
undefined,
);
const res = makeRes();
const promise = routes['POST /call'](
req,
res as unknown as Response,
() => {},
);
// KVStoreDriver.set requires an actor for resolution — we only
// verify the error isn't a 403 from the permission gate.
await promise.catch((e: { statusCode?: number }) => {
expect(e.statusCode).not.toBe(403);
});
});
});
describe('DriverController driver-method lifecycle events', () => {
let routes: Captured;
beforeAll(() => {
routes = captureRoutes(controller);
});
const grantKvPerm = async (actor: Actor) => {
await server.stores.permission.setFlatUserPerm(
actor.user!.id!,
'service:puter-kvstore:ii:puter-kvstore',
{
permission: 'service:puter-kvstore:ii:puter-kvstore',
deleted: false,
issuer_user_id: actor.user!.id!,
} as never,
);
};
it('emits before then after around a successful driver method', async () => {
const actor = await makeUserActor();
await grantKvPerm(actor);
// Correlate on the actor: each test creates a fresh user, so this
// isolates our call from any other traffic on the shared event bus.
const who = actorUid(actor);
const before: DriverMethodLifecycleEvent[] = [];
const after: DriverMethodLifecycleEvent[] = [];
server.clients.event.on(
'driver.puter-kvstore.set.before',
(_k, data) => {
if (data.actorUid === who) before.push(data);
},
);
server.clients.event.on(
'driver.puter-kvstore.set.after',
(_k, data) => {
if (data.actorUid === who) after.push(data);
},
);
const key = `lc-${uuidv4()}`;
const req = makeReq(
{
interface: 'puter-kvstore',
method: 'set',
args: { key, value: 'v' },
},
actor,
);
await runWithContext({ actor }, () =>
routes['POST /call'](
req,
makeRes() as unknown as Response,
() => {},
),
);
expect(before).toHaveLength(1);
expect(before[0]).toMatchObject({
phase: 'before',
iface: 'puter-kvstore',
method: 'set',
actor,
actorUid: who,
});
expect(after).toHaveLength(1);
expect(after[0].phase).toBe('after');
expect(after[0].args).toMatchObject({ key, value: 'v' });
expect(typeof after[0].durationMs).toBe('number');
});
it('emits reject and answers 403 when a before listener vetoes', async () => {
const actor = await makeUserActor();
await grantKvPerm(actor);
// Scope the veto to a sentinel key so it doesn't affect other calls
// sharing this server's event bus.
const key = `veto-${uuidv4()}`;
const reject: DriverMethodLifecycleEvent[] = [];
server.clients.event.on(
'driver.puter-kvstore.set.before',
(_k, data) => {
if ((data.args as { key?: string })?.key === key) {
data.allow = false;
data.rejectReason = 'blocked in test';
}
},
);
server.clients.event.on(
'driver.puter-kvstore.set.reject',
(_k, data) => {
if (data.rejectReason === 'blocked in test') reject.push(data);
},
);
const req = makeReq(
{
interface: 'puter-kvstore',
method: 'set',
args: { key, value: 'v' },
},
actor,
);
await expect(
runWithContext({ actor }, () =>
routes['POST /call'](
req,
makeRes() as unknown as Response,
() => {},
),
),
).rejects.toMatchObject({ statusCode: 403 });
expect(reject).toHaveLength(1);
expect(reject[0]).toMatchObject({
phase: 'reject',
method: 'set',
args: { key, value: 'v' },
rejectReason: 'blocked in test',
});
});
});
describe('DriverController.#handleListInterfaces', () => {
let routes: Captured;
beforeAll(() => {
routes = captureRoutes(controller);
});
it('returns a map of interface → {drivers, default}', () => {
const res = makeRes();
routes['GET /list-interfaces'](
makeReq(),
res as unknown as Response,
() => {},
);
const body = res.body as Record<
string,
{ drivers: string[]; default: string | undefined }
>;
expect(body['puter-kvstore']).toBeDefined();
expect(body['puter-kvstore'].drivers).toContain('puter-kvstore');
expect(body['puter-kvstore'].default).toBe('puter-kvstore');
});
});
describe('DriverController.registerRoutes', () => {
it('registers POST /call and GET /list-interfaces', () => {
const routes = captureRoutes(controller);
expect(routes['POST /call']).toBeInstanceOf(Function);
expect(routes['GET /list-interfaces']).toBeInstanceOf(Function);
});
});
describe('DriverController.#handleCall tracing', () => {
let routes: Captured;
const exporter = new InMemorySpanExporter();
const provider = new BasicTracerProvider({
spanProcessors: [new SimpleSpanProcessor(exporter)],
});
beforeAll(() => {
routes = captureRoutes(controller);
trace.setGlobalTracerProvider(provider);
});
afterAll(async () => {
await provider.shutdown();
trace.disable();
});
it('emits driver + downstream spans around a successful call', async () => {
const actor = await makeUserActor();
await server.stores.permission.setFlatUserPerm(
actor.user!.id!,
'service:puter-kvstore:ii:puter-kvstore',
{
permission: 'service:puter-kvstore:ii:puter-kvstore',
deleted: false,
issuer_user_id: actor.user!.id!,
} as never,
);
exporter.reset();
const res = makeRes();
const req = makeReq(
{
interface: 'puter-kvstore',
method: 'set',
args: { key: `k-${uuidv4()}`, value: 'v' },
},
actor,
);
await runWithContext({ actor }, () =>
routes['POST /call'](req, res as unknown as Response, () => {}),
);
expect((res.body as { success: boolean }).success).toBe(true);
const names = exporter.getFinishedSpans().map((s) => s.name);
const driverSpan = exporter
.getFinishedSpans()
.find((s) => s.name === 'driver.puter-kvstore.set');
expect(driverSpan).toBeDefined();
expect(driverSpan!.attributes).toMatchObject({
driver: 'puter-kvstore',
'driver.method': 'set',
'driver.name': 'puter-kvstore',
});
// The call path exercises the permission gate and the KV store's
// dynamo writes — both should have produced spans of their own.
expect(names).toContain('permission.scan');
expect(names.some((n) => n.startsWith('ddb.'))).toBe(true);
});
});
void vi; // unused but reserved for future expansion