Files
puter/src/backend/drivers/workers/WorkerDriver.test.ts
T
Juan Fernando Castro aa47dcd3e5
release-please / release-please (push) Canceled after 0s
Backend Tests / changes (push) Canceled after 0s
Backend Tests / typecheck (push) Canceled after 0s
Backend Tests / test shard 1/4 (push) Canceled after 0s
Backend Tests / test shard 2/4 (push) Canceled after 0s
Backend Tests / test shard 3/4 (push) Canceled after 0s
Backend Tests / test shard 4/4 (push) Canceled after 0s
Backend Tests / test (main) (push) Canceled after 0s
Backend Tests / coverage (push) Canceled after 0s
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
feat(workers): internal teardown that takes the script down before the row, and destroy reports Cloudflare refusals (PUT-2219) (#4297)
2026-10-21 18:50:54 -07:00

1085 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// This test checks everything in WorkerDriver up to the cloudflare level.
// Full deployments are not tested as this would require Cloudflare Workerd,
// however the interactions with the Puter API are
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { Actor, makeActor as resolveActor } from '../../core/actor.js';
import { runWithContext } from '../../core/context.js';
import { PuterServer } from '../../server.js';
import {
setupPuterTestEnv,
setupTestServer,
type PuterTestEnv,
} from '../../testUtil.js';
import { decodeCursor } from '../../util/pagination.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
import { resolveCallableMethods } from '../meta.js';
import {
INTERNAL_ADMIN_DESTROY,
INTERNAL_ADMISSION_BYPASS,
} from './WorkerDriver.js';
import type { WorkerDriver } from './WorkerDriver.js';
describe('WorkerDriver', () => {
let server: PuterServer;
let target: WorkerDriver;
beforeAll(async () => {
server = await setupTestServer();
target = server.drivers.workers as unknown as WorkerDriver;
});
afterAll(async () => {
await server?.shutdown();
});
let actor: Actor;
const makeActor = (overrides: Partial<Actor> = {}): Actor =>
resolveActor({
user: {
uuid: `test-user-${Math.random().toString(36).slice(2)}`,
id: 1,
username: 'test-user',
email: 'test@test.com',
email_confirmed: true,
},
app: { uid: 'test-app', id: 1 },
...overrides,
});
beforeEach(() => {
actor = makeActor();
});
const inCtx = <T>(fn: () => T | Promise<T>, withActor: Actor = actor) =>
runWithContext({ actor: withActor }, fn);
// Real `user` rows — `apps.owner_user_id` is a foreign key, so app-scoping
// tests can't get away with synthetic ids.
const makeUser = async (label: string) =>
await server.stores.user.create({
username: `wk-${label}`,
uuid: `wk-uuid-${label}`,
password: null,
email: null,
});
const actorFor = (
user: { id: number; uuid: string; username: string },
app?: { uid: string; id: number },
): Actor =>
resolveActor({
user: {
uuid: user.uuid,
id: user.id,
username: user.username,
email: `${user.username}@test.com`,
email_confirmed: true,
},
app,
});
describe('actor scoping', () => {
it('rejects calls without an actor in context', async () => {
await expect(
runWithContext({}, () =>
target.create({
appId: 'test',
workerName: 'test',
filePath: '/test.js',
}),
),
).rejects.toMatchObject({ statusCode: 401 });
});
it('rejects calls with an actor missing user.id', async () => {
const noIdActor = { user: { uuid: 'uuid' } } as Actor;
await expect(
inCtx(
() =>
target.create({
appId: 'test',
workerName: 'test',
filePath: '/test.js',
}),
noIdActor,
),
).rejects.toMatchObject({ statusCode: 401 });
});
});
describe('create', () => {
it('rejects when workerName is missing', async () => {
await expect(
inCtx(() =>
target.create({
appId: 'test',
workerName: '',
filePath: '/test.js',
}),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects when filePath is missing', async () => {
await expect(
inCtx(() =>
target.create({
appId: 'test',
workerName: 'myworker',
filePath: '',
}),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects invalid worker names (special characters)', async () => {
await expect(
inCtx(() =>
target.create({
appId: 'test',
workerName: 'invalid name!',
filePath: '/test.js',
}),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects worker names with dots', async () => {
await expect(
inCtx(() =>
target.create({
appId: 'test',
workerName: 'my.worker',
filePath: '/test.js',
}),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('allows valid worker names with underscores and dashes', async () => {
// This will fail at the CF config check (503) rather than the
// name validation (400), proving the name was accepted.
await expect(
inCtx(() =>
target.create({
appId: 'test-app',
workerName: 'my-worker_01',
filePath: '/test.js',
}),
),
).rejects.toMatchObject({ statusCode: 503 });
});
it('lowercases the worker name', async () => {
await expect(
inCtx(() =>
target.create({
appId: 'test-app',
workerName: 'MyWorker',
filePath: '/test.js',
}),
),
).rejects.toMatchObject({ statusCode: 503 });
});
it('returns 503 when Cloudflare Workers is not configured', async () => {
await expect(
inCtx(() =>
target.create({
appId: 'test-app',
workerName: 'validname',
filePath: '/test.js',
}),
),
).rejects.toMatchObject({ statusCode: 503 });
});
it('rejects reserved worker names', async () => {
const serverWithReserved = await setupTestServer({
reserved_words: ['admin', 'api'],
} as never);
const driverWithReserved = serverWithReserved.drivers
.workers as unknown as WorkerDriver;
try {
await expect(
runWithContext({ actor }, () =>
driverWithReserved.create({
appId: 'test',
workerName: 'admin',
filePath: '/test.js',
}),
),
).rejects.toMatchObject({ statusCode: 400 });
} finally {
await serverWithReserved.shutdown();
}
});
});
// An app may bind a worker to itself or to an app it created for the same
// user (the sandbox case), and to nothing else. Deploys die at the CF
// config check (503) in tests, so 503 here means "the binding was
// authorized" the same way it means "the name was accepted" above.
describe('app-scoped worker binding', () => {
let seq = 0;
const seedApps = async () => {
const tag = `wkbind${seq++}`;
const owner = await makeUser(tag);
const mkApp = async (label: string, appOwner?: number) =>
await server.stores.app.create(
{
name: `${label}-${tag}`,
title: `${label}-${tag}`,
index_url: `https://${label}-${tag}.example.com/`,
},
{ ownerUserId: owner.id, appOwner },
);
const builder = await mkApp('builder');
const generated = await mkApp('generated', builder.id);
const unrelated = await mkApp('unrelated');
const builderActor = actorFor(owner, {
uid: builder.uid,
id: builder.id,
});
return { owner, tag, builder, generated, unrelated, builderActor };
};
const deploy = (appId: string, name: string) =>
target.create({ appId, workerName: name, filePath: '/test.js' });
it('lets an app bind a worker to an app it created', async () => {
const { generated, builderActor } = await seedApps();
await expect(
inCtx(
() => deploy(generated.uid, 'wk-sandboxed'),
builderActor,
),
).rejects.toMatchObject({ statusCode: 503 });
});
it('lets an app bind a worker to itself', async () => {
const { builder, builderActor } = await seedApps();
await expect(
inCtx(() => deploy(builder.uid, 'wk-self'), builderActor),
).rejects.toMatchObject({ statusCode: 503 });
});
it('rejects an app binding a worker to an app it did not create', async () => {
const { unrelated, builderActor } = await seedApps();
await expect(
inCtx(
() => deploy(unrelated.uid, 'wk-unrelated'),
builderActor,
),
).rejects.toMatchObject({ statusCode: 403 });
});
it('rejects an app binding a worker to an unknown app', async () => {
const { builderActor } = await seedApps();
await expect(
inCtx(
() => deploy('app-does-not-exist', 'wk-ghost'),
builderActor,
),
).rejects.toMatchObject({ statusCode: 403 });
});
it('rejects a child app owned by a different user', async () => {
const { builder, builderActor, tag } = await seedApps();
// `app_owner` matches the caller, but the row belongs to someone
// else — both halves have to line up.
const stranger = await makeUser(`${tag}-stranger`);
const otherUsersChild = await server.stores.app.create(
{
name: `other-child-${tag}`,
title: `other-child-${tag}`,
index_url: `https://other-child-${tag}.example.com/`,
},
{ ownerUserId: stranger.id, appOwner: builder.id },
);
await expect(
inCtx(
() => deploy(otherUsersChild.uid, 'wk-otheruser'),
builderActor,
),
).rejects.toMatchObject({ statusCode: 403 });
});
it('lets a user-token actor bind a worker to any app it names', async () => {
const { unrelated, owner } = await seedApps();
await expect(
inCtx(() => deploy(unrelated.uid, 'wk-root'), actorFor(owner)),
).rejects.toMatchObject({ statusCode: 503 });
});
});
describe('destroy', () => {
let seq = 0;
// A user with a builder app, a generated app it created, and a worker
// row bound to whichever of them the test names.
const seedWorker = async (bindTo: 'builder' | 'generated' | null) => {
const tag = `wkdel${seq++}`;
const owner = await makeUser(tag);
const builder = await server.stores.app.create(
{
name: `builder-${tag}`,
title: `builder-${tag}`,
index_url: `https://builder-${tag}.example.com/`,
},
{ ownerUserId: owner.id },
);
const generated = await server.stores.app.create(
{
name: `generated-${tag}`,
title: `generated-${tag}`,
index_url: `https://generated-${tag}.example.com/`,
},
{ ownerUserId: owner.id, appOwner: builder.id },
);
const appOwner =
bindTo === 'builder'
? builder.id
: bindTo === 'generated'
? generated.id
: null;
const name = tag;
await server.stores.subdomain.create({
userId: owner.id,
subdomain: `workers.puter.${name}`,
rootDirId: null,
associatedAppId: null,
appOwner,
});
return { tag, owner, builder, generated, name };
};
it('rejects when workerName is missing', async () => {
await expect(
inCtx(() => target.destroy({ workerName: '' })),
).rejects.toMatchObject({ statusCode: 400 });
});
it('returns 404 for a worker that does not exist', async () => {
await expect(
inCtx(() => target.destroy({ workerName: 'validname' })),
).rejects.toMatchObject({ statusCode: 404 });
});
it('lets an app destroy a worker bound to an app it created', async () => {
const { owner, builder, name } = await seedWorker('generated');
// 503 (not 403) means the ownership chain was accepted and the
// call reached the deploy backend.
await expect(
inCtx(
() => target.destroy({ workerName: name }),
actorFor(owner, { uid: builder.uid, id: builder.id }),
),
).rejects.toMatchObject({ statusCode: 503 });
});
it('rejects an app destroying a worker bound to an app it does not own', async () => {
const { tag, owner, name } = await seedWorker('generated');
const outsider = await server.stores.app.create(
{
name: `outsider-${tag}`,
title: `outsider-${tag}`,
index_url: `https://outsider-${tag}.example.com/`,
},
{ ownerUserId: owner.id },
);
await expect(
inCtx(
() => target.destroy({ workerName: name }),
actorFor(owner, { uid: outsider.uid, id: outsider.id }),
),
).rejects.toMatchObject({ statusCode: 403 });
});
it("rejects destroying another user's worker", async () => {
const { tag, name } = await seedWorker(null);
const intruder = await makeUser(`${tag}-intruder`);
await expect(
inCtx(
() => target.destroy({ workerName: name }),
actorFor(intruder),
),
).rejects.toMatchObject({ statusCode: 403 });
});
});
describe('getFilePaths', () => {
it('returns an empty array when the user has no workers', async () => {
const res = await inCtx(() => target.getFilePaths({}));
expect(res).toEqual([]);
});
it('returns an empty array when querying a non-existent worker name', async () => {
const res = await inCtx(() =>
target.getFilePaths({ workerName: 'nonexistent' }),
);
expect(res).toEqual([]);
});
it('shows an app its own workers and the ones it sandboxed, but not another app’s', async () => {
const tag = `wklist${Math.random().toString(36).slice(2, 8)}`;
const owner = await makeUser(tag);
const mkApp = async (label: string, appOwner?: number) =>
await server.stores.app.create(
{
name: `${label}-${tag}`,
title: `${label}-${tag}`,
index_url: `https://${label}-${tag}.example.com/`,
},
{ ownerUserId: owner.id, appOwner },
);
const builder = await mkApp('builder');
const generated = await mkApp('generated', builder.id);
const unrelated = await mkApp('unrelated');
const seed = async (name: string, appOwner: number | null) =>
await server.stores.subdomain.create({
userId: owner.id,
subdomain: `workers.puter.${name}`,
rootDirId: null,
associatedAppId: null,
appOwner,
});
await seed(`${tag}-own`, builder.id);
await seed(`${tag}-sandboxed`, generated.id);
await seed(`${tag}-other`, unrelated.id);
await seed(`${tag}-userscoped`, null);
const seen = (
(await inCtx(
() => target.getFilePaths({}),
actorFor(owner, { uid: builder.uid, id: builder.id }),
)) as Array<{ name: string }>
).map((r) => r.name);
expect(seen).toContain(`${tag}-own`);
expect(seen).toContain(`${tag}-sandboxed`);
expect(seen).not.toContain(`${tag}-other`);
expect(seen).not.toContain(`${tag}-userscoped`);
});
it('shows a user-token actor every worker in the account', async () => {
const tag = `wkall${Math.random().toString(36).slice(2, 8)}`;
const owner = await makeUser(tag);
const app = await server.stores.app.create(
{
name: `app-${tag}`,
title: `app-${tag}`,
index_url: `https://app-${tag}.example.com/`,
},
{ ownerUserId: owner.id },
);
for (const [name, appOwner] of [
[`${tag}-bound`, app.id],
[`${tag}-loose`, null],
] as Array<[string, number | null]>) {
await server.stores.subdomain.create({
userId: owner.id,
subdomain: `workers.puter.${name}`,
rootDirId: null,
associatedAppId: null,
appOwner,
});
}
const seen = (
(await inCtx(
() => target.getFilePaths({}),
actorFor(owner),
)) as Array<{ name: string }>
).map((r) => r.name);
expect(seen).toEqual(
expect.arrayContaining([`${tag}-bound`, `${tag}-loose`]),
);
});
it('returns app_uid for sandboxed/app-bound workers and null for user-scoped workers', async () => {
const tag = `wkuid${Math.random().toString(36).slice(2, 8)}`;
const owner = await makeUser(tag);
const app = await server.stores.app.create(
{
name: `app-${tag}`,
title: `app-${tag}`,
index_url: `https://app-${tag}.example.com/`,
},
{ ownerUserId: owner.id },
);
await server.stores.subdomain.create({
userId: owner.id,
subdomain: `workers.puter.${tag}-bound`,
rootDirId: null,
associatedAppId: null,
appOwner: app.id,
});
await server.stores.subdomain.create({
userId: owner.id,
subdomain: `workers.puter.${tag}-plain`,
rootDirId: null,
associatedAppId: null,
appOwner: null,
});
const seen = (await inCtx(
() => target.getFilePaths({}),
actorFor(owner),
)) as Array<{ name: string; app_uid: string | null }>;
const bound = seen.find((r) => r.name === `${tag}-bound`);
const plain = seen.find((r) => r.name === `${tag}-plain`);
expect(bound).toBeDefined();
expect(bound?.app_uid).toBe(app.uid);
expect(plain).toBeDefined();
expect(plain?.app_uid).toBeNull();
});
});
describe('getFilePaths lookups', () => {
const seedWorker = async (tag: string) => {
const owner = await makeUser(tag);
await generateDefaultFsentries(
server.clients.db,
server.stores.user,
owner,
);
const dir = (await server.stores.fsEntry.getEntryByPath(
`/${owner.username}/Public`,
))!;
const app = await server.stores.app.create(
{
name: `app-${tag}`,
title: `app-${tag}`,
index_url: `https://app-${tag}.example.com/`,
},
{ ownerUserId: owner.id },
);
await server.stores.subdomain.create({
userId: owner.id,
subdomain: `workers.puter.${tag}`,
rootDirId: dir.id,
associatedAppId: null,
appOwner: app.id,
});
return { owner, dir, app };
};
const newTag = (label: string) =>
`${label}${Math.random().toString(36).slice(2, 8)}`;
it('loads root dirs and apps together and shapes each row from them', async () => {
const tag = newTag('wkboth');
const { owner, dir, app } = await seedWorker(tag);
const realEntries = server.stores.fsEntry.getEntriesByIds.bind(
server.stores.fsEntry,
);
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const entries = vi
.spyOn(server.stores.fsEntry, 'getEntriesByIds')
.mockImplementation(async (ids) => {
await gate;
return realEntries(ids);
});
const apps = vi.spyOn(server.stores.app, 'getByIds');
try {
const pending = inCtx(
() => target.getFilePaths({}),
actorFor(owner),
);
await vi.waitFor(() => expect(entries).toHaveBeenCalled());
expect(apps).toHaveBeenCalledWith([app.id]);
release();
expect(await pending).toEqual([
{
name: tag,
url: `https://${tag}.puter.work`,
file_path: dir.path,
file_uid: dir.uuid,
app_uid: app.uid,
created_at: expect.any(String),
},
]);
} finally {
release();
entries.mockRestore();
apps.mockRestore();
}
});
it("doesn't look up the root dir of another user's worker", async () => {
const tag = newTag('wkforeign');
const { dir } = await seedWorker(tag);
const intruder = await makeUser(newTag('wkintruder'));
const entries = vi.spyOn(server.stores.fsEntry, 'getEntriesByIds');
try {
const res = await inCtx(
() => target.getFilePaths({ workerName: tag }),
actorFor(intruder),
);
expect(res).toEqual([]);
for (const [ids] of entries.mock.calls)
expect(ids).not.toContain(dir.id);
} finally {
entries.mockRestore();
}
});
it('surfaces a root dir lookup failure over an app lookup failure', async () => {
const tag = newTag('wkfail');
const { owner } = await seedWorker(tag);
const unhandled = vi.fn();
process.on('unhandledRejection', unhandled);
const entries = vi
.spyOn(server.stores.fsEntry, 'getEntriesByIds')
.mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
throw new Error('entries failed');
});
const apps = vi
.spyOn(server.stores.app, 'getByIds')
.mockRejectedValue(new Error('apps failed'));
try {
await expect(
inCtx(() => target.getFilePaths({}), actorFor(owner)),
).rejects.toThrow('entries failed');
await new Promise((resolve) => setTimeout(resolve, 20));
expect(unhandled).not.toHaveBeenCalled();
} finally {
process.off('unhandledRejection', unhandled);
entries.mockRestore();
apps.mockRestore();
}
});
});
describe('getFilePaths pagination', () => {
let nextUserId = 90_000;
const seedWorkers = async (count: number) => {
const userId = nextUserId++;
const workerActor = makeActor({
user: {
uuid: `wk-user-${userId}`,
id: userId,
username: `wk-user-${userId}`,
email: `wk-${userId}@test.com`,
email_confirmed: true,
},
app: undefined,
});
const names: string[] = [];
for (let i = 0; i < count; i++) {
const name = `wk${userId}n${i}`;
names.push(name);
await server.stores.subdomain.create({
userId,
subdomain: `workers.puter.${name}`,
rootDirId: null,
associatedAppId: null,
appOwner: null,
});
}
return { workerActor, names };
};
it('keeps the bare array response when no pagination params are given', async () => {
const { workerActor, names } = await seedWorkers(3);
const res = (await inCtx(
() => target.getFilePaths({}),
workerActor,
)) as Array<{ name: string }>;
expect(Array.isArray(res)).toBe(true);
expect(res.map((r) => r.name)).toEqual(names);
});
it('returns the envelope and pages with cursors when limit is given', async () => {
const { workerActor, names } = await seedWorkers(5);
const seen: string[] = [];
let cursor: string | null | undefined = null;
do {
const page = (await inCtx(
() => target.getFilePaths({ limit: 2, cursor }),
workerActor,
)) as { items: Array<{ name: string }>; cursor?: string };
seen.push(...page.items.map((r) => r.name));
cursor = page.cursor;
// Sealed: it reads as nothing but a cursor.
if (cursor) expect(() => decodeCursor(cursor)).toThrow();
} while (cursor);
expect(seen).toEqual(names);
});
it('supports offset paging and totals', async () => {
const { workerActor, names } = await seedWorkers(4);
const page = (await inCtx(
() =>
target.getFilePaths({
limit: 10,
offset: 1,
includeTotal: true,
}),
workerActor,
)) as { items: Array<{ name: string }>; total?: number };
expect(page.items.map((r) => r.name)).toEqual(names.slice(1));
expect(page.total).toBe(names.length);
});
it('rejects cursor combined with offset', async () => {
const { workerActor } = await seedWorkers(2);
const first = (await inCtx(
() => target.getFilePaths({ limit: 1, cursor: null }),
workerActor,
)) as { cursor?: string };
expect(first.cursor).toBeDefined();
await expect(
inCtx(
() =>
target.getFilePaths({
offset: 1,
cursor: first.cursor,
}),
workerActor,
),
).rejects.toMatchObject({ statusCode: 400 });
});
});
describe('getLoggingUrl', () => {
it('returns null when loggingUrl is not configured', async () => {
const res = await inCtx(() => target.getLoggingUrl());
expect(res).toBeNull();
});
it('returns the configured loggingUrl', async () => {
const serverWithLogging = await setupTestServer({
workers: { loggingUrl: 'https://logs.test/view' },
} as never);
const driverWithLogging = serverWithLogging.drivers
.workers as unknown as WorkerDriver;
try {
const res = await runWithContext({ actor }, () =>
driverWithLogging.getLoggingUrl(),
);
expect(res).toBe('https://logs.test/view');
} finally {
await serverWithLogging.shutdown();
}
});
});
describe('getReportedCosts', () => {
it('returns an empty array (workers have no cost reporting)', () => {
const rows = target.getReportedCosts();
expect(rows).toEqual([]);
});
});
describe('admission bypass', () => {
const unverified = (): Actor =>
makeActor({
user: {
uuid: 'unverified-uuid',
id: 1,
username: 'unverified',
email: 'unverified@test.com',
email_confirmed: false,
},
});
it('rejects an unverified account without the bypass', async () => {
const strictServer = await setupTestServer({
strict_email_verification_required: true,
} as never);
const strictDriver = strictServer.drivers
.workers as unknown as WorkerDriver;
try {
await expect(
runWithContext({ actor: unverified() }, () =>
strictDriver.create({
appId: 'test-app',
workerName: 'existing',
filePath: '/test.js',
}),
),
).rejects.toMatchObject({
statusCode: 400,
message: 'Account email is not verified',
});
} finally {
await strictServer.shutdown();
}
});
it('lets the bypass past the verified-email gate', async () => {
const strictServer = await setupTestServer({
strict_email_verification_required: true,
} as never);
const strictDriver = strictServer.drivers
.workers as unknown as WorkerDriver;
try {
// Cloudflare is unconfigured in tests, so getting as far as the
// 503 proves the email gate no longer stopped the call.
await expect(
runWithContext({ actor: unverified() }, () =>
strictDriver.create({
appId: 'test-app',
workerName: 'existing',
filePath: '/test.js',
[INTERNAL_ADMISSION_BYPASS]: true,
}),
),
).rejects.toMatchObject({ statusCode: 503 });
} finally {
await strictServer.shutdown();
}
});
it('skips the per-user worker limit', async () => {
// The limit is checked after the Cloudflare-config gate, so the
// driver needs credentials to reach it. Set them on the running
// server rather than booting a configured one: a server that boots
// with an account id also demands a built worker preamble, which
// is not built for backend tests.
const store = server.stores.subdomain;
const originalList = store.listByUserIdAndPrefix.bind(store);
const driverConfig = (
target as unknown as { config: Record<string, unknown> }
).config;
const originalWorkersConfig = driverConfig.workers;
driverConfig.workers = {
XAUTHKEY: 'test-key',
ACCOUNTID: 'test-account',
};
let listCalls = 0;
store.listByUserIdAndPrefix = (async () => {
listCalls++;
return Array.from({ length: 100 }, (_, i) => ({
subdomain: `workers.puter.w${i}`,
})) as never;
}) as typeof store.listByUserIdAndPrefix;
try {
await expect(
inCtx(() =>
target.create({
appId: 'test-app',
workerName: 'atcap',
filePath: '/test.js',
}),
),
).rejects.toMatchObject({ statusCode: 403 });
expect(listCalls).toBe(1);
// With the bypass the limit is never even counted; the call
// fails later, on the missing source file.
await expect(
inCtx(() =>
target.create({
appId: 'test-app',
workerName: 'atcap',
filePath: '/test.js',
[INTERNAL_ADMISSION_BYPASS]: true,
}),
),
).rejects.not.toMatchObject({ statusCode: 403 });
expect(listCalls).toBe(1);
} finally {
store.listByUserIdAndPrefix = originalList;
driverConfig.workers = originalWorkersConfig;
}
});
it('cannot be forged through caller-supplied args', async () => {
const strictServer = await setupTestServer({
strict_email_verification_required: true,
} as never);
const strictDriver = strictServer.drivers
.workers as unknown as WorkerDriver;
// Driver args reach the method as parsed JSON from the request
// body, so parse these the same way a call would.
const forgeries = [
'{"skipAdmission":true}',
'{"skipAdmissionChecks":true}',
'{"INTERNAL_ADMISSION_BYPASS":true}',
'{"Symbol(workers.internalAdmissionBypass)":true}',
'{"__proto__":{"skipAdmission":true}}',
];
try {
for (const body of forgeries) {
const forged = JSON.parse(body) as Record<string, unknown>;
expect(Object.getOwnPropertySymbols(forged)).toHaveLength(
0,
);
await expect(
runWithContext({ actor: unverified() }, () =>
strictDriver.create({
...forged,
appId: 'test-app',
workerName: 'existing',
filePath: '/test.js',
} as Parameters<WorkerDriver['create']>[0]),
),
).rejects.toMatchObject({
statusCode: 400,
message: 'Account email is not verified',
});
}
} finally {
await strictServer.shutdown();
}
});
});
});
// Boots local workerd mode, which sticks for the rest of this file: runs last.
describe('WorkerDriver operator teardown on local workerd', () => {
let env: PuterTestEnv;
let driver: WorkerDriver;
beforeAll(async () => {
env = await setupPuterTestEnv({
workers: { localServer: true },
} as never);
driver = env.server.drivers.workers as unknown as WorkerDriver;
}, 60_000);
afterAll(async () => {
await env?.shutdown();
});
const seedWorker = async (label: string) => {
const owner = await env.server.stores.user.create({
username: `wklocal-${label}`,
uuid: `wklocal-uuid-${label}`,
password: null,
email: null,
});
const row = await env.server.stores.subdomain.create({
userId: owner.id,
subdomain: `workers.puter.${label}`,
rootDirId: null,
associatedAppId: null,
});
return { owner, row };
};
/** A worker deployed for real through the driver, so Miniflare holds it. */
const deployLocal = async (label: string) => {
const owner = await env.server.stores.user.getByUsername(
env.users.user.username,
);
const ownerActor = resolveActor({
user: {
uuid: owner!.uuid,
id: owner!.id,
username: owner!.username,
email: `${owner!.username}@test.com`,
email_confirmed: true,
},
});
const path = `/${owner!.username}/${label}.js`;
const source = `router.get('/', () => new Response('${label}'));`;
await runWithContext({ actor: ownerActor }, () =>
env.server.services.fs.write(owner!.id, {
fileMetadata: {
path,
size: Buffer.byteLength(source),
contentType: 'application/javascript',
overwrite: true,
},
fileContent: Buffer.from(source),
}),
);
const deployed = (await runWithContext({ actor: ownerActor }, () =>
driver.create({ appId: '', workerName: label, filePath: path }),
)) as { success?: boolean };
expect(deployed.success).toBe(true);
const row = await env.server.stores.subdomain.getBySubdomain(
`workers.puter.${label}`,
{ primary: true },
);
return { owner: owner!, row: row! };
};
it('takes the local script down, drops the row, and reports the owner', async () => {
const { owner, row } = await deployLocal('torn');
const local = env.server.services.localworkerservice;
const deleteLocal = vi.spyOn(local, 'cfDeleteLocal');
const served = await local.cfCallLocal(
'torn',
new Request('http://worker.localhost/'),
);
expect(await served.text()).toBe('torn');
const result = await driver[INTERNAL_ADMIN_DESTROY]({
uuid: row.uuid,
});
expect(deleteLocal).toHaveBeenCalledWith('torn');
expect(result).toEqual({
subdomain: 'workers.puter.torn',
userId: owner.id,
workerName: 'torn',
});
expect(
await env.server.stores.subdomain.getByUuid(row.uuid, {
primary: true,
}),
).toBeNull();
// The Miniflare instance is disposed with the row: nothing answers now.
const after = await local.cfCallLocal(
'torn',
new Request('http://worker.localhost/'),
);
expect(after.status).toBe(404);
}, 60_000);
it('is absent from the callable surface and unreachable over /drivers/call', async () => {
const { row } = await seedWorker('kept');
expect(resolveCallableMethods(driver).has('destroy')).toBe(true);
for (const method of [
String(INTERNAL_ADMIN_DESTROY.description),
'INTERNAL_ADMIN_DESTROY',
`Symbol(${INTERNAL_ADMIN_DESTROY.description})`,
]) {
expect(resolveCallableMethods(driver).has(method)).toBe(false);
const res = await fetch(new URL('/drivers/call', env.apiOrigin), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${env.users.user.token}`,
Origin: env.apiOrigin,
},
body: JSON.stringify({
interface: 'workers',
method,
args: { uuid: row.uuid },
}),
});
expect(res.status, method).toBe(404);
}
expect(
await env.server.stores.subdomain.getByUuid(row.uuid, {
primary: true,
}),
).toBeTruthy();
});
});