mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-03 02:18:06 +00:00
1832 lines
66 KiB
TypeScript
1832 lines
66 KiB
TypeScript
/*
|
|
* 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 { 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 { PuterServer } from '../../server.js';
|
|
import { setupTestServer } from '../../testUtil.js';
|
|
|
|
// ── Test harness ────────────────────────────────────────────────────
|
|
//
|
|
// Boots one PuterServer (in-memory sqlite + dynamo + s3 + mock redis)
|
|
// and exercises the live AppDriver (`puter-apps`) against the real
|
|
// AppStore. Each test makes its own user via `makeUser` so app rows
|
|
// from one test don't pollute another's `select` results.
|
|
|
|
let server: PuterServer;
|
|
// AppDriver is a JS module without an exported class type; treat as a
|
|
// generic CRUD-Q surface so we don't fight TS over private internals.
|
|
type 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[]>;
|
|
update: (args: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
|
upsert: (args: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
|
delete: (args: Record<string, unknown>) => Promise<{ success: boolean; uid: string }>;
|
|
isNameAvailable: (name: string) => Promise<boolean>;
|
|
};
|
|
let driver: CrudQDriver;
|
|
|
|
beforeAll(async () => {
|
|
server = await setupTestServer();
|
|
driver = server.drivers.apps as unknown as CrudQDriver;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server?.shutdown();
|
|
});
|
|
|
|
const makeUser = async (): Promise<{ actor: Actor; userId: number }> => {
|
|
const username = `ad-${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 = async <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/`;
|
|
|
|
// ── create ──────────────────────────────────────────────────────────
|
|
|
|
describe('AppDriver.create', () => {
|
|
it('creates an app and stamps the actor as owner', async () => {
|
|
const { actor, userId } = await makeUser();
|
|
const name = uniqueName('app');
|
|
|
|
const result = await withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name,
|
|
title: 'My App',
|
|
description: 'desc',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(result.uid).toEqual(expect.any(String));
|
|
expect(result.name).toBe(name);
|
|
expect(result.title).toBe('My App');
|
|
// `owner` is only attached when the actor is the owner.
|
|
expect(result.owner).toMatchObject({ username: actor.user!.username });
|
|
|
|
// Confirm DB-level ownership.
|
|
const stored = await server.stores.app.getByUid(result.uid as string);
|
|
expect(stored?.owner_user_id).toBe(userId);
|
|
});
|
|
|
|
it('rejects an invalid app name with 400', async () => {
|
|
const { actor } = await makeUser();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: 'has spaces',
|
|
title: 'x',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
it('rejects a missing index_url with 400', async () => {
|
|
const { actor } = await makeUser();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
driver.create({
|
|
object: { name: uniqueName('no-url'), title: 't' },
|
|
}),
|
|
),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
// App iframes get `allow-same-origin allow-scripts`, so an index_url
|
|
// on a Puter system host would run third-party code same-origin with
|
|
// the GUI. The test server's domain is `puter.localhost` (from
|
|
// config.default.json).
|
|
it.each([
|
|
['the GUI host', 'https://puter.localhost/evil.html'],
|
|
['the GUI host on another port/scheme', 'http://puter.localhost:4100/evil.html'],
|
|
['the API host', 'https://api.puter.localhost/evil.html'],
|
|
['the builtin sentinel host', 'https://builtins.namespaces.puter.com/emulator'],
|
|
])('rejects an index_url on %s with 400', async (_label, index_url) => {
|
|
const { actor } = await makeUser();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('sys-host'),
|
|
title: 't',
|
|
index_url,
|
|
},
|
|
}),
|
|
),
|
|
).rejects.toMatchObject({
|
|
statusCode: 400,
|
|
message: /system host/,
|
|
});
|
|
});
|
|
|
|
it('rejects updating an index_url to a Puter system host with 400', async () => {
|
|
const { actor } = await makeUser();
|
|
const created = await withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('sys-host-upd'),
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
);
|
|
await expect(
|
|
withActor(actor, () =>
|
|
driver.update({
|
|
uid: created.uid,
|
|
object: { index_url: 'https://puter.localhost/evil.html' },
|
|
}),
|
|
),
|
|
).rejects.toMatchObject({
|
|
statusCode: 400,
|
|
message: /system host/,
|
|
});
|
|
});
|
|
|
|
it('rejects a duplicate app name with 400', async () => {
|
|
const a = await makeUser();
|
|
const b = await makeUser();
|
|
const name = uniqueName('dup');
|
|
|
|
await withActor(a.actor, () =>
|
|
driver.create({
|
|
object: { name, title: 'a', index_url: uniqueIndexUrl() },
|
|
}),
|
|
);
|
|
await expect(
|
|
withActor(b.actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name,
|
|
title: 'b',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
it('reports a lost name-uniqueness race the way the check reports it', async () => {
|
|
const { actor } = await makeUser();
|
|
|
|
// The name check and the insert are two statements, so a name can be
|
|
// claimed in between and only the unique index catches it. The
|
|
// in-memory sqlite schema has no unique index on `apps`.`name` (mysql
|
|
// and postgres do), so the losing insert is what gets stubbed here. A
|
|
// raw driver error would escape as a 500 carrying the index name.
|
|
const dup = Object.assign(new Error('Duplicate entry'), {
|
|
code: 'ER_DUP_ENTRY',
|
|
errno: 1062,
|
|
});
|
|
const create = vi
|
|
.spyOn(server.stores.app, 'create')
|
|
.mockRejectedValueOnce(dup);
|
|
|
|
try {
|
|
await expect(
|
|
withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('race'),
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
),
|
|
).rejects.toMatchObject({
|
|
statusCode: 400,
|
|
legacyCode: 'app_name_already_in_use',
|
|
});
|
|
} finally {
|
|
create.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('lets a non-uniqueness insert failure surface as a server error', async () => {
|
|
const { actor } = await makeUser();
|
|
|
|
const create = vi
|
|
.spyOn(server.stores.app, 'create')
|
|
.mockRejectedValueOnce(new Error('connection lost'));
|
|
|
|
try {
|
|
await expect(
|
|
withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('boom'),
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
),
|
|
).rejects.toThrow('connection lost');
|
|
} finally {
|
|
create.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('dedupes a colliding name when `dedupe_name` is true', async () => {
|
|
const { actor } = await makeUser();
|
|
const name = uniqueName('dedup');
|
|
|
|
await withActor(actor, () =>
|
|
driver.create({
|
|
object: { name, title: 't', index_url: uniqueIndexUrl() },
|
|
}),
|
|
);
|
|
const second = await withActor(actor, () =>
|
|
driver.create({
|
|
object: { name, title: 't', index_url: uniqueIndexUrl() },
|
|
options: { dedupe_name: true },
|
|
}),
|
|
);
|
|
|
|
expect(second.name).not.toBe(name);
|
|
expect(String(second.name).startsWith(name)).toBe(true);
|
|
});
|
|
|
|
it('rejects a non-image data: icon with 400', async () => {
|
|
const { actor } = await makeUser();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('bad-icon'),
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
icon: 'data:text/plain;base64,AAAA',
|
|
},
|
|
}),
|
|
),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
it('throws 401 with no actor in context', async () => {
|
|
await expect(
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('noctx'),
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
).rejects.toMatchObject({ statusCode: 401 });
|
|
});
|
|
});
|
|
|
|
// ── read ────────────────────────────────────────────────────────────
|
|
|
|
describe('AppDriver.read', () => {
|
|
it('reads a public app for any actor', async () => {
|
|
const a = await makeUser();
|
|
const b = await makeUser();
|
|
const created = await withActor(a.actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('public'),
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
);
|
|
|
|
const fetched = await withActor(b.actor, () =>
|
|
driver.read({ uid: created.uid }),
|
|
);
|
|
expect(fetched.uid).toBe(created.uid);
|
|
// Owner block is NOT exposed to non-owners.
|
|
expect(fetched.owner).toBeUndefined();
|
|
});
|
|
|
|
it('reads via id object with `{ name }`', async () => {
|
|
const { actor } = await makeUser();
|
|
const name = uniqueName('by-name');
|
|
await withActor(actor, () =>
|
|
driver.create({
|
|
object: { name, title: 't', index_url: uniqueIndexUrl() },
|
|
}),
|
|
);
|
|
const fetched = await withActor(actor, () =>
|
|
driver.read({ id: { name } }),
|
|
);
|
|
expect(fetched.name).toBe(name);
|
|
});
|
|
|
|
it('returns 404 for a missing app', async () => {
|
|
const { actor } = await makeUser();
|
|
await expect(
|
|
withActor(actor, () => driver.read({ uid: 'app-nonexistent' })),
|
|
).rejects.toMatchObject({ statusCode: 404 });
|
|
});
|
|
});
|
|
|
|
// ── select ──────────────────────────────────────────────────────────
|
|
|
|
describe('AppDriver.select', () => {
|
|
it('returns visible apps including those owned by other users', async () => {
|
|
const a = await makeUser();
|
|
const b = await makeUser();
|
|
const aName = uniqueName('a');
|
|
const bName = uniqueName('b');
|
|
await withActor(a.actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: aName,
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
);
|
|
await withActor(b.actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: bName,
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = (await withActor(a.actor, () =>
|
|
driver.select({}),
|
|
)) as Array<Record<string, unknown>>;
|
|
const names = result.map((r) => r.name);
|
|
expect(names).toContain(aName);
|
|
expect(names).toContain(bName);
|
|
});
|
|
|
|
it('predicate `user-can-edit` filters to actor-owned apps only', async () => {
|
|
const a = await makeUser();
|
|
const b = await makeUser();
|
|
const mine = uniqueName('mine');
|
|
await withActor(a.actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: mine,
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
);
|
|
await withActor(b.actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('theirs'),
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = (await withActor(a.actor, () =>
|
|
driver.select({ predicate: ['user-can-edit'] }),
|
|
)) as Array<Record<string, unknown>>;
|
|
|
|
// `select` only returns one row in this slice — the actor-owned
|
|
// one. Caller filters server-side via `owner_user_id`.
|
|
const names = result.map((r) => r.name);
|
|
expect(names).toContain(mine);
|
|
for (const row of result) {
|
|
expect(row.owner).toMatchObject({
|
|
username: a.actor.user!.username,
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── update / delete ─────────────────────────────────────────────────
|
|
|
|
describe('AppDriver.update', () => {
|
|
it('updates editable fields on an owned app', async () => {
|
|
const { actor } = await makeUser();
|
|
const created = await withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('upd'),
|
|
title: 'Old',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
);
|
|
const updated = await withActor(actor, () =>
|
|
driver.update({
|
|
uid: created.uid,
|
|
object: { title: 'New', description: 'now with desc' },
|
|
}),
|
|
);
|
|
expect(updated.title).toBe('New');
|
|
expect(updated.description).toBe('now with desc');
|
|
});
|
|
|
|
it("rejects updating another user's app with 403", async () => {
|
|
const a = await makeUser();
|
|
const b = await makeUser();
|
|
const created = await withActor(a.actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('cross'),
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(
|
|
withActor(b.actor, () =>
|
|
driver.update({
|
|
uid: created.uid,
|
|
object: { title: 'hacked' },
|
|
}),
|
|
),
|
|
).rejects.toMatchObject({ statusCode: 403 });
|
|
});
|
|
});
|
|
|
|
describe('AppDriver.delete', () => {
|
|
it('deletes an owned app and reports `{ success, uid }`', async () => {
|
|
const { actor } = await makeUser();
|
|
const created = await withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('del'),
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
);
|
|
const result = await withActor(actor, () =>
|
|
driver.delete({ uid: created.uid }),
|
|
);
|
|
expect(result).toEqual({ success: true, uid: created.uid });
|
|
expect(
|
|
await server.stores.app.getByUid(created.uid as string),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('deletes the subdomain rows the app owns and leaves the rest', async () => {
|
|
const { actor, userId } = await makeUser();
|
|
const created = await withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('own'),
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
);
|
|
const app = (await server.stores.app.getByUid(created.uid as string))!;
|
|
const prefix = `appdel-${Math.random().toString(36).slice(2, 8)}`;
|
|
await server.stores.subdomain.create({
|
|
userId,
|
|
subdomain: `${prefix}-owned`,
|
|
appOwner: app.id,
|
|
});
|
|
await server.stores.subdomain.create({
|
|
userId,
|
|
subdomain: `${prefix}-unowned`,
|
|
});
|
|
|
|
await withActor(actor, () => driver.delete({ uid: created.uid }));
|
|
|
|
const remaining = await server.stores.subdomain.listByUserIdAndPrefix(
|
|
userId,
|
|
prefix,
|
|
);
|
|
expect(remaining.map((r) => r.subdomain)).toEqual([
|
|
`${prefix}-unowned`,
|
|
]);
|
|
});
|
|
|
|
it("refuses to delete another user's app with 403", async () => {
|
|
const a = await makeUser();
|
|
const b = await makeUser();
|
|
const created = await withActor(a.actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('cross-del'),
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
);
|
|
await expect(
|
|
withActor(b.actor, () => driver.delete({ uid: created.uid })),
|
|
).rejects.toMatchObject({ statusCode: 403 });
|
|
});
|
|
|
|
it('returns 404 for a non-existent uid', async () => {
|
|
const { actor } = await makeUser();
|
|
await expect(
|
|
withActor(actor, () => driver.delete({ uid: 'app-nonexistent' })),
|
|
).rejects.toMatchObject({ statusCode: 404 });
|
|
});
|
|
});
|
|
|
|
// ── upsert ──────────────────────────────────────────────────────────
|
|
|
|
describe('AppDriver.upsert', () => {
|
|
it('creates when no row matches', async () => {
|
|
const { actor } = await makeUser();
|
|
const result = await withActor(actor, () =>
|
|
driver.upsert({
|
|
object: {
|
|
name: uniqueName('ups'),
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
);
|
|
expect(result.uid).toEqual(expect.any(String));
|
|
});
|
|
|
|
it('updates when a row already exists at the resolved uid', async () => {
|
|
const { actor } = await makeUser();
|
|
const created = await withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('ups-existing'),
|
|
title: 'first',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await withActor(actor, () =>
|
|
driver.upsert({
|
|
uid: created.uid,
|
|
object: { title: 'second' },
|
|
}),
|
|
);
|
|
expect(result.title).toBe('second');
|
|
});
|
|
});
|
|
|
|
// ── isNameAvailable ────────────────────────────────────────────────
|
|
|
|
describe('AppDriver.isNameAvailable', () => {
|
|
it('returns true for an unused name', async () => {
|
|
const result = await driver.isNameAvailable(uniqueName('avail'));
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('returns false once an app has claimed the name', async () => {
|
|
const { actor } = await makeUser();
|
|
const name = uniqueName('claimed');
|
|
await withActor(actor, () =>
|
|
driver.create({
|
|
object: { name, title: 't', index_url: uniqueIndexUrl() },
|
|
}),
|
|
);
|
|
const result = await driver.isNameAvailable(name);
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('rejects an invalid name format with 400', async () => {
|
|
await expect(driver.isNameAvailable('has spaces')).rejects.toMatchObject(
|
|
{ statusCode: 400 },
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── create: additional validation branches ─────────────────────────
|
|
|
|
describe('AppDriver.create additional branches', () => {
|
|
it('rejects with 400 when `object` is missing or not an object', async () => {
|
|
const { actor } = await makeUser();
|
|
await expect(
|
|
withActor(actor, () => driver.create({})),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
await expect(
|
|
withActor(actor, () =>
|
|
driver.create({ object: 'not an object' as unknown as object }),
|
|
),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
it('rejects a too-long name with 400', async () => {
|
|
const { actor } = await makeUser();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: 'a'.repeat(101),
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
it('rejects when title is missing on create', async () => {
|
|
const { actor } = await makeUser();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('no-title'),
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
it('accepts a valid data:image/png base64 icon', async () => {
|
|
const { actor } = await makeUser();
|
|
// 1x1 transparent PNG
|
|
const png = `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=`;
|
|
const created = await withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('icon'),
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
icon: png,
|
|
},
|
|
}),
|
|
);
|
|
expect(created.icon).toBe(png);
|
|
});
|
|
|
|
// The write path once validated only the MIME prefix, so an
|
|
// allow-listed prefix plus arbitrary text was stored verbatim and later
|
|
// interpolated into a Dev Center template — stored XSS in a godmode app
|
|
// that carries the user's session token. Reachable with an app-under-user
|
|
// token, the lowest-privilege credential we issue.
|
|
describe('icon data URL payload validation', () => {
|
|
const ATTACK_PAYLOAD =
|
|
'data:image/png;base64,iVBORw0KGgo=" a5x="1"><img src=x onerror=window.__A5APP=1>';
|
|
|
|
const createWithIcon = async (icon: string, label: string) => {
|
|
const { actor } = await makeUser();
|
|
return withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName(label),
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
icon,
|
|
},
|
|
}),
|
|
);
|
|
};
|
|
|
|
it('rejects the reported breakout payload', async () => {
|
|
await expect(
|
|
createWithIcon(ATTACK_PAYLOAD, 'xss-icon'),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
it('rejects the breakout payload on update, not just create', async () => {
|
|
const { actor } = await makeUser();
|
|
const created = await withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('xss-upd'),
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
);
|
|
await expect(
|
|
withActor(actor, () =>
|
|
driver.update({
|
|
uid: created.uid,
|
|
object: { icon: ATTACK_PAYLOAD },
|
|
}),
|
|
),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
it('rejects an allow-listed MIME whose payload is not an image', async () => {
|
|
// Valid base64, decodes cleanly — just isn't a PNG.
|
|
const notAnImage = `data:image/png;base64,${Buffer.from(
|
|
'not an image at all',
|
|
).toString('base64')}`;
|
|
await expect(
|
|
createWithIcon(notAnImage, 'notimg-icon'),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
it('rejects a payload whose bytes contradict the declared MIME', async () => {
|
|
const pngBytes =
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
|
|
await expect(
|
|
createWithIcon(
|
|
`data:image/gif;base64,${pngBytes}`,
|
|
'mismatch-icon',
|
|
),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
it('rejects a percent-encoded (non-base64) data URL', async () => {
|
|
// The only shape that can carry literal `<` and `"`.
|
|
await expect(
|
|
createWithIcon(
|
|
'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%3E%3C/svg%3E',
|
|
'pct-icon',
|
|
),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
it('rejects base64 with smuggled non-base64 characters', async () => {
|
|
// `Buffer.from(…,'base64')` silently drops these; the
|
|
// round-trip check is what catches them.
|
|
await expect(
|
|
createWithIcon(
|
|
'data:image/png;base64,iVBORw0KGgo=<script>alert(1)</script>',
|
|
'smuggle-icon',
|
|
),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
it('accepts a base64 SVG icon and stores it canonically', async () => {
|
|
const svg = Buffer.from(
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"></svg>',
|
|
).toString('base64');
|
|
const created = await createWithIcon(
|
|
`data:image/svg+xml;base64,${svg}`,
|
|
'svg-icon',
|
|
);
|
|
expect(created.icon).toBe(`data:image/svg+xml;base64,${svg}`);
|
|
});
|
|
|
|
it('accepts image/jpg as an alias of image/jpeg', async () => {
|
|
// Minimal JPEG SOI + APP0 header — enough to sniff.
|
|
const jpeg = Buffer.concat([
|
|
Buffer.from([0xff, 0xd8, 0xff, 0xe0]),
|
|
Buffer.from('0000JFIF'),
|
|
]).toString('base64');
|
|
const created = await createWithIcon(
|
|
`data:image/jpg;base64,${jpeg}`,
|
|
'jpg-icon',
|
|
);
|
|
expect(String(created.icon).startsWith('data:image/jpg;base64,')).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it('strips line wrapping from an otherwise valid payload', async () => {
|
|
const png =
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
|
|
const wrapped = `${png.slice(0, 40)}\n${png.slice(40)}`;
|
|
const created = await createWithIcon(
|
|
`data:image/png;base64,${wrapped}`,
|
|
'wrapped-icon',
|
|
);
|
|
expect(created.icon).toBe(`data:image/png;base64,${png}`);
|
|
});
|
|
|
|
it('rejects raw base64 that does not decode to an image', async () => {
|
|
// v1 wrapped any base64 as image/png regardless of content.
|
|
await expect(
|
|
createWithIcon(
|
|
Buffer.from('definitely not an image payload').toString(
|
|
'base64',
|
|
),
|
|
'rawtext-icon',
|
|
),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
});
|
|
|
|
it('normalizes a raw-base64 icon into a data: URL', async () => {
|
|
const { actor } = await makeUser();
|
|
// Raw base64 of a 1x1 PNG (no data: prefix)
|
|
const rawB64 =
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
|
|
const created = await withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('rawicon'),
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
icon: rawB64,
|
|
},
|
|
}),
|
|
);
|
|
expect(typeof created.icon).toBe('string');
|
|
expect(String(created.icon).startsWith('data:image/')).toBe(true);
|
|
});
|
|
|
|
it('rejects an icon URL that is neither base64, data:, nor an app-icon endpoint', async () => {
|
|
const { actor } = await makeUser();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('bad-icon-url'),
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
icon: 'https://evil.example/icon.png',
|
|
},
|
|
}),
|
|
),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
it('persists metadata, maximize_on_start, and background flags', async () => {
|
|
const { actor } = await makeUser();
|
|
const created = await withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('flags'),
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
maximize_on_start: true,
|
|
background: true,
|
|
metadata: { foo: 'bar' },
|
|
},
|
|
}),
|
|
);
|
|
expect(created.maximize_on_start).toBe(true);
|
|
expect(created.background).toBe(true);
|
|
// metadata round-trips as a JSON string in the wire shape.
|
|
const parsed =
|
|
typeof created.metadata === 'string'
|
|
? JSON.parse(created.metadata)
|
|
: created.metadata;
|
|
expect(parsed).toEqual({ foo: 'bar' });
|
|
});
|
|
|
|
it('persists filetype_associations as an array', async () => {
|
|
const { actor } = await makeUser();
|
|
const created = await withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('ft'),
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
filetype_associations: ['.txt', '.md'],
|
|
},
|
|
}),
|
|
);
|
|
expect(Array.isArray(created.filetype_associations)).toBe(true);
|
|
// Dotted input is canonicalized to the bare lowercase extension.
|
|
expect(created.filetype_associations).toEqual(
|
|
expect.arrayContaining(['txt', 'md']),
|
|
);
|
|
});
|
|
|
|
it('rejects too-long title with 400', async () => {
|
|
const { actor } = await makeUser();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('lt'),
|
|
title: 'x'.repeat(101),
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
it('rejects too-long description with 400', async () => {
|
|
const { actor } = await makeUser();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('ld'),
|
|
title: 't',
|
|
description: 'd'.repeat(7001),
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
});
|
|
|
|
// ── update: additional branches ────────────────────────────────────
|
|
|
|
describe('AppDriver.update additional branches', () => {
|
|
it('rejects with 400 when object is missing or invalid', async () => {
|
|
const { actor } = await makeUser();
|
|
const created = await withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('u1'),
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
);
|
|
await expect(
|
|
withActor(actor, () => driver.update({ uid: created.uid })),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
it('returns 404 when neither uid/id matches anything', async () => {
|
|
const { actor } = await makeUser();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
driver.update({
|
|
uid: 'app-nonexistent',
|
|
object: { title: 'x' },
|
|
}),
|
|
),
|
|
).rejects.toMatchObject({ statusCode: 404 });
|
|
});
|
|
|
|
it('renames an app and persists the new name', async () => {
|
|
const { actor } = await makeUser();
|
|
const created = await withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('old'),
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
);
|
|
const newName = uniqueName('renamed');
|
|
const updated = await withActor(actor, () =>
|
|
driver.update({
|
|
uid: created.uid,
|
|
object: { name: newName },
|
|
}),
|
|
);
|
|
expect(updated.name).toBe(newName);
|
|
});
|
|
|
|
it('rejects renaming to a name already taken with 409', async () => {
|
|
const a = await makeUser();
|
|
const b = await makeUser();
|
|
const claimed = uniqueName('claimed');
|
|
// a registers `claimed`.
|
|
await withActor(a.actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: claimed,
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
);
|
|
// b creates a separate app, then tries to rename to `claimed`.
|
|
const bApp = await withActor(b.actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('temp'),
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
);
|
|
await expect(
|
|
withActor(b.actor, () =>
|
|
driver.update({
|
|
uid: bApp.uid,
|
|
object: { name: claimed },
|
|
}),
|
|
),
|
|
).rejects.toMatchObject({ statusCode: 409 });
|
|
});
|
|
|
|
it('reports a lost rename-uniqueness race as 409, not a 500', async () => {
|
|
const { actor } = await makeUser();
|
|
const created = await withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('ren'),
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
);
|
|
|
|
const dup = Object.assign(new Error('Duplicate entry'), {
|
|
code: 'ER_DUP_ENTRY',
|
|
errno: 1062,
|
|
});
|
|
const update = vi
|
|
.spyOn(server.stores.app, 'update')
|
|
.mockRejectedValueOnce(dup);
|
|
|
|
try {
|
|
await expect(
|
|
withActor(actor, () =>
|
|
driver.update({
|
|
uid: created.uid,
|
|
object: { name: uniqueName('taken') },
|
|
}),
|
|
),
|
|
).rejects.toMatchObject({
|
|
statusCode: 409,
|
|
legacyCode: 'conflict',
|
|
});
|
|
} finally {
|
|
update.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('updates metadata and filetype_associations on an owned app', async () => {
|
|
const { actor } = await makeUser();
|
|
const created = await withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('upd-meta'),
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
filetype_associations: ['.txt'],
|
|
},
|
|
}),
|
|
);
|
|
const updated = await withActor(actor, () =>
|
|
driver.update({
|
|
uid: created.uid,
|
|
object: {
|
|
metadata: { version: 2 },
|
|
filetype_associations: ['.md', '.csv'],
|
|
},
|
|
}),
|
|
);
|
|
const meta =
|
|
typeof updated.metadata === 'string'
|
|
? JSON.parse(updated.metadata)
|
|
: updated.metadata;
|
|
expect(meta).toEqual({ version: 2 });
|
|
// Dotted input is canonicalized to the bare lowercase extension.
|
|
expect(updated.filetype_associations).toEqual(
|
|
expect.arrayContaining(['md', 'csv']),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── read: additional branches ──────────────────────────────────────
|
|
|
|
describe('AppDriver.read additional branches', () => {
|
|
it('reads via id object with `{ uid }`', async () => {
|
|
const { actor } = await makeUser();
|
|
const created = await withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('rid'),
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
);
|
|
const fetched = await withActor(actor, () =>
|
|
driver.read({ id: { uid: created.uid } }),
|
|
);
|
|
expect(fetched.uid).toBe(created.uid);
|
|
});
|
|
|
|
it('reads via numeric `id` (positional number)', async () => {
|
|
const { actor } = await makeUser();
|
|
const created = await withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('rid-num'),
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
);
|
|
const row = await server.stores.app.getByUid(created.uid as string);
|
|
const fetched = await withActor(actor, () =>
|
|
driver.read({ id: row!.id }),
|
|
);
|
|
expect(fetched.uid).toBe(created.uid);
|
|
});
|
|
|
|
it('throws 401 when there is no actor in context', async () => {
|
|
await expect(driver.read({ uid: 'app-anything' })).rejects.toMatchObject(
|
|
{ statusCode: 401 },
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── delete: protected-app branch ───────────────────────────────────
|
|
|
|
describe('AppDriver.delete additional branches', () => {
|
|
it('rejects deleting a protected app with 403', async () => {
|
|
const { actor } = await makeUser();
|
|
const created = await withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('prot'),
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
);
|
|
const row = await server.stores.app.getByUid(created.uid as string);
|
|
// `protected` is in READ_ONLY_COLUMNS so AppStore.update filters it
|
|
// out — write directly, then invalidate so the next getByUid hits
|
|
// the fresh row.
|
|
await server.clients.db.write(
|
|
'UPDATE `apps` SET `protected` = 1 WHERE `id` = ?',
|
|
[row!.id],
|
|
);
|
|
await server.stores.app.invalidateByUid(created.uid as string);
|
|
await expect(
|
|
withActor(actor, () => driver.delete({ uid: created.uid })),
|
|
).rejects.toMatchObject({ statusCode: 403 });
|
|
});
|
|
});
|
|
|
|
// ── select: predicate + visibility ─────────────────────────────────
|
|
|
|
describe('AppDriver.select additional branches', () => {
|
|
it('returns [] for an unauthenticated caller (throws 401)', async () => {
|
|
await expect(driver.select({})).rejects.toMatchObject({
|
|
statusCode: 401,
|
|
});
|
|
});
|
|
});
|
|
|
|
// -- select pagination --
|
|
|
|
describe('AppDriver.select pagination', () => {
|
|
const makeApps = async (count: number) => {
|
|
const { actor } = await makeUser();
|
|
const names: string[] = [];
|
|
for (let i = 0; i < count; i++) {
|
|
const name = uniqueName(`pg${i}`);
|
|
names.push(name);
|
|
await withActor(actor, () =>
|
|
driver.create({
|
|
object: { name, title: 't', index_url: uniqueIndexUrl() },
|
|
}),
|
|
);
|
|
}
|
|
return { actor, names };
|
|
};
|
|
|
|
it('keeps the bare array response for plain limit requests', async () => {
|
|
const { actor } = await makeApps(2);
|
|
const result = await withActor(actor, () =>
|
|
driver.select({ predicate: ['user-can-edit'], limit: 1 }),
|
|
);
|
|
expect(Array.isArray(result)).toBe(true);
|
|
expect((result as unknown[]).length).toBe(1);
|
|
});
|
|
|
|
it('pages through owned apps with cursors', async () => {
|
|
const { actor, names } = await makeApps(5);
|
|
const seen: string[] = [];
|
|
let cursor: string | null | undefined = null;
|
|
do {
|
|
const page = (await withActor(actor, () =>
|
|
driver.select({
|
|
predicate: ['user-can-edit'],
|
|
limit: 2,
|
|
cursor,
|
|
}),
|
|
)) as { items: Array<{ name: string }>; cursor?: string };
|
|
seen.push(...page.items.map((r) => r.name));
|
|
cursor = page.cursor;
|
|
} while (cursor);
|
|
expect(seen).toEqual(names);
|
|
});
|
|
|
|
it('supports offset paging', async () => {
|
|
const { actor, names } = await makeApps(3);
|
|
const page = (await withActor(actor, () =>
|
|
driver.select({
|
|
predicate: ['user-can-edit'],
|
|
limit: 10,
|
|
offset: 1,
|
|
}),
|
|
)) as { items: Array<{ name: string }> };
|
|
expect(page.items.map((r) => r.name)).toEqual(names.slice(1));
|
|
});
|
|
|
|
it('rejects cursor combined with offset', async () => {
|
|
const { actor } = await makeApps(2);
|
|
const first = (await withActor(actor, () =>
|
|
driver.select({
|
|
predicate: ['user-can-edit'],
|
|
limit: 1,
|
|
cursor: null,
|
|
}),
|
|
)) as { cursor?: string };
|
|
expect(first.cursor).toBeDefined();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
driver.select({ offset: 1, cursor: first.cursor }),
|
|
),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
it('reports an exact total for owner-scoped selects', async () => {
|
|
const { actor, names } = await makeApps(3);
|
|
const page = (await withActor(actor, () =>
|
|
driver.select({
|
|
predicate: ['user-can-edit'],
|
|
limit: 1,
|
|
includeTotal: true,
|
|
}),
|
|
)) as { items: unknown[]; total?: number };
|
|
expect(page.items.length).toBe(1);
|
|
expect(page.total).toBe(names.length);
|
|
});
|
|
|
|
it("hides other users' protected apps from paginated catalog listings", async () => {
|
|
const a = await makeApps(3);
|
|
const [visible1, hidden, visible2] = a.names;
|
|
const created = await withActor(a.actor, () =>
|
|
driver.read({ id: { name: hidden } }),
|
|
);
|
|
const row = await server.stores.app.getByUid(
|
|
(created as Record<string, unknown>).uid as string,
|
|
);
|
|
await server.clients.db.write(
|
|
'UPDATE `apps` SET `protected` = 1 WHERE `id` = ?',
|
|
[row!.id],
|
|
);
|
|
await server.stores.app.invalidateByUid(row!.uid as string);
|
|
|
|
const b = await makeUser();
|
|
const seen: string[] = [];
|
|
let cursor: string | null | undefined = null;
|
|
do {
|
|
const page = (await withActor(b.actor, () =>
|
|
driver.select({ limit: 50, cursor }),
|
|
)) as { items: Array<{ name: string }>; cursor?: string };
|
|
seen.push(...page.items.map((r) => r.name));
|
|
cursor = page.cursor;
|
|
} while (cursor);
|
|
|
|
expect(seen).toContain(visible1);
|
|
expect(seen).toContain(visible2);
|
|
expect(seen).not.toContain(hidden);
|
|
});
|
|
});
|
|
|
|
// ── upsert ──────────────────────────────────────────────────────────
|
|
|
|
describe('AppDriver.upsert additional branches', () => {
|
|
it('updates by resolved id when a row matches', async () => {
|
|
const { actor } = await makeUser();
|
|
const name = uniqueName('ups-by-id');
|
|
const created = await withActor(actor, () =>
|
|
driver.create({
|
|
object: { name, title: 't', index_url: uniqueIndexUrl() },
|
|
}),
|
|
);
|
|
const result = await withActor(actor, () =>
|
|
driver.upsert({
|
|
id: { uid: created.uid },
|
|
object: { title: 'replaced' },
|
|
}),
|
|
);
|
|
expect(result.title).toBe('replaced');
|
|
});
|
|
});
|
|
|
|
// ── isNameAvailable extra branch ───────────────────────────────────
|
|
|
|
describe('AppDriver.isNameAvailable additional branches', () => {
|
|
it('rejects a too-long name with 400', async () => {
|
|
await expect(driver.isNameAvailable('a'.repeat(101))).rejects.toMatchObject(
|
|
{ statusCode: 400 },
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── alias-group custom domains (`app_origin_aliases`) ──────────────
|
|
//
|
|
// Custom hosts claimed by an alias group get the same bootstrap-stub
|
|
// merge treatment as puter-hosted subdomains: creating or repointing
|
|
// an app at an aliased host absorbs the unowned origin-bootstrap row
|
|
// instead of rejecting with `app_index_url_already_in_use`.
|
|
|
|
describe('AppDriver alias-group index_url merge', () => {
|
|
const aliasHostA = `alias-a-${Math.random().toString(36).slice(2, 10)}.test`;
|
|
const aliasHostB = `alias-b-${Math.random().toString(36).slice(2, 10)}.test`;
|
|
const aliasHostC = `alias-c-${Math.random().toString(36).slice(2, 10)}.test`;
|
|
|
|
// `config` is protected on PuterDriver; reach in to toggle the alias
|
|
// groups for this block only. `#getOriginAliasGroups` reads config at
|
|
// call time, so runtime mutation takes effect immediately.
|
|
const driverConfig = () =>
|
|
(driver as unknown as { config: Record<string, unknown> }).config;
|
|
|
|
beforeAll(() => {
|
|
driverConfig().app_origin_aliases = [
|
|
[aliasHostA],
|
|
[aliasHostB],
|
|
[aliasHostC],
|
|
];
|
|
});
|
|
|
|
afterAll(() => {
|
|
delete driverConfig().app_origin_aliases;
|
|
});
|
|
|
|
const makeBootstrapStub = async (host: string) => {
|
|
const stubUid = `app-${uuidv4()}`;
|
|
// Mirrors AuthController's get-user-app-token bootstrap path:
|
|
// origin persisted as index_url, no owner, name === uid.
|
|
await server.stores.app.createFromOrigin(stubUid, `https://${host}`);
|
|
return stubUid;
|
|
};
|
|
|
|
it('create at an aliased host absorbs the unowned bootstrap stub', async () => {
|
|
const { actor, userId } = await makeUser();
|
|
const stubUid = await makeBootstrapStub(aliasHostA);
|
|
const name = uniqueName('alias-create');
|
|
|
|
const result = await withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name,
|
|
title: 'Aliased',
|
|
index_url: `https://${aliasHostA}/`,
|
|
},
|
|
}),
|
|
);
|
|
|
|
// The stub row survives as the canonical app, claimed + merged.
|
|
expect(result.uid).toBe(stubUid);
|
|
expect(result.name).toBe(name);
|
|
const stored = await server.stores.app.getByUid(stubUid);
|
|
expect(stored?.owner_user_id).toBe(userId);
|
|
});
|
|
|
|
it('rejects another user registering an app under a reserved aliased host', async () => {
|
|
const other = await makeUser();
|
|
await expect(
|
|
withActor(other.actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('squatter'),
|
|
title: 't',
|
|
index_url: `https://${aliasHostA}/index.html`,
|
|
},
|
|
}),
|
|
),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
it('update repointing to an aliased host merges and aliases the old uid', async () => {
|
|
const { actor, userId } = await makeUser();
|
|
const stubUid = await makeBootstrapStub(aliasHostB);
|
|
|
|
const created = await withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('alias-upd'),
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
);
|
|
|
|
const updated = await withActor(actor, () =>
|
|
driver.update({
|
|
uid: created.uid,
|
|
object: { index_url: `https://${aliasHostB}/` },
|
|
}),
|
|
);
|
|
|
|
// Merged into the stub; source row deleted; old uid still resolves
|
|
// via the canonical-uid alias.
|
|
expect(updated.uid).toBe(stubUid);
|
|
expect(await server.stores.app.getByUid(created.uid as string)).toBeNull();
|
|
const stored = await server.stores.app.getByUid(stubUid);
|
|
expect(stored?.owner_user_id).toBe(userId);
|
|
|
|
const viaOldUid = await withActor(actor, () =>
|
|
driver.read({ uid: created.uid }),
|
|
);
|
|
expect(viaOldUid.uid).toBe(stubUid);
|
|
});
|
|
|
|
it("update merge hands the source app's owned subdomain rows to the joined app", async () => {
|
|
const { actor, userId } = await makeUser();
|
|
const stubUid = await makeBootstrapStub(aliasHostC);
|
|
const created = await withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('alias-own'),
|
|
title: 't',
|
|
index_url: uniqueIndexUrl(),
|
|
},
|
|
}),
|
|
);
|
|
const sourceApp = (await server.stores.app.getByUid(
|
|
created.uid as string,
|
|
))!;
|
|
const subdomain = `merge-${Math.random().toString(36).slice(2, 8)}`;
|
|
await server.stores.subdomain.create({
|
|
userId,
|
|
subdomain,
|
|
appOwner: sourceApp.id,
|
|
});
|
|
|
|
const updated = await withActor(actor, () =>
|
|
driver.update({
|
|
uid: created.uid,
|
|
object: { index_url: `https://${aliasHostC}/` },
|
|
}),
|
|
);
|
|
expect(updated.uid).toBe(stubUid);
|
|
|
|
// The source row is gone; its subdomain row survives under the joined
|
|
// app rather than cascading away with the source.
|
|
const stub = (await server.stores.app.getByUid(stubUid))!;
|
|
const [row] = await server.stores.subdomain.listByUserIdAndPrefix(
|
|
userId,
|
|
subdomain,
|
|
);
|
|
expect(row).toBeTruthy();
|
|
expect(Number(row!.app_owner)).toBe(stub.id);
|
|
});
|
|
|
|
it('leaves unrelated custom domains untouched (no alias group, no conflict check)', async () => {
|
|
const a = await makeUser();
|
|
const b = await makeUser();
|
|
const sharedUrl = uniqueIndexUrl();
|
|
|
|
// Non-puter, non-aliased hosts keep their historical behavior:
|
|
// no uniqueness enforcement, both creates succeed.
|
|
const first = await withActor(a.actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('plain-a'),
|
|
title: 't',
|
|
index_url: sharedUrl,
|
|
},
|
|
}),
|
|
);
|
|
const second = await withActor(b.actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('plain-b'),
|
|
title: 't',
|
|
index_url: sharedUrl,
|
|
},
|
|
}),
|
|
);
|
|
expect(first.uid).not.toBe(second.uid);
|
|
});
|
|
});
|
|
|
|
// ── hosted-subdomain ownership check ────────────────────────────────
|
|
//
|
|
// `#ensurePuterSiteSubdomainIsOwned` gates puter-hosted index_urls on a
|
|
// subdomain row the caller owns. Deploy flows create that row and point
|
|
// the app at it in back-to-back requests, so the check must tolerate a
|
|
// replica/cache miss by confirming against the primary before refusing.
|
|
|
|
describe('AppDriver hosted-subdomain ownership check', () => {
|
|
const hostedUrl = (sub: string) => `https://${sub}.site.puter.localhost/`;
|
|
|
|
it('accepts a hosted index_url when the subdomain row has not reached the replica yet', async () => {
|
|
const { actor, userId } = await makeUser();
|
|
const sub = uniqueName('deploy');
|
|
await server.stores.subdomain.create({ userId, subdomain: sub });
|
|
|
|
// Simulate a peer node with a lagging replica: no cache entry for
|
|
// the row, and replica reads (`read`) that don't see it yet while
|
|
// primary reads (`pread`) do. Sqlite's `pread` delegates to
|
|
// `this.read`, so pin it to the original before stubbing `read`.
|
|
await server.clients.redis.del(`subdomains:name:${sub}`);
|
|
const db = server.clients.db as unknown as {
|
|
read: (q: string, p?: unknown[]) => Promise<unknown[]>;
|
|
pread: (q: string, p?: unknown[]) => Promise<unknown[]>;
|
|
};
|
|
const originalRead = db.read.bind(server.clients.db);
|
|
const hadOwnPread = Object.prototype.hasOwnProperty.call(db, 'pread');
|
|
db.pread = async (query: string, params?: unknown[]) =>
|
|
originalRead(query, params);
|
|
db.read = async (query: string, params?: unknown[]) =>
|
|
query.includes('FROM `subdomains`') && (params ?? []).includes(sub)
|
|
? []
|
|
: originalRead(query, params);
|
|
|
|
try {
|
|
const result = await withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('app'),
|
|
title: 'Deployed App',
|
|
index_url: hostedUrl(sub),
|
|
},
|
|
}),
|
|
);
|
|
expect(result.uid).toEqual(expect.any(String));
|
|
|
|
// The primary hit must also heal the stale cache: a normal
|
|
// lookup now resolves from cache even though the replica
|
|
// still misses.
|
|
const healed =
|
|
await server.stores.subdomain.getBySubdomain(sub);
|
|
expect(healed?.subdomain).toBe(sub);
|
|
} finally {
|
|
delete (db as { read?: unknown }).read;
|
|
if (!hadOwnPread) delete (db as { pread?: unknown }).pread;
|
|
}
|
|
});
|
|
|
|
it('create merges into an owner-stamped bootstrap stub for an owned subdomain', async () => {
|
|
// The get-user-app-token bootstrap path stamps the subdomain owner
|
|
// on the stub at mint time — the owner's later create must still
|
|
// absorb the stub (claimOwnership is skipped, merge proceeds).
|
|
const { actor, userId } = await makeUser();
|
|
const sub = uniqueName('ownedstub');
|
|
await server.stores.subdomain.create({ userId, subdomain: sub });
|
|
const stubUid = `app-${uuidv4()}`;
|
|
await server.stores.app.createFromOrigin(
|
|
stubUid,
|
|
`https://${sub}.site.puter.localhost`,
|
|
{ ownerUserId: userId },
|
|
);
|
|
|
|
const name = uniqueName('owned-create');
|
|
const result = await withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name,
|
|
title: 'Owned stub',
|
|
index_url: hostedUrl(sub),
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(result.uid).toBe(stubUid);
|
|
expect(result.name).toBe(name);
|
|
const stored = await server.stores.app.getByUid(stubUid);
|
|
expect(stored?.owner_user_id).toBe(userId);
|
|
});
|
|
|
|
it('create absorbs a bootstrap stub minted on an alternate hosting domain', async () => {
|
|
const { actor, userId } = await makeUser();
|
|
const sub = uniqueName('altstub');
|
|
await server.stores.subdomain.create({ userId, subdomain: sub });
|
|
const stubUid = `app-${uuidv4()}`;
|
|
await server.stores.app.createFromOrigin(
|
|
stubUid,
|
|
`https://${sub}.host.puter.localhost`,
|
|
{ ownerUserId: userId },
|
|
);
|
|
|
|
const name = uniqueName('alt-create');
|
|
const result = await withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name,
|
|
title: 'Alt-host stub',
|
|
index_url: hostedUrl(sub),
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(result.uid).toBe(stubUid);
|
|
expect(result.name).toBe(name);
|
|
const stored = await server.stores.app.getByUid(stubUid);
|
|
expect(stored?.index_url).toBe(hostedUrl(sub));
|
|
});
|
|
|
|
it('rejects a hosted index_url whose subdomain does not exist anywhere', async () => {
|
|
const { actor } = await makeUser();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('app'),
|
|
title: 'x',
|
|
index_url: hostedUrl(uniqueName('ghost')),
|
|
},
|
|
}),
|
|
),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
it("rejects a hosted index_url pointing at another user's subdomain", async () => {
|
|
const owner = await makeUser();
|
|
const intruder = await makeUser();
|
|
const sub = uniqueName('theirs');
|
|
await server.stores.subdomain.create({
|
|
userId: owner.userId,
|
|
subdomain: sub,
|
|
});
|
|
|
|
await expect(
|
|
withActor(intruder.actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('app'),
|
|
title: 'x',
|
|
index_url: hostedUrl(sub),
|
|
},
|
|
}),
|
|
),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
// -- Launch guard: the backing subdomain can disappear AFTER the app
|
|
// was created (deleted by its owner, then reclaimable by anyone). The
|
|
// read path must refuse to launch so the GUI never appends the launch
|
|
// token to a now-reclaimable origin.
|
|
|
|
it('denies launch when the hosted subdomain is later deleted', async () => {
|
|
const { actor, userId } = await makeUser();
|
|
const sub = uniqueName('gone');
|
|
const row = await server.stores.subdomain.create({
|
|
userId,
|
|
subdomain: sub,
|
|
});
|
|
|
|
const created = await withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('app'),
|
|
title: 'Backed App',
|
|
index_url: hostedUrl(sub),
|
|
},
|
|
}),
|
|
);
|
|
|
|
// While the subdomain is still owned, the app launches normally.
|
|
const before = await withActor(actor, () =>
|
|
driver.read({ uid: created.uid }),
|
|
);
|
|
expect(String(before.index_url)).toContain(sub);
|
|
expect(
|
|
(before.privateAccess as { hasAccess?: boolean } | undefined)
|
|
?.hasAccess,
|
|
).not.toBe(false);
|
|
|
|
// Delete the subdomain but keep the app pointing at it.
|
|
await server.stores.subdomain.deleteByUuid(
|
|
String((row as { uuid: string }).uuid),
|
|
{ userId },
|
|
);
|
|
|
|
const after = await withActor(actor, () =>
|
|
driver.read({ uid: created.uid }),
|
|
);
|
|
const access = after.privateAccess as {
|
|
hasAccess?: boolean;
|
|
reason?: string;
|
|
};
|
|
expect(access?.hasAccess).toBe(false);
|
|
expect(access?.reason).toBe('hosted_backing_unavailable');
|
|
});
|
|
|
|
it('withholds the stale index_url from everyone but the owner', async () => {
|
|
const owner = await makeUser();
|
|
const other = await makeUser();
|
|
const sub = uniqueName('stale');
|
|
const row = await server.stores.subdomain.create({
|
|
userId: owner.userId,
|
|
subdomain: sub,
|
|
});
|
|
|
|
const created = await withActor(owner.actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('app'),
|
|
title: 'Backed App',
|
|
index_url: hostedUrl(sub),
|
|
},
|
|
}),
|
|
);
|
|
await server.stores.subdomain.deleteByUuid(
|
|
String((row as { uuid: string }).uuid),
|
|
{ userId: owner.userId },
|
|
);
|
|
|
|
// The owner still sees it — dev center renders the URL in the app's
|
|
// edit form, and it's their row to repoint.
|
|
const asOwner = await withActor(owner.actor, () =>
|
|
driver.read({ uid: created.uid }),
|
|
);
|
|
expect(String(asOwner.index_url)).toContain(sub);
|
|
|
|
// Anyone else gets the denial without the URL it suppresses, so a
|
|
// consumer that reads `index_url` without reading the verdict still
|
|
// can't hand it to the launcher.
|
|
const asOther = await withActor(other.actor, () =>
|
|
driver.read({ uid: created.uid }),
|
|
);
|
|
expect(asOther.index_url).toBeUndefined();
|
|
expect(
|
|
(asOther.privateAccess as { hasAccess?: boolean }).hasAccess,
|
|
).toBe(false);
|
|
});
|
|
|
|
it('denies launch when the hosted subdomain was reclaimed by another user', async () => {
|
|
const owner = await makeUser();
|
|
const attacker = await makeUser();
|
|
const sub = uniqueName('reclaim');
|
|
const row = await server.stores.subdomain.create({
|
|
userId: owner.userId,
|
|
subdomain: sub,
|
|
});
|
|
|
|
const created = await withActor(owner.actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('app'),
|
|
title: 'Backed App',
|
|
index_url: hostedUrl(sub),
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Owner deletes the subdomain; the attacker re-registers the name.
|
|
await server.stores.subdomain.deleteByUuid(
|
|
String((row as { uuid: string }).uuid),
|
|
{ userId: owner.userId },
|
|
);
|
|
await server.stores.subdomain.create({
|
|
userId: attacker.userId,
|
|
subdomain: sub,
|
|
});
|
|
|
|
const after = await withActor(owner.actor, () =>
|
|
driver.read({ uid: created.uid }),
|
|
);
|
|
expect(
|
|
(after.privateAccess as { hasAccess?: boolean }).hasAccess,
|
|
).toBe(false);
|
|
});
|
|
|
|
it('keeps launching while the hosted subdomain is still owned', async () => {
|
|
const { actor, userId } = await makeUser();
|
|
const sub = uniqueName('live');
|
|
await server.stores.subdomain.create({ userId, subdomain: sub });
|
|
|
|
const created = await withActor(actor, () =>
|
|
driver.create({
|
|
object: {
|
|
name: uniqueName('app'),
|
|
title: 'Backed App',
|
|
index_url: hostedUrl(sub),
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await withActor(actor, () =>
|
|
driver.read({ uid: created.uid }),
|
|
);
|
|
expect(String(result.index_url)).toContain(sub);
|
|
expect(
|
|
(result.privateAccess as { hasAccess?: boolean } | undefined)
|
|
?.hasAccess,
|
|
).not.toBe(false);
|
|
});
|
|
});
|