From 32c2c0c1a55ee90a70511327d338f4c138b9fc70 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Thu, 7 May 2026 21:35:49 -0700 Subject: [PATCH] test: add tests for AppController (#3004) Covers /apps, /apps/nameAvailable, /rao, /apps/:name (single + batch), /query/app (max-entries cap, marketplace shape, hidden-app gating), and /app-icon (default fallback, data-URL decoding, MIME allowlist, CDN redirect). Routes are collected via PuterRouter; AppDriver is injected through the shared `driversContainers` registry, and stub stores return prefab app rows. Closes #2969 --- .../controllers/apps/AppController.test.ts | 709 ++++++++++++++++++ 1 file changed, 709 insertions(+) create mode 100644 src/backend/controllers/apps/AppController.test.ts diff --git a/src/backend/controllers/apps/AppController.test.ts b/src/backend/controllers/apps/AppController.test.ts new file mode 100644 index 000000000..1ad7c95c8 --- /dev/null +++ b/src/backend/controllers/apps/AppController.test.ts @@ -0,0 +1,709 @@ +/** + * 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 . + */ + +import type { Request, RequestHandler, Response } from 'express'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { v4 as uuidv4 } from 'uuid'; +import type { Actor } from '../../core/actor.js'; +import { runWithContext } from '../../core/context.js'; +import { PuterRouter } from '../../core/http/PuterRouter.js'; +import { PuterServer } from '../../server.js'; +import { setupTestServer } from '../../testUtil.js'; + +// ── Test harness ──────────────────────────────────────────────────── +// +// Boots one real PuterServer (in-memory sqlite + dynamo + s3 + mock +// redis) and re-registers AppController's inline lambda routes onto a +// fresh PuterRouter so each handler is reachable. Tests run against +// the live wired AppDriver, AppStore, DB client, and EventClient — +// no method spies. Apps are created via the real AppDriver so +// ownership / approval / metadata behave exactly like prod. + +let server: PuterServer; +let router: PuterRouter; + +interface CrudQDriver { + create: (args: Record) => Promise>; + read: (args: Record) => Promise>; + select: (args: Record) => Promise; + isNameAvailable: (name: string) => Promise; +} +let driver: CrudQDriver; + +beforeAll(async () => { + server = await setupTestServer(); + router = new PuterRouter(); + server.controllers.apps.registerRoutes(router); + driver = server.drivers.apps as unknown as CrudQDriver; +}); + +afterAll(async () => { + await server?.shutdown(); +}); + +const makeUser = async (): Promise<{ actor: Actor; userId: number }> => { + const username = `apc-${Math.random().toString(36).slice(2, 10)}`; + const created = await server.stores.user.create({ + username, + uuid: uuidv4(), + password: null, + email: `${username}@test.local`, + free_storage: 100 * 1024 * 1024, + requires_email_confirmation: false, + }); + const refreshed = (await server.stores.user.getById(created.id))!; + return { + userId: refreshed.id, + actor: { + user: { + id: refreshed.id, + uuid: refreshed.uuid, + username: refreshed.username, + email: refreshed.email ?? null, + email_confirmed: true, + } as Actor['user'], + }, + }; +}; + +const withActor = (actor: Actor, fn: () => Promise): Promise => + runWithContext({ actor }, fn); + +const uniqueName = (prefix: string) => + `${prefix}-${Math.random().toString(36).slice(2, 10)}`; + +const uniqueIndexUrl = () => + `https://example-${Math.random().toString(36).slice(2, 10)}.test/`; + +const createApp = async ( + actor: Actor, + overrides: Record = {}, +): Promise> => + withActor(actor, () => + driver.create({ + object: { + name: uniqueName('app'), + title: 'Test App', + description: 'desc', + index_url: uniqueIndexUrl(), + ...overrides, + }, + }), + ); + +interface CapturedResponse { + statusCode: number; + body: unknown; + headers: Record; + redirectStatus?: number; + redirectUrl?: string; +} + +const makeReq = (init: { + body?: unknown; + query?: Record; + params?: Record; + actor?: Actor | unknown; +}): Request => { + return { + body: init.body ?? {}, + query: init.query ?? {}, + params: init.params ?? {}, + headers: {}, + actor: init.actor, + } as unknown as Request; +}; + +const makeRes = () => { + const captured: CapturedResponse = { + statusCode: 200, + body: undefined, + headers: {}, + }; + const res = { + json: vi.fn((value: unknown) => { + captured.body = value; + return res; + }), + send: vi.fn((value: unknown) => { + captured.body = value; + return res; + }), + status: vi.fn((code: number) => { + captured.statusCode = code; + return res; + }), + set: vi.fn((key: string, value: string) => { + if (typeof key === 'string') { + captured.headers[key.toLowerCase()] = value; + } + return res; + }), + setHeader: vi.fn(() => res), + redirect: vi.fn((status: number | string, url?: string) => { + if (typeof status === 'number' && typeof url === 'string') { + captured.redirectStatus = status; + captured.redirectUrl = url; + } else if (typeof status === 'string') { + captured.redirectStatus = 302; + captured.redirectUrl = status; + } + return res; + }), + }; + return { res: res as unknown as Response, captured }; +}; + +const findHandler = (method: string, path: string): RequestHandler => { + const route = router.routes.find( + (r) => r.method === method && r.path === path, + ); + if (!route) throw new Error(`No ${method.toUpperCase()} ${path} route`); + return route.handler; +}; + +const callRoute = async ( + method: string, + path: string, + req: Request, + res: Response, +) => { + const handler = findHandler(method, path); + await handler(req, res, () => { + throw new Error('handler called next() unexpectedly'); + }); +}; + +// ── GET /apps ─────────────────────────────────────────────────────── + +describe('AppController GET /apps', () => { + it('returns the apps the caller can edit (filtered by user-can-edit)', async () => { + const owner = await makeUser(); + const stranger = await makeUser(); + const app = await createApp(owner.actor, { name: uniqueName('mine') }); + + // Owner sees their own app. + const { res: ownerRes, captured: ownerCaptured } = makeRes(); + await withActor(owner.actor, () => + callRoute( + 'get', + '/apps', + makeReq({ actor: owner.actor }), + ownerRes, + ), + ); + const ownerApps = ownerCaptured.body as Array<{ uid: string }>; + expect(ownerApps.some((a) => a.uid === app.uid)).toBe(true); + + // A different user does not. + const { res: strangerRes, captured: strangerCaptured } = makeRes(); + await withActor(stranger.actor, () => + callRoute( + 'get', + '/apps', + makeReq({ actor: stranger.actor }), + strangerRes, + ), + ); + const strangerApps = strangerCaptured.body as Array<{ uid: string }>; + expect(strangerApps.every((a) => a.uid !== app.uid)).toBe(true); + }); +}); + +// ── GET /apps/nameAvailable ───────────────────────────────────────── + +describe('AppController GET /apps/nameAvailable', () => { + it('returns available=true for an unused name', async () => { + const { actor } = await makeUser(); + const name = uniqueName('avail'); + const { res, captured } = makeRes(); + await withActor(actor, () => + callRoute( + 'get', + '/apps/nameAvailable', + makeReq({ query: { name }, actor }), + res, + ), + ); + expect(captured.body).toEqual({ name, available: true }); + }); + + it('returns available=false once an app with that name exists', async () => { + const { actor } = await makeUser(); + const name = uniqueName('taken'); + await createApp(actor, { name }); + + const { res, captured } = makeRes(); + await withActor(actor, () => + callRoute( + 'get', + '/apps/nameAvailable', + makeReq({ query: { name }, actor }), + res, + ), + ); + expect(captured.body).toEqual({ name, available: false }); + }); + + it('throws 400 when `name` query param is missing', async () => { + const { actor } = await makeUser(); + const { res } = makeRes(); + await expect( + withActor(actor, () => + callRoute( + 'get', + '/apps/nameAvailable', + makeReq({ query: {}, actor }), + res, + ), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); +}); + +// ── POST /rao ─────────────────────────────────────────────────────── + +describe('AppController POST /rao', () => { + it('throws 400 when neither body nor actor carries app_uid', async () => { + const { actor } = await makeUser(); + const { res } = makeRes(); + await expect( + withActor(actor, () => + callRoute( + 'post', + '/rao', + makeReq({ body: {}, actor }), + res, + ), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('throws 404 when the supplied app_uid does not exist', async () => { + const { actor } = await makeUser(); + const { res } = makeRes(); + await expect( + withActor(actor, () => + callRoute( + 'post', + '/rao', + makeReq({ + body: { app_uid: 'app-no-such-thing' }, + actor, + }), + res, + ), + ), + ).rejects.toMatchObject({ statusCode: 404 }); + }); + + it('records the app_open in the DB on success', async () => { + const owner = await makeUser(); + const app = await createApp(owner.actor); + + const { res, captured } = makeRes(); + await withActor(owner.actor, () => + callRoute( + 'post', + '/rao', + makeReq({ body: { app_uid: app.uid }, actor: owner.actor }), + res, + ), + ); + expect(captured.body).toEqual({}); + + const rows = (await server.clients.db.read( + 'SELECT `app_uid`, `user_id` FROM `app_opens` WHERE `app_uid` = ? AND `user_id` = ?', + [app.uid, owner.userId], + )) as Array<{ app_uid: string; user_id: number }>; + expect(rows).toHaveLength(1); + }); + + it('falls back to actor.app.uid when the body omits app_uid', async () => { + const owner = await makeUser(); + const app = await createApp(owner.actor); + + const { res, captured } = makeRes(); + const actorWithApp: unknown = { + ...owner.actor, + app: { uid: app.uid }, + }; + await withActor(owner.actor, () => + callRoute( + 'post', + '/rao', + makeReq({ body: {}, actor: actorWithApp }), + res, + ), + ); + expect(captured.body).toEqual({}); + + const rows = (await server.clients.db.read( + 'SELECT `app_uid` FROM `app_opens` WHERE `app_uid` = ? AND `user_id` = ?', + [app.uid, owner.userId], + )) as Array<{ app_uid: string }>; + expect(rows.length).toBeGreaterThan(0); + }); +}); + +// ── GET /apps/:name (single + pipe-batched) ───────────────────────── + +describe('AppController GET /apps/:name', () => { + it('throws 404 for a single missing app', async () => { + const { actor } = await makeUser(); + const { res } = makeRes(); + await expect( + withActor(actor, () => + callRoute( + 'get', + '/apps/:name', + makeReq({ + params: { name: 'no-such-app' }, + actor, + }), + res, + ), + ), + ).rejects.toMatchObject({ statusCode: 404 }); + }); + + it('returns a single app object for a single name', async () => { + const owner = await makeUser(); + const app = await createApp(owner.actor); + + const { res, captured } = makeRes(); + await withActor(owner.actor, () => + callRoute( + 'get', + '/apps/:name', + makeReq({ + params: { name: app.name }, + actor: owner.actor, + }), + res, + ), + ); + expect(Array.isArray(captured.body)).toBe(false); + const body = captured.body as Record; + expect(body.uid).toBe(app.uid); + expect(body.privateAccess).toBeDefined(); + }); + + it('returns an array (with nulls for unknowns) for pipe-separated batch', async () => { + const owner = await makeUser(); + const app = await createApp(owner.actor); + + const { res, captured } = makeRes(); + await withActor(owner.actor, () => + callRoute( + 'get', + '/apps/:name', + makeReq({ + params: { name: `${app.name}|missing-app` }, + actor: owner.actor, + }), + res, + ), + ); + expect(Array.isArray(captured.body)).toBe(true); + const arr = captured.body as unknown[]; + expect(arr).toHaveLength(2); + expect((arr[0] as Record).uid).toBe(app.uid); + expect(arr[1]).toBeNull(); + }); +}); + +// ── POST /query/app ───────────────────────────────────────────────── + +describe('AppController POST /query/app', () => { + it('returns [] for a non-array body', async () => { + const { actor } = await makeUser(); + const { res, captured } = makeRes(); + await withActor(actor, () => + callRoute( + 'post', + '/query/app', + makeReq({ body: { not: 'an-array' }, actor }), + res, + ), + ); + expect(captured.body).toEqual([]); + }); + + it('throws 400 when the array exceeds the 200-entry cap', async () => { + const { actor } = await makeUser(); + const { res } = makeRes(); + await expect( + withActor(actor, () => + callRoute( + 'post', + '/query/app', + makeReq({ + body: new Array(201).fill('x'), + actor, + }), + res, + ), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('returns approved-for-listing apps with the v1 marketplace shape', async () => { + const owner = await makeUser(); + // Name must not start with `app-` — the controller treats names + // with that prefix as UID lookups. + const app = await createApp(owner.actor, { + name: uniqueName('marketplace'), + description: 'cool desc', + }); + // approved_for_listing is admin-controlled and goes through the + // store's READ_ONLY filter — flip it via a direct DB write + // and invalidate the store's cache so the stale row doesn't + // get served. + await server.clients.db.write( + 'UPDATE `apps` SET `approved_for_listing` = 1 WHERE `uid` = ?', + [app.uid], + ); + await server.stores.app.invalidateByUid(app.uid as string); + + // Stranger queries by name and gets the v1 marketplace shape. + const stranger = await makeUser(); + const { res, captured } = makeRes(); + await withActor(stranger.actor, () => + callRoute( + 'post', + '/query/app', + makeReq({ body: [app.name], actor: stranger.actor }), + res, + ), + ); + + const body = captured.body as Array>; + expect(body).toHaveLength(1); + expect(body[0]).toMatchObject({ + uuid: app.uid, + name: app.name, + description: 'cool desc', + }); + // Internal fields must be omitted. + expect(body[0]?.id).toBeUndefined(); + expect(body[0]?.owner_user_id).toBeUndefined(); + expect(body[0]?.index_url).toBeUndefined(); + }); + + it('skips selectors that are empty/oversize/non-string', async () => { + const { actor } = await makeUser(); + const { res, captured } = makeRes(); + await withActor(actor, () => + callRoute( + 'post', + '/query/app', + makeReq({ + body: [ + '', // empty + 'a'.repeat(201), // oversize + 123, // wrong type + ], + actor, + }), + res, + ), + ); + expect(captured.body).toEqual([]); + }); + + it('hides unapproved, protected, non-owned apps from strangers', async () => { + const owner = await makeUser(); + const app = await createApp(owner.actor, { + name: uniqueName('hidden'), + }); + // The /query/app gate only fires when AppDriver.read denies + // access — and that only happens for `protected` apps the + // caller doesn't own / hasn't been granted access to. + // `protected` is admin-controlled (READ_ONLY in AppStore), so + // we flip it via a direct DB write. + await server.clients.db.write( + 'UPDATE `apps` SET `protected` = 1 WHERE `uid` = ?', + [app.uid], + ); + await server.stores.app.invalidateByUid(app.uid as string); + + const stranger = await makeUser(); + const { res, captured } = makeRes(); + await withActor(stranger.actor, () => + callRoute( + 'post', + '/query/app', + makeReq({ body: [app.name], actor: stranger.actor }), + res, + ), + ); + // Existence isn't surfaced — caller can't enumerate private apps. + expect(captured.body).toEqual([]); + }); + + it('shows owners their own unapproved apps', async () => { + const owner = await makeUser(); + // Avoid `app-` prefix so the controller looks up by name. + const app = await createApp(owner.actor, { + name: uniqueName('mine'), + }); + + const { res, captured } = makeRes(); + await withActor(owner.actor, () => + callRoute( + 'post', + '/query/app', + makeReq({ body: [app.name], actor: owner.actor }), + res, + ), + ); + const body = captured.body as Array>; + expect(body).toHaveLength(1); + expect(body[0]?.uuid).toBe(app.uid); + }); + + it('looks up by UID when the selector starts with `app-`', async () => { + const owner = await makeUser(); + const app = await createApp(owner.actor); + await server.clients.db.write( + 'UPDATE `apps` SET `approved_for_listing` = 1 WHERE `uid` = ?', + [app.uid], + ); + await server.stores.app.invalidateByUid(app.uid as string); + + const { res, captured } = makeRes(); + await withActor(owner.actor, () => + callRoute( + 'post', + '/query/app', + makeReq({ body: [app.uid], actor: owner.actor }), + res, + ), + ); + const body = captured.body as Array>; + expect(body).toHaveLength(1); + expect(body[0]?.name).toBe(app.name); + }); +}); + +// ── GET /app-icon/:app_uid(/:size) ────────────────────────────────── + +describe('AppController GET /app-icon/:app_uid', () => { + it('returns 400 for a missing app_uid param', async () => { + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/app-icon/:app_uid', + makeReq({ params: { app_uid: '' } }), + res, + ); + expect(captured.statusCode).toBe(400); + }); + + it('returns 400 for an unsupported size param', async () => { + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/app-icon/:app_uid/:size', + makeReq({ params: { app_uid: 'app-1', size: '999' } }), + res, + ); + expect(captured.statusCode).toBe(400); + }); + + it('serves the default icon when the app row has no icon', async () => { + const owner = await makeUser(); + const app = await createApp(owner.actor); + + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/app-icon/:app_uid', + makeReq({ params: { app_uid: app.uid } }), + res, + ); + // Default icon is SVG; CSP sandbox locks down script execution. + expect(captured.headers['content-type']).toContain('image/svg+xml'); + expect(captured.headers['content-security-policy']).toContain( + 'sandbox', + ); + expect(Buffer.isBuffer(captured.body)).toBe(true); + }); + + it('decodes a data URL icon and serves the declared MIME', async () => { + const owner = await makeUser(); + const png = Buffer.from('mock-png-bytes'); + const dataUrl = `data:image/png;base64,${png.toString('base64')}`; + const app = await createApp(owner.actor, { icon: dataUrl }); + + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/app-icon/:app_uid', + makeReq({ params: { app_uid: app.uid } }), + res, + ); + + expect(captured.headers['content-type']).toBe('image/png'); + expect(Buffer.isBuffer(captured.body)).toBe(true); + expect((captured.body as Buffer).equals(png)).toBe(true); + }); + + it('falls back to the default icon when the data-URL MIME is not allowlisted', async () => { + const owner = await makeUser(); + // text/html is NOT in the icon allowlist — must NOT be echoed back. + // AppDriver.create rejects non-image MIMEs at write time, so we + // bypass and store the dangerous icon directly in the DB. + const app = await createApp(owner.actor); + const dataUrl = `data:text/html;base64,${Buffer.from('').toString('base64')}`; + await server.clients.db.write( + 'UPDATE `apps` SET `icon` = ? WHERE `uid` = ?', + [dataUrl, app.uid], + ); + + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/app-icon/:app_uid', + makeReq({ params: { app_uid: app.uid } }), + res, + ); + // Falls back to default icon (SVG). + expect(captured.headers['content-type']).toContain('image/svg+xml'); + }); + + it('prepends the `app-` prefix when omitted from the param', async () => { + const owner = await makeUser(); + const app = await createApp(owner.actor); + + // Strip the prefix; controller should re-add it before lookup. + const stripped = String(app.uid).replace(/^app-/, ''); + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/app-icon/:app_uid', + makeReq({ params: { app_uid: stripped } }), + res, + ); + // Either the default icon (no `icon` column on the row) or a + // configured one — both come back as 200, not 404. + expect(captured.statusCode).toBe(200); + }); +});