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
This commit is contained in:
Daniel Salazar
2026-05-07 21:35:49 -07:00
committed by GitHub
parent 508d652194
commit 32c2c0c1a5
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<string, unknown>) => Promise<Record<string, unknown>>;
read: (args: Record<string, unknown>) => Promise<Record<string, unknown>>;
select: (args: Record<string, unknown>) => Promise<unknown[]>;
isNameAvailable: (name: string) => Promise<boolean>;
}
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 = <T>(actor: Actor, fn: () => Promise<T>): Promise<T> =>
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<string, unknown> = {},
): Promise<Record<string, unknown>> =>
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<string, string>;
redirectStatus?: number;
redirectUrl?: string;
}
const makeReq = (init: {
body?: unknown;
query?: Record<string, unknown>;
params?: Record<string, unknown>;
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<string, unknown>;
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<string, unknown>).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<Record<string, unknown>>;
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<Record<string, unknown>>;
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<Record<string, unknown>>;
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('<script>alert(1)</script>').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);
});
});