diff --git a/src/backend/controllers/peer/PeerController.test.ts b/src/backend/controllers/peer/PeerController.test.ts new file mode 100644 index 000000000..0d2703073 --- /dev/null +++ b/src/backend/controllers/peer/PeerController.test.ts @@ -0,0 +1,273 @@ +import type { Request, Response } from 'express'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { PuterRouter } from '../../core/http/PuterRouter.js'; +import { PuterServer } from '../../server.js'; +import { setupTestServer } from '../../testUtil.js'; +import { PEER_COSTS } from './costs.js'; +import type { PeerController } from './PeerController.js'; + +let server: PuterServer; +let controller: PeerController; + +beforeAll(async () => { + server = await setupTestServer({ + peers: { + signaller_url: 'wss://signal.test', + fallback_ice: [{ urls: 'stun:stun.test' }], + internal_auth_secret: 'test-secret', + }, + }); + controller = server.controllers.peer as unknown as PeerController; +}); + +afterAll(async () => { + await server?.shutdown(); +}); + +interface CapturedResponse { + statusCode: number; + body: unknown; +} + +const makeReq = (init: { + body?: unknown; + headers?: Record; + actor?: unknown; + method?: string; +}): Request => { + return { + body: init.body ?? {}, + query: {}, + headers: init.headers ?? {}, + actor: init.actor, + method: init.method ?? 'POST', + } as unknown as Request; +}; + +const makeRes = () => { + const captured: CapturedResponse = { statusCode: 200, body: undefined }; + const res = { + json: vi.fn((value: unknown) => { + captured.body = value; + return res; + }), + status: vi.fn((code: number) => { + captured.statusCode = code; + return res; + }), + setHeader: vi.fn(() => res), + set: vi.fn(() => res), + end: vi.fn(() => res), + send: vi.fn((value: unknown) => { + captured.body = value; + return res; + }), + }; + return { res: res as unknown as Response, captured }; +}; + +describe('PeerController', () => { + describe('getReportedCosts', () => { + it('reports a row per PEER cost type with the configured rate', () => { + const rows = controller.getReportedCosts(); + expect(rows).toEqual( + expect.arrayContaining([ + { + usageType: 'turn:egress-bytes', + ucentsPerUnit: PEER_COSTS['turn:egress-bytes'], + unit: 'byte', + source: 'controller:peer', + }, + ]), + ); + expect(rows.length).toBe(Object.keys(PEER_COSTS).length); + }); + }); + + describe('signaller-info', () => { + it('returns the configured signaller URL and fallback ICE servers', () => { + const { res, captured } = makeRes(); + const req = makeReq({ method: 'GET' }); + + const router = new PuterRouter(); + controller.registerRoutes(router); + + const signallerRoute = router.routes.find( + (r) => r.path === '/peer/signaller-info', + ); + expect(signallerRoute).toBeDefined(); + signallerRoute!.handler(req, res); + + expect(captured.body).toEqual({ + url: 'wss://signal.test', + fallbackIce: [{ urls: 'stun:stun.test' }], + }); + }); + + it('returns null url and empty fallbackIce when peers config is absent', async () => { + const minimalServer = await setupTestServer(); + const minimalController = minimalServer.controllers + .peer as unknown as PeerController; + try { + const router = new PuterRouter(); + minimalController.registerRoutes(router); + const route = router.routes.find( + (r) => r.path === '/peer/signaller-info', + ); + + const { res, captured } = makeRes(); + route!.handler(makeReq({ method: 'GET' }), res); + + expect(captured.body).toEqual({ + url: null, + fallbackIce: [], + }); + } finally { + await minimalServer.shutdown(); + } + }); + }); + + describe('generate-turn', () => { + it('returns 503 when TURN is not configured', async () => { + const minimalServer = await setupTestServer(); + const minimalController = minimalServer.controllers + .peer as unknown as PeerController; + try { + const router = new PuterRouter(); + minimalController.registerRoutes(router); + const route = router.routes.find( + (r) => r.path === '/peer/generate-turn', + ); + + const req = makeReq({ + actor: { + user: { uuid: '00000000-0000-0000-0000-000000000001' }, + }, + }); + + await expect( + route!.handler(req, makeRes().res), + ).rejects.toMatchObject({ statusCode: 503 }); + } finally { + await minimalServer.shutdown(); + } + }); + }); + + describe('ingest-usage', () => { + let ingestHandler: Function; + + beforeAll(() => { + const router = new PuterRouter(); + controller.registerRoutes(router); + const route = router.routes.find( + (r) => r.path === '/turn/ingest-usage', + ); + ingestHandler = route!.handler; + }); + + it('rejects requests without valid internal auth secret', async () => { + const req = makeReq({ + body: { records: [] }, + headers: { 'x-puter-internal-auth': 'wrong-secret' }, + }); + await expect(ingestHandler(req, makeRes().res)).rejects.toMatchObject( + { statusCode: 403 }, + ); + }); + + it('rejects requests with missing auth header', async () => { + const req = makeReq({ body: { records: [] } }); + await expect(ingestHandler(req, makeRes().res)).rejects.toMatchObject( + { statusCode: 403 }, + ); + }); + + it('rejects when records is not an array', async () => { + const req = makeReq({ + body: { records: 'not-array' }, + headers: { 'x-puter-internal-auth': 'test-secret' }, + }); + await expect(ingestHandler(req, makeRes().res)).rejects.toMatchObject( + { statusCode: 400 }, + ); + }); + + it('returns ok for an empty records array', async () => { + const { res, captured } = makeRes(); + const req = makeReq({ + body: { records: [] }, + headers: { 'x-puter-internal-auth': 'test-secret' }, + }); + await ingestHandler(req, res); + expect(captured.body).toEqual({ ok: true }); + }); + + it('skips records with non-positive egressBytes', async () => { + const { res, captured } = makeRes(); + const req = makeReq({ + body: { + records: [ + { egressBytes: 0, userId: 'AAAAAAAAAAAAAAAAAAAAAA' }, + { egressBytes: -5, userId: 'AAAAAAAAAAAAAAAAAAAAAA' }, + { userId: 'AAAAAAAAAAAAAAAAAAAAAA' }, + ], + }, + headers: { 'x-puter-internal-auth': 'test-secret' }, + }); + await ingestHandler(req, res); + expect(captured.body).toEqual({ ok: true }); + }); + + it('skips records with missing or invalid userId', async () => { + const { res, captured } = makeRes(); + const req = makeReq({ + body: { + records: [ + { egressBytes: 100 }, + { egressBytes: 100, userId: '' }, + { egressBytes: 100, userId: 'not-valid-b64' }, + ], + }, + headers: { 'x-puter-internal-auth': 'test-secret' }, + }); + await ingestHandler(req, res); + expect(captured.body).toEqual({ ok: true }); + }); + + it('skips null and non-object records gracefully', async () => { + const { res, captured } = makeRes(); + const req = makeReq({ + body: { + records: [null, undefined, 42, 'string'], + }, + headers: { 'x-puter-internal-auth': 'test-secret' }, + }); + await ingestHandler(req, res); + expect(captured.body).toEqual({ ok: true }); + }); + + it('rejects when body is missing entirely', async () => { + const req = makeReq({ + body: undefined, + headers: { 'x-puter-internal-auth': 'test-secret' }, + }); + await expect(ingestHandler(req, makeRes().res)).rejects.toMatchObject( + { statusCode: 400 }, + ); + }); + }); + + describe('route registration', () => { + it('registers all three expected routes', () => { + const router = new PuterRouter(); + controller.registerRoutes(router); + + const paths = router.routes.map((r) => r.path); + expect(paths).toContain('/peer/signaller-info'); + expect(paths).toContain('/peer/generate-turn'); + expect(paths).toContain('/turn/ingest-usage'); + }); + }); +}); diff --git a/src/backend/controllers/webdav/WebDAVController.test.ts b/src/backend/controllers/webdav/WebDAVController.test.ts new file mode 100644 index 000000000..1317a1efb --- /dev/null +++ b/src/backend/controllers/webdav/WebDAVController.test.ts @@ -0,0 +1,654 @@ +// This suite tests basic features of puter webdav. it is not a comprehensive webdav test suite unlike litmus +// but rather it performs some common sense checks to ensure that WebDAV support isn't irrevocably broken in puter +import type { Request, Response } from 'express'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { PuterRouter } from '../../core/http/PuterRouter.js'; +import { PuterServer } from '../../server.js'; +import { setupTestServer } from '../../testUtil.js'; +import type { WebDAVController } from './WebDAVController.js'; + +let server: PuterServer; +let controller: WebDAVController; +let dispatchMiddleware: Function; + +beforeAll(async () => { + server = await setupTestServer(); + controller = server.controllers.webdav as unknown as WebDAVController; + + const router = new PuterRouter(); + controller.registerRoutes(router); + + // WebDAVController registers a single `use()` middleware on the `dav` + // subdomain. Grab it to call directly in tests. + dispatchMiddleware = router.routes[0]!.handler; +}); + +afterAll(async () => { + await server?.shutdown(); +}); + +interface CapturedResponse { + statusCode: number; + body: unknown; + headers: Record; + ended: boolean; +} + +const makeReq = (init: { + method: string; + path?: string; + body?: unknown; + headers?: Record; + actor?: unknown; + socket?: unknown; +}): Request => { + return { + method: init.method, + path: init.path ?? '/', + body: init.body ?? {}, + query: {}, + headers: init.headers ?? {}, + actor: init.actor, + socket: init.socket ?? {}, + } as unknown as Request; +}; + +const makeRes = () => { + const captured: CapturedResponse = { + statusCode: 200, + body: undefined, + headers: {}, + ended: false, + }; + const res = { + json: vi.fn((value: unknown) => { + captured.body = value; + return res; + }), + status: vi.fn((code: number) => { + captured.statusCode = code; + return res; + }), + set: vi.fn((obj: Record | string, val?: string) => { + if (typeof obj === 'string') { + captured.headers[obj.toLowerCase()] = val!; + } else { + for (const [k, v] of Object.entries(obj)) { + captured.headers[k.toLowerCase()] = v; + } + } + return res; + }), + setHeader: vi.fn((key: string, val: string) => { + captured.headers[key.toLowerCase()] = val; + return res; + }), + send: vi.fn((value: unknown) => { + captured.body = value; + return res; + }), + end: vi.fn(() => { + captured.ended = true; + return res; + }), + headersSent: false, + }; + return { res: res as unknown as Response, captured }; +}; + +const basicAuth = (user: string, pass: string) => + `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`; + +const noop = vi.fn(); + +describe('WebDAVController', () => { + describe('route registration', () => { + it('registers a single catch-all use() route', () => { + const router = new PuterRouter(); + controller.registerRoutes(router); + expect(router.routes.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('authentication', () => { + it('returns 401 when no auth is provided and no session actor', async () => { + const { res, captured } = makeRes(); + await dispatchMiddleware( + makeReq({ method: 'OPTIONS' }), + res, + noop, + ); + expect(captured.statusCode).toBe(401); + expect(captured.headers['www-authenticate']).toContain('Basic'); + }); + + it('returns 401 for malformed Basic auth (no colon)', async () => { + const { res, captured } = makeRes(); + const encoded = Buffer.from('no-colon-here').toString('base64'); + await dispatchMiddleware( + makeReq({ + method: 'OPTIONS', + headers: { authorization: `Basic ${encoded}` }, + }), + res, + noop, + ); + expect(captured.statusCode).toBe(401); + }); + + it('returns 401 for invalid -token auth', async () => { + const { res, captured } = makeRes(); + await dispatchMiddleware( + makeReq({ + method: 'OPTIONS', + headers: { + authorization: basicAuth('-token', 'bad-token-value'), + }, + }), + res, + noop, + ); + expect(captured.statusCode).toBe(401); + }); + + it('returns 401 for non-existent username', async () => { + const { res, captured } = makeRes(); + await dispatchMiddleware( + makeReq({ + method: 'OPTIONS', + headers: { + authorization: basicAuth( + 'nonexistent-user-xyz', + 'password', + ), + }, + }), + res, + noop, + ); + expect(captured.statusCode).toBe(401); + }); + }); + + describe('OPTIONS (with session actor)', () => { + it('returns 200 with DAV headers when actor is present', async () => { + const { res, captured } = makeRes(); + await dispatchMiddleware( + makeReq({ + method: 'OPTIONS', + actor: { + user: { + id: 1, + uuid: 'test-uuid', + username: 'test', + }, + }, + }), + res, + noop, + ); + expect(captured.statusCode).toBe(200); + expect(captured.headers['dav']).toContain('1'); + expect(captured.headers['allow']).toContain('PROPFIND'); + expect(captured.headers['allow']).toContain('GET'); + expect(captured.headers['allow']).toContain('PUT'); + expect(captured.headers['allow']).toContain('DELETE'); + }); + }); + + describe('unsupported methods', () => { + it('returns 405 for unknown HTTP methods', async () => { + const { res, captured } = makeRes(); + await dispatchMiddleware( + makeReq({ + method: 'PATCH', + actor: { + user: { + id: 1, + uuid: 'test-uuid', + username: 'test', + }, + }, + }), + res, + noop, + ); + expect(captured.statusCode).toBe(405); + expect(captured.headers['allow']).toContain('PROPFIND'); + }); + }); + + describe('GET', () => { + it('returns 404 for a non-existent path', async () => { + const { res, captured } = makeRes(); + await dispatchMiddleware( + makeReq({ + method: 'GET', + path: '/nonexistent-file-that-does-not-exist.txt', + actor: { + user: { + id: 1, + uuid: 'test-uuid', + username: 'test', + }, + }, + }), + res, + noop, + ); + expect(captured.statusCode).toBe(404); + }); + }); + + describe('PROPFIND', () => { + it('returns 207 multistatus for root path', async () => { + const { res, captured } = makeRes(); + await dispatchMiddleware( + makeReq({ + method: 'PROPFIND', + path: '/', + headers: { depth: '0' }, + actor: { + user: { + id: 1, + uuid: 'test-uuid', + username: 'test', + }, + }, + }), + res, + noop, + ); + expect(captured.statusCode).toBe(207); + expect(captured.headers['content-type']).toContain( + 'application/xml', + ); + expect(captured.body).toContain('multistatus'); + }); + + it('returns 404 for a non-existent path', async () => { + const { res, captured } = makeRes(); + await dispatchMiddleware( + makeReq({ + method: 'PROPFIND', + path: '/does-not-exist', + headers: { depth: '0' }, + actor: { + user: { + id: 1, + uuid: 'test-uuid', + username: 'test', + }, + }, + }), + res, + noop, + ); + expect(captured.statusCode).toBe(404); + }); + + it('includes DAV XML properties in the root PROPFIND response', async () => { + const { res, captured } = makeRes(); + await dispatchMiddleware( + makeReq({ + method: 'PROPFIND', + path: '/', + headers: { depth: '0' }, + actor: { + user: { + id: 1, + uuid: 'test-uuid', + username: 'test', + }, + }, + }), + res, + noop, + ); + const xml = captured.body as string; + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(xml).toContain(''); + }); + }); + + describe('MKCOL', () => { + it('rejects creating a collection at root', async () => { + const { res, captured } = makeRes(); + await dispatchMiddleware( + makeReq({ + method: 'MKCOL', + path: '/', + actor: { + user: { + id: 1, + uuid: 'test-uuid', + username: 'test', + }, + }, + }), + res, + noop, + ); + expect(captured.statusCode).toBe(403); + }); + + it('rejects MKCOL with a body', async () => { + const { res, captured } = makeRes(); + await dispatchMiddleware( + makeReq({ + method: 'MKCOL', + path: '/new-collection', + headers: { 'content-length': '10' }, + actor: { + user: { + id: 1, + uuid: 'test-uuid', + username: 'test', + }, + }, + }), + res, + noop, + ); + expect(captured.statusCode).toBe(415); + }); + }); + + describe('PUT', () => { + it('rejects macOS junk files (.DS_Store)', async () => { + const { res, captured } = makeRes(); + await dispatchMiddleware( + makeReq({ + method: 'PUT', + path: '/some/dir/.DS_Store', + actor: { + user: { + id: 1, + uuid: 'test-uuid', + username: 'test', + }, + }, + }), + res, + noop, + ); + expect(captured.statusCode).toBe(422); + }); + + it('rejects macOS resource fork files (._prefix)', async () => { + const { res, captured } = makeRes(); + await dispatchMiddleware( + makeReq({ + method: 'PUT', + path: '/some/dir/._myfile.txt', + actor: { + user: { + id: 1, + uuid: 'test-uuid', + username: 'test', + }, + }, + }), + res, + noop, + ); + expect(captured.statusCode).toBe(422); + }); + }); + + describe('DELETE', () => { + it('rejects delete when ACL denies write access', async () => { + const { res, captured } = makeRes(); + await dispatchMiddleware( + makeReq({ + method: 'DELETE', + path: '/nonexistent-file-to-delete.txt', + actor: { + user: { + id: 1, + uuid: 'test-uuid', + username: 'test', + }, + }, + }), + res, + noop, + ); + expect(captured.statusCode).toBe(403); + }); + }); + + describe('COPY', () => { + it('returns 400 when Destination header is missing', async () => { + const { res, captured } = makeRes(); + await dispatchMiddleware( + makeReq({ + method: 'COPY', + path: '/some/file.txt', + actor: { + user: { + id: 1, + uuid: 'test-uuid', + username: 'test', + }, + }, + }), + res, + noop, + ); + expect(captured.statusCode).toBe(400); + }); + }); + + describe('MOVE', () => { + it('returns 400 when Destination header is missing', async () => { + const { res, captured } = makeRes(); + await dispatchMiddleware( + makeReq({ + method: 'MOVE', + path: '/some/file.txt', + actor: { + user: { + id: 1, + uuid: 'test-uuid', + username: 'test', + }, + }, + }), + res, + noop, + ); + expect(captured.statusCode).toBe(400); + }); + }); + + describe('UNLOCK', () => { + it('returns 400 when Lock-Token header is missing', async () => { + const { res, captured } = makeRes(); + await dispatchMiddleware( + makeReq({ + method: 'UNLOCK', + path: '/some/file.txt', + actor: { + user: { + id: 1, + uuid: 'test-uuid', + username: 'test', + }, + }, + }), + res, + noop, + ); + expect(captured.statusCode).toBe(400); + }); + + it('returns 204 for an expired/unknown lock token (idempotent)', async () => { + const { res, captured } = makeRes(); + await dispatchMiddleware( + makeReq({ + method: 'UNLOCK', + path: '/some/file.txt', + headers: { + 'lock-token': + '', + }, + actor: { + user: { + id: 1, + uuid: 'test-uuid', + username: 'test', + }, + }, + }), + res, + noop, + ); + expect(captured.statusCode).toBe(204); + }); + }); + + describe('LOCK', () => { + it('creates a new exclusive lock and returns XML with lock token', async () => { + const { res, captured } = makeRes(); + await dispatchMiddleware( + makeReq({ + method: 'LOCK', + path: '/lockable-file.txt', + actor: { + user: { + id: 1, + uuid: 'test-uuid', + username: 'test', + }, + }, + }), + res, + noop, + ); + expect(captured.statusCode).toBe(200); + expect(captured.headers['content-type']).toContain( + 'application/xml', + ); + const xml = captured.body as string; + expect(xml).toContain('lockdiscovery'); + expect(xml).toContain('urn:uuid:'); + expect(xml).toContain(''); + expect(captured.headers['lock-token']).toContain('urn:uuid:'); + }); + + it('rejects a second exclusive lock on the same path', async () => { + const uniquePath = `/double-lock-${Date.now()}.txt`; + + // First lock + const { res: res1, captured: cap1 } = makeRes(); + await dispatchMiddleware( + makeReq({ + method: 'LOCK', + path: uniquePath, + actor: { + user: { + id: 1, + uuid: 'test-uuid', + username: 'test', + }, + }, + }), + res1, + noop, + ); + expect(cap1.statusCode).toBe(200); + + // Second lock — should be 423 Locked + const { res: res2, captured: cap2 } = makeRes(); + await dispatchMiddleware( + makeReq({ + method: 'LOCK', + path: uniquePath, + actor: { + user: { + id: 1, + uuid: 'test-uuid', + username: 'test', + }, + }, + }), + res2, + noop, + ); + expect(cap2.statusCode).toBe(423); + }); + + it('refreshes an existing lock when If header provides the token', async () => { + const uniquePath = `/refresh-lock-${Date.now()}.txt`; + const { res: res1, captured: cap1 } = makeRes(); + await dispatchMiddleware( + makeReq({ + method: 'LOCK', + path: uniquePath, + actor: { + user: { + id: 1, + uuid: 'test-uuid', + username: 'test', + }, + }, + }), + res1, + noop, + ); + expect(cap1.statusCode).toBe(200); + const lockToken = cap1.headers['lock-token']!.replace( + /[<>]/g, + '', + ); + + // Refresh + const { res: res2, captured: cap2 } = makeRes(); + await dispatchMiddleware( + makeReq({ + method: 'LOCK', + path: uniquePath, + headers: { if: `(<${lockToken}>)` }, + actor: { + user: { + id: 1, + uuid: 'test-uuid', + username: 'test', + }, + }, + }), + res2, + noop, + ); + expect(cap2.statusCode).toBe(200); + const xml = cap2.body as string; + expect(xml).toContain(lockToken); + }); + }); + + describe('error handling', () => { + it('catches HttpError and returns its status code', async () => { + // GET on a non-existent file → HttpError(404) → 404 response + const { res, captured } = makeRes(); + await dispatchMiddleware( + makeReq({ + method: 'GET', + path: '/no-such-file', + actor: { + user: { + id: 1, + uuid: 'test-uuid', + username: 'test', + }, + }, + }), + res, + noop, + ); + expect(captured.statusCode).toBe(404); + }); + }); +}); diff --git a/src/backend/controllers/wisp/WispController.test.ts b/src/backend/controllers/wisp/WispController.test.ts new file mode 100644 index 000000000..1f84d1a7e --- /dev/null +++ b/src/backend/controllers/wisp/WispController.test.ts @@ -0,0 +1,218 @@ +// This tests wisp controller but not wisp itself. That is out of process and out of this repo +// This simply tests the authentication methods that puter wisp expects and uses. +import type { Request, Response } from 'express'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { PuterRouter } from '../../core/http/PuterRouter.js'; +import { PuterServer } from '../../server.js'; +import { setupTestServer } from '../../testUtil.js'; +import type { WispController } from './WispController.js'; + +let server: PuterServer; +let controller: WispController; +let createHandler: Function; +let verifyHandler: Function; + +beforeAll(async () => { + server = await setupTestServer({ + wisp: { server: 'wss://wisp.test' }, + }); + controller = server.controllers.wisp as unknown as WispController; + + const router = new PuterRouter(); + controller.registerRoutes(router); + + createHandler = router.routes.find( + (r) => r.path === '/wisp/relay-token/create', + )!.handler; + verifyHandler = router.routes.find( + (r) => r.path === '/wisp/relay-token/verify', + )!.handler; +}); + +afterAll(async () => { + await server?.shutdown(); +}); + +interface CapturedResponse { + statusCode: number; + body: unknown; +} + +const makeReq = (init: { + body?: unknown; + headers?: Record; + actor?: unknown; +}): Request => { + return { + body: init.body ?? {}, + query: {}, + headers: init.headers ?? {}, + actor: init.actor, + } as unknown as Request; +}; + +const makeRes = () => { + const captured: CapturedResponse = { statusCode: 200, body: undefined }; + const res = { + json: vi.fn((value: unknown) => { + captured.body = value; + return res; + }), + status: vi.fn((code: number) => { + captured.statusCode = code; + return res; + }), + setHeader: vi.fn(() => res), + set: vi.fn(() => res), + send: vi.fn((value: unknown) => { + captured.body = value; + return res; + }), + end: vi.fn(() => res), + }; + return { res: res as unknown as Response, captured }; +}; + +describe('WispController', () => { + describe('route registration', () => { + it('registers both expected routes', () => { + const router = new PuterRouter(); + controller.registerRoutes(router); + const paths = router.routes.map((r) => r.path); + expect(paths).toContain('/wisp/relay-token/create'); + expect(paths).toContain('/wisp/relay-token/verify'); + }); + }); + + describe('create', () => { + it('returns a token and server for an authenticated user', async () => { + const { res, captured } = makeRes(); + const req = makeReq({ + actor: { + user: { + uuid: '00000000-0000-0000-0000-000000000001', + }, + }, + }); + await createHandler(req, res); + + const body = captured.body as { token: string; server: string }; + expect(body.token).toBeDefined(); + expect(typeof body.token).toBe('string'); + expect(body.token.length).toBeGreaterThan(0); + expect(body.server).toBe('wss://wisp.test'); + }); + + it('returns a guest token when actor has no user uuid', async () => { + const { res, captured } = makeRes(); + const req = makeReq({ actor: { user: {} } }); + await createHandler(req, res); + + const body = captured.body as { token: string; server: string }; + expect(body.token).toBeDefined(); + expect(typeof body.token).toBe('string'); + expect(body.server).toBe('wss://wisp.test'); + }); + + it('returns a guest token when actor is absent', async () => { + const { res, captured } = makeRes(); + const req = makeReq({}); + await createHandler(req, res); + + const body = captured.body as { token: string; server: string }; + expect(body.token).toBeDefined(); + expect(body.server).toBe('wss://wisp.test'); + }); + + it('returns null server when wisp config has no server', async () => { + const minServer = await setupTestServer(); + const minController = minServer.controllers + .wisp as unknown as WispController; + try { + const router = new PuterRouter(); + minController.registerRoutes(router); + const handler = router.routes.find( + (r) => r.path === '/wisp/relay-token/create', + )!.handler; + + const { res, captured } = makeRes(); + await handler(makeReq({ actor: { user: {} } }), res); + + const body = captured.body as { token: string; server: unknown }; + expect(body.server).toBeNull(); + } finally { + await minServer.shutdown(); + } + }); + }); + + describe('verify', () => { + it('verifies a valid authenticated-user token', async () => { + const { res: createRes, captured: createCaptured } = makeRes(); + await createHandler( + makeReq({ + actor: { + user: { + uuid: '00000000-0000-0000-0000-000000000001', + }, + }, + }), + createRes, + ); + const token = (createCaptured.body as { token: string }).token; + + const { res, captured } = makeRes(); + await verifyHandler(makeReq({ body: { token } }), res); + + expect(captured.statusCode).toBe(200); + const body = captured.body as { allow: boolean }; + expect(body.allow).toBe(true); + }); + + it('verifies a valid guest token', async () => { + const { res: createRes, captured: createCaptured } = makeRes(); + await createHandler(makeReq({ actor: { user: {} } }), createRes); + const token = (createCaptured.body as { token: string }).token; + + const { res, captured } = makeRes(); + await verifyHandler(makeReq({ body: { token } }), res); + + expect(captured.statusCode).toBe(200); + const body = captured.body as { allow: boolean }; + expect(body.allow).toBe(true); + }); + + it('rejects when token is missing', async () => { + await expect( + verifyHandler(makeReq({ body: {} }), makeRes().res), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('rejects when token is not a string', async () => { + await expect( + verifyHandler( + makeReq({ body: { token: 12345 } }), + makeRes().res, + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('rejects an invalid/tampered token', async () => { + await expect( + verifyHandler( + makeReq({ body: { token: 'not-a-valid-jwt' } }), + makeRes().res, + ), + ).rejects.toMatchObject({ statusCode: 403 }); + }); + + it('rejects when body is undefined', async () => { + await expect( + verifyHandler( + makeReq({ body: undefined }), + makeRes().res, + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + }); +}); diff --git a/src/backend/drivers/workers/WorkerDriver.test.ts b/src/backend/drivers/workers/WorkerDriver.test.ts new file mode 100644 index 000000000..42af236eb --- /dev/null +++ b/src/backend/drivers/workers/WorkerDriver.test.ts @@ -0,0 +1,244 @@ +// 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, +} from 'vitest'; +import { Actor } from '../../core/actor.js'; +import { runWithContext } from '../../core/context.js'; +import { PuterServer } from '../../server.js'; +import { setupTestServer } from '../../testUtil.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 => ({ + 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 = (fn: () => T | Promise, withActor: Actor = actor) => + runWithContext({ actor: withActor }, fn); + + 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', + workerName: 'my-worker_01', + filePath: '/test.js', + }), + ), + ).rejects.toMatchObject({ statusCode: 503 }); + }); + + it('lowercases the worker name', async () => { + await expect( + inCtx(() => + target.create({ + appId: 'test', + 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', + workerName: 'validname', + filePath: '/test.js', + }), + ), + ).rejects.toMatchObject({ statusCode: 503 }); + }); + + it('rejects reserved worker names', async () => { + const serverWithReserved = await setupTestServer({ + reserved_words: ['admin', 'api'], + }); + 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(); + } + }); + }); + + describe('destroy', () => { + it('rejects when workerName is missing', async () => { + await expect( + inCtx(() => target.destroy({ workerName: '' })), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('returns 503 when Cloudflare Workers is not configured', async () => { + await expect( + inCtx(() => target.destroy({ workerName: 'validname' })), + ).rejects.toMatchObject({ statusCode: 503 }); + }); + }); + + 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([]); + }); + }); + + 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' }, + }); + 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([]); + }); + }); +}); diff --git a/src/backend/drivers/workers/WorkerDriver.ts b/src/backend/drivers/workers/WorkerDriver.ts index d584f5173..cef3d772c 100644 --- a/src/backend/drivers/workers/WorkerDriver.ts +++ b/src/backend/drivers/workers/WorkerDriver.ts @@ -38,6 +38,7 @@ const WORKER_SUBDOMAIN_PREFIX = 'workers.puter.'; // If the file hasn't been built, workers run without puter.js access. let preamble = ''; +let preambleError = false; let preambleLineCount = 0; try { const preamblePath = path.join( @@ -51,6 +52,7 @@ try { console.warn( '[workers] preamble not built — workers will not have puter.js injected.', ); + preambleError = true; } /** @@ -78,6 +80,11 @@ export class WorkerDriver extends PuterDriver { if (cfg.namespace) { this.#cfBaseUrl += `/dispatch/namespaces/${cfg.namespace}`; } + if (preambleError) { + throw new Error( + '[workers] preamble not build but workers configured to be enabled. Halting start', + ); + } } this.#subscribeHotReload(); }