mirror of
https://github.com/HeyPuter/puter.git
synced 2026-07-09 08:42:08 +00:00
4de39ab6f2
* Mark external apps and use the flag for dashboard titles
An app with no owner_user_id (null/empty) isn't owned by a Puter user —
it's an external, origin-bootstrapped app whose uuid/name/title are all the
opaque app-… id. The dashboard used to detect these client-side by comparing
uuid/name/title and a name.startsWith('app-') check.
Expose an authoritative `external` flag from the API instead:
- /installedApps and /get-launch-apps now return `external`, derived from
owner_user_id, and no longer leak the raw owner id.
- The dashboard (Home + Apps tabs) shows the index_url hostname for external
apps based on `external` rather than the uuid/name/title heuristic.
Add/extend extension tests to cover the `external` flag and non-leak.
* Use hostname for opaque external app titles
Detect opaque external app IDs (when name === title === uid/uuid) and replace the displayed title with the hostname from index_url. Adds a uid/uuid fallback and tightens the external-app check in both the Apps and Home dashboard tabs so they behave consistently; preserves target_link for Home entries.
* Add external flag for apps without owner
Expose an `external` property in AppSummary (toAppSummary in RecommendedAppsService.ts) to mark apps that are not owned by a Puter user. The flag is set when `owner_user_id` is null or an empty string, allowing clients to identify origin-bootstrapped/external apps.
189 lines
6.2 KiB
TypeScript
189 lines
6.2 KiB
TypeScript
import type { Request, Response } from 'express';
|
||
import { v4 as uuidv4 } from 'uuid';
|
||
import {
|
||
afterAll,
|
||
beforeAll,
|
||
describe,
|
||
expect,
|
||
it,
|
||
vi,
|
||
} from 'vitest';
|
||
import { runWithContext } from '../src/backend/core/context.ts';
|
||
import { PuterServer } from '../src/backend/server.ts';
|
||
import { setupTestServer } from '../src/backend/testUtil.ts';
|
||
import { handleInstalledApps } from './installedApps.ts';
|
||
|
||
interface CapturedResponse {
|
||
body: unknown;
|
||
}
|
||
|
||
const makeReq = (query: Record<string, unknown> = {}): Request =>
|
||
({ query }) as unknown as Request;
|
||
|
||
const makeRes = () => {
|
||
const captured: CapturedResponse = { body: undefined };
|
||
const res = {
|
||
json: vi.fn((value: unknown) => {
|
||
captured.body = value;
|
||
return res;
|
||
}),
|
||
};
|
||
return { res: res as unknown as Response, captured };
|
||
};
|
||
|
||
let server: PuterServer;
|
||
|
||
beforeAll(async () => {
|
||
server = await setupTestServer();
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await server.shutdown();
|
||
});
|
||
|
||
const seedUser = async () => {
|
||
const slug = Math.random().toString(36).slice(2, 8);
|
||
return server.stores.user.create({
|
||
username: `iauser_${slug}`,
|
||
uuid: uuidv4(),
|
||
password: 'x',
|
||
email: null,
|
||
});
|
||
};
|
||
|
||
const seedApp = async (ownerUserId: number) => {
|
||
const slug = Math.random().toString(36).slice(2, 8);
|
||
return server.stores.app.create(
|
||
{
|
||
name: `iaapp_${slug}`,
|
||
title: `Installed App ${slug}`,
|
||
index_url: `https://example.com/${slug}`,
|
||
},
|
||
{ ownerUserId },
|
||
);
|
||
};
|
||
|
||
const grantInstalled = async (appId: number, userId: number) => {
|
||
// Mimic the `flag:app-is-authenticated` perm row the handler joins on.
|
||
await server.clients.db.write(
|
||
`INSERT INTO user_to_app_permissions (user_id, app_id, permission, extra) VALUES (?, ?, ?, ?)`,
|
||
[userId, appId, 'flag:app-is-authenticated', null],
|
||
);
|
||
};
|
||
|
||
describe('installedApps extension — handleInstalledApps', () => {
|
||
it('throws HttpError(401) when no actor is on the context', async () => {
|
||
const { res } = makeRes();
|
||
await expect(
|
||
runWithContext({ actor: undefined }, () =>
|
||
handleInstalledApps(makeReq({}), res),
|
||
),
|
||
).rejects.toMatchObject({ statusCode: 401 });
|
||
});
|
||
|
||
it('throws HttpError(400) when orderBy is not in the allowlist', async () => {
|
||
const user = await seedUser();
|
||
const { res } = makeRes();
|
||
await expect(
|
||
runWithContext(
|
||
{
|
||
actor: {
|
||
user: { uuid: user.uuid, id: user.id as number },
|
||
},
|
||
},
|
||
() =>
|
||
handleInstalledApps(
|
||
// SQL injection attempt — must be rejected.
|
||
makeReq({ orderBy: 'apps.id; DROP TABLE apps;--' }),
|
||
res,
|
||
),
|
||
),
|
||
).rejects.toMatchObject({
|
||
statusCode: 400,
|
||
message: expect.stringContaining('Invalid orderBy'),
|
||
});
|
||
});
|
||
|
||
it('returns an empty list for a user with no installed apps', async () => {
|
||
const user = await seedUser();
|
||
const { res, captured } = makeRes();
|
||
|
||
await runWithContext(
|
||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||
() => handleInstalledApps(makeReq({}), res),
|
||
);
|
||
|
||
expect(captured.body).toEqual([]);
|
||
});
|
||
|
||
it('returns the caller’s installed apps with an iconUrl field', async () => {
|
||
const user = await seedUser();
|
||
const app = await seedApp(user.id as number);
|
||
await grantInstalled(app!.id as number, user.id as number);
|
||
|
||
const { res, captured } = makeRes();
|
||
await runWithContext(
|
||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||
() => handleInstalledApps(makeReq({}), res),
|
||
);
|
||
|
||
const list = captured.body as Array<Record<string, unknown>>;
|
||
expect(list).toHaveLength(1);
|
||
expect(list[0].uid).toBe(app!.uid);
|
||
expect(list[0].name).toBe(app!.name);
|
||
expect(list[0].title).toBe(app!.title);
|
||
// index_url is required so the dashboard can derive a hostname title
|
||
// for anonymous (app-…) apps.
|
||
expect(list[0].index_url).toBe(app!.index_url);
|
||
expect(Object.prototype.hasOwnProperty.call(list[0], 'iconUrl')).toBe(
|
||
true,
|
||
);
|
||
// An owned app is not external, and the raw owner id must not leak.
|
||
expect(list[0].external).toBe(false);
|
||
expect(
|
||
Object.prototype.hasOwnProperty.call(list[0], 'owner_user_id'),
|
||
).toBe(false);
|
||
});
|
||
|
||
it('flags apps with no owner_user_id as external', async () => {
|
||
const user = await seedUser();
|
||
const slug = Math.random().toString(36).slice(2, 8);
|
||
// createFromOrigin bootstraps an app with owner_user_id = null.
|
||
const app = await server.stores.app.createFromOrigin(
|
||
`app-${slug}`,
|
||
`https://external-${slug}.example.com`,
|
||
);
|
||
await grantInstalled(app!.id as number, user.id as number);
|
||
|
||
const { res, captured } = makeRes();
|
||
await runWithContext(
|
||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||
() => handleInstalledApps(makeReq({}), res),
|
||
);
|
||
|
||
const list = captured.body as Array<Record<string, unknown>>;
|
||
expect(list).toHaveLength(1);
|
||
expect(list[0].external).toBe(true);
|
||
expect(
|
||
Object.prototype.hasOwnProperty.call(list[0], 'owner_user_id'),
|
||
).toBe(false);
|
||
});
|
||
|
||
it('clamps page/limit to safe ranges (page>=1, 1<=limit<=100)', async () => {
|
||
const user = await seedUser();
|
||
const { res, captured } = makeRes();
|
||
|
||
// page=0 and limit=999 should be clamped without throwing.
|
||
await runWithContext(
|
||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||
() =>
|
||
handleInstalledApps(
|
||
makeReq({ page: 0, limit: 999, desc: '1' }),
|
||
res,
|
||
),
|
||
);
|
||
|
||
expect(Array.isArray(captured.body)).toBe(true);
|
||
});
|
||
});
|