diff --git a/src/backend/controllers/broadcast/BroadcastController.test.ts b/src/backend/controllers/broadcast/BroadcastController.test.ts new file mode 100644 index 000000000..c9db6e878 --- /dev/null +++ b/src/backend/controllers/broadcast/BroadcastController.test.ts @@ -0,0 +1,199 @@ +/** + * 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, Response } from 'express'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { PuterServer } from '../../server.js'; +import { setupTestServer } from '../../testUtil.js'; +import type { BroadcastController } from './BroadcastController.js'; + +// ── Test harness ──────────────────────────────────────────────────── +// +// Boots one PuterServer and exercises the live BroadcastController +// against the wired BroadcastService. The default test config has no +// configured peers, so every signed-incoming path falls through to the +// service's "unknown peer" gate (403). That's the right blast radius +// for these tests — we cover header validation + error→HTTP wiring, +// and trust BroadcastService's own tests for the crypto path. + +let server: PuterServer; +let controller: BroadcastController; + +beforeAll(async () => { + server = await setupTestServer(); + controller = server.controllers.broadcast as unknown as BroadcastController; +}); + +afterAll(async () => { + await server?.shutdown(); +}); + +interface CapturedResponse { + statusCode: number; + body: unknown; +} + +const makeReq = (init: { + body?: unknown; + rawBody?: Buffer; + headers?: Record; +}): Request => { + return { + body: init.body ?? {}, + rawBody: init.rawBody, + query: {}, + headers: init.headers ?? {}, + } as unknown as Request; +}; + +const makeRes = () => { + const captured: CapturedResponse = { statusCode: 200, body: undefined }; + const res = { + json: vi.fn((value: unknown) => { + captured.body = value; + return res; + }), + status: vi.fn((code: number) => { + captured.statusCode = code; + return res; + }), + setHeader: vi.fn(() => res), + }; + return { res: res as unknown as Response, captured }; +}; + +// Standard signed-payload headers used across cases. Real verification +// fails because no peer is configured — the test config doesn't set up +// `broadcast_peers` — so we land on the service's "Unknown peer" gate +// rather than an HMAC mismatch. +const signedHeaders = (): Record => ({ + 'x-broadcast-peer-id': 'unknown-peer', + 'x-broadcast-timestamp': String(Math.floor(Date.now() / 1000)), + 'x-broadcast-nonce': '1', + 'x-broadcast-signature': 'a'.repeat(64), +}); + +// ── /broadcast/webhook ────────────────────────────────────────────── + +describe('BroadcastController.webhook', () => { + it('returns 400 when rawBody is missing', async () => { + const { res, captured } = makeRes(); + await controller.webhook( + makeReq({ + body: { events: [] }, + headers: signedHeaders(), + }), + res, + ); + expect(captured.statusCode).toBe(400); + expect(captured.body).toMatchObject({ + error: { message: expect.stringContaining('body') }, + }); + }); + + it('returns 400 when the JSON body is not an object', async () => { + const { res, captured } = makeRes(); + await controller.webhook( + makeReq({ + body: 'not an object', + rawBody: Buffer.from('"not an object"'), + headers: signedHeaders(), + }), + res, + ); + expect(captured.statusCode).toBe(400); + }); + + it('returns 400 when the body has neither `events` nor a single-event shape', async () => { + const raw = Buffer.from('{}'); + const { res, captured } = makeRes(); + await controller.webhook( + makeReq({ + body: {}, + rawBody: raw, + headers: signedHeaders(), + }), + res, + ); + expect(captured.statusCode).toBe(400); + expect(captured.body).toMatchObject({ + error: { message: expect.stringContaining('payload') }, + }); + }); + + it('returns 403 when the peer-id header is missing', async () => { + const raw = Buffer.from('{"events":[]}'); + const headers = signedHeaders(); + delete headers['x-broadcast-peer-id']; + const { res, captured } = makeRes(); + await controller.webhook( + makeReq({ + body: { events: [] }, + rawBody: raw, + headers, + }), + res, + ); + expect(captured.statusCode).toBe(403); + expect(captured.body).toMatchObject({ + error: { message: expect.stringContaining('Peer-Id') }, + }); + }); + + it('returns 403 for an unknown peer-id (no configured webhook secret)', async () => { + const raw = Buffer.from('{"events":[{"key":"x","data":{},"meta":{}}]}'); + const { res, captured } = makeRes(); + await controller.webhook( + makeReq({ + body: { events: [{ key: 'x', data: {}, meta: {} }] }, + rawBody: raw, + headers: signedHeaders(), + }), + res, + ); + expect(captured.statusCode).toBe(403); + expect(captured.body).toMatchObject({ + error: { message: expect.stringContaining('Unknown peer') }, + }); + }); + + it('reads only the first value when a header is repeated as an array', async () => { + // Express normally collapses duplicates to a string, but tests + // can supply arrays — the controller's `headerOnce` helper picks + // index 0. Verify by sending an unknown peer-id as `[id, junk]` + // and confirming we still hit the 403 path (not a 400 from the + // body parsing path that runs before peer lookup). + const raw = Buffer.from('{"events":[]}'); + const headers = signedHeaders() as unknown as Record< + string, + string | string[] + >; + headers['x-broadcast-peer-id'] = ['unknown-peer', 'second-value']; + const { res, captured } = makeRes(); + await controller.webhook( + makeReq({ + body: { events: [] }, + rawBody: raw, + headers: headers as Record, + }), + res, + ); + expect(captured.statusCode).toBe(403); + }); +}); diff --git a/src/backend/controllers/drivers/DriverController.test.ts b/src/backend/controllers/drivers/DriverController.test.ts new file mode 100644 index 000000000..d228829f5 --- /dev/null +++ b/src/backend/controllers/drivers/DriverController.test.ts @@ -0,0 +1,112 @@ +/** + * 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 { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { PuterServer } from '../../server.js'; +import { setupTestServer } from '../../testUtil.js'; +import type { DriverController } from './DriverController.js'; + +// ── Test harness ──────────────────────────────────────────────────── +// +// Boots one PuterServer (in-memory sqlite + dynamo + s3 + mock redis). +// The DriverController under test is the same instance the live request +// pipeline uses, so its iface→driver registry is populated from real +// drivers (puter-kvstore, puter-apps, puter-subdomains, …). The HTTP +// handlers (`#handleCall`, `#handleListInterfaces`, `#handleXd`) are +// private — we exercise the public lookup API (`resolve` / list / get +// default) which the handlers themselves delegate to. + +let server: PuterServer; +let controller: DriverController; + +beforeAll(async () => { + server = await setupTestServer(); + controller = server.controllers.drivers as unknown as DriverController; +}); + +afterAll(async () => { + await server?.shutdown(); +}); + +// ── Lookup API ────────────────────────────────────────────────────── + +describe('DriverController.listInterfaces', () => { + it('exposes built-in interfaces', () => { + const interfaces = controller.listInterfaces(); + // Several drivers ship by default — assert known ones rather + // than the exact set so adding a driver doesn't break this. + expect(interfaces).toEqual( + expect.arrayContaining([ + 'puter-kvstore', + 'puter-apps', + 'puter-subdomains', + 'puter-notifications', + ]), + ); + }); +}); + +describe('DriverController.listDrivers', () => { + it('returns every name registered for an interface', () => { + const drivers = controller.listDrivers('puter-kvstore'); + expect(drivers).toContain('puter-kvstore'); + }); + + it('returns [] for an unknown interface', () => { + expect(controller.listDrivers('nonexistent')).toEqual([]); + }); +}); + +describe('DriverController.getDefault', () => { + it('returns the registered default driver name', () => { + // KVStoreDriver declares `isDefault = true`. + expect(controller.getDefault('puter-kvstore')).toBe('puter-kvstore'); + }); + + it('returns undefined for an unknown interface', () => { + expect(controller.getDefault('nonexistent')).toBeUndefined(); + }); +}); + +describe('DriverController.resolve', () => { + it('returns the default-driver instance when no name is given', () => { + const driver = controller.resolve('puter-kvstore'); + expect(driver).not.toBeNull(); + // The KV driver exposes a `set` method per its interface. + expect(typeof (driver as Record)?.set).toBe( + 'function', + ); + }); + + it('finds the same instance by explicit driver name', () => { + const byDefault = controller.resolve('puter-kvstore'); + const byName = controller.resolve('puter-kvstore', 'puter-kvstore'); + expect(byName).toBe(byDefault); + }); + + it('returns null for an unknown interface', () => { + expect(controller.resolve('nope')).toBeNull(); + }); + + it('returns null for a known interface but unknown driver name', () => { + expect( + controller.resolve('puter-kvstore', 'no-such-driver'), + ).toBeNull(); + }); +}); diff --git a/src/backend/controllers/notification/NotificationController.test.ts b/src/backend/controllers/notification/NotificationController.test.ts new file mode 100644 index 000000000..6367c3a52 --- /dev/null +++ b/src/backend/controllers/notification/NotificationController.test.ts @@ -0,0 +1,220 @@ +/** + * 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, 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 { PuterServer } from '../../server.js'; +import { setupTestServer } from '../../testUtil.js'; +import type { NotificationController } from './NotificationController.js'; + +// ── Test harness ──────────────────────────────────────────────────── +// +// Boots one PuterServer with the live wired NotificationController. +// Tests seed real notification rows via the store, then drive the +// controller's `markAck` / `markRead` handlers with stub req/res +// objects. The controller's path through NotificationService updates +// the underlying row, so we verify behaviour by reading the store +// state back. + +let server: PuterServer; +let controller: NotificationController; + +beforeAll(async () => { + server = await setupTestServer(); + controller = + server.controllers.notification as unknown as NotificationController; +}); + +afterAll(async () => { + await server?.shutdown(); +}); + +interface CapturedResponse { + statusCode: number; + body: unknown; +} + +const makeReq = (init: { + body?: unknown; + actor?: Actor; +}): Request => { + return { + body: init.body ?? {}, + query: {}, + headers: {}, + actor: init.actor, + } as unknown as Request; +}; + +const makeRes = () => { + const captured: CapturedResponse = { statusCode: 200, body: undefined }; + const res = { + json: vi.fn((value: unknown) => { + captured.body = value; + return res; + }), + status: vi.fn((code: number) => { + captured.statusCode = code; + return res; + }), + setHeader: vi.fn(() => res), + }; + return { res: res as unknown as Response, captured }; +}; + +const makeUser = async (): Promise<{ actor: Actor; userId: number }> => { + const username = `nc-${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'], + }, + }; +}; + +// ── /notif/mark-ack ───────────────────────────────────────────────── + +describe('NotificationController.markAck', () => { + it('sets `acknowledged` on the underlying notification row', async () => { + const { actor, userId } = await makeUser(); + const created = await server.stores.notification.create({ + userId, + value: { title: 't' }, + }); + + const { res, captured } = makeRes(); + await controller.markAck( + makeReq({ body: { uid: created.uid }, actor }), + res, + ); + + // Empty `{}` is the conventional success body for these routes. + expect(captured.body).toEqual({}); + const after = await server.stores.notification.getByUid( + created.uid as string, + { userId }, + ); + expect(after?.acknowledged).not.toBeNull(); + }); + + it('rejects a missing uid with 400', async () => { + const { actor } = await makeUser(); + const { res } = makeRes(); + await expect( + controller.markAck(makeReq({ body: {}, actor }), res), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('rejects a non-string uid with 400', async () => { + const { actor } = await makeUser(); + const { res } = makeRes(); + await expect( + controller.markAck(makeReq({ body: { uid: 123 }, actor }), res), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('throws 401 when there is no actor on the request', async () => { + const { res } = makeRes(); + await expect( + controller.markAck(makeReq({ body: { uid: 'whatever' } }), res), + ).rejects.toMatchObject({ statusCode: 401 }); + }); + + it("does not flip another user's notification", async () => { + const a = await makeUser(); + const b = await makeUser(); + const created = await server.stores.notification.create({ + userId: a.userId, + value: {}, + }); + + const { res } = makeRes(); + await controller.markAck( + makeReq({ body: { uid: created.uid }, actor: b.actor }), + res, + ); + + const after = await server.stores.notification.getByUid( + created.uid as string, + { userId: a.userId }, + ); + // Store update is scoped by user_id — cross-user mutation is a + // silent no-op rather than an error from the controller. + expect(after?.acknowledged).toBeFalsy(); + }); +}); + +// ── /notif/mark-read ──────────────────────────────────────────────── + +describe('NotificationController.markRead', () => { + it('sets `shown` on the underlying notification row', async () => { + const { actor, userId } = await makeUser(); + const created = await server.stores.notification.create({ + userId, + value: {}, + }); + + const { res, captured } = makeRes(); + await controller.markRead( + makeReq({ body: { uid: created.uid }, actor }), + res, + ); + + expect(captured.body).toEqual({}); + const after = await server.stores.notification.getByUid( + created.uid as string, + { userId }, + ); + expect(after?.shown).not.toBeNull(); + // Marking read should NOT also set acknowledged. + expect(after?.acknowledged).toBeFalsy(); + }); + + it('rejects an empty uid string with 400', async () => { + const { actor } = await makeUser(); + const { res } = makeRes(); + await expect( + controller.markRead(makeReq({ body: { uid: '' }, actor }), res), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('throws 401 when there is no actor on the request', async () => { + const { res } = makeRes(); + await expect( + controller.markRead(makeReq({ body: { uid: 'x' } }), res), + ).rejects.toMatchObject({ statusCode: 401 }); + }); +}); diff --git a/src/backend/drivers/apps/AppDriver.test.ts b/src/backend/drivers/apps/AppDriver.test.ts new file mode 100644 index 000000000..4f87179a0 --- /dev/null +++ b/src/backend/drivers/apps/AppDriver.test.ts @@ -0,0 +1,496 @@ +/** + * 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 { afterAll, beforeAll, describe, expect, it } 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) => Promise>; + read: (args: Record) => Promise>; + select: (args: Record) => Promise; + update: (args: Record) => Promise>; + upsert: (args: Record) => Promise>; + delete: (args: Record) => Promise<{ success: boolean; uid: string }>; + isNameAvailable: (name: string) => Promise; +}; +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 (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/`; + +// ── 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 }); + }); + + 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('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>; + 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>; + + // `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("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 }, + ); + }); +}); diff --git a/src/backend/drivers/notification/NotificationDriver.test.ts b/src/backend/drivers/notification/NotificationDriver.test.ts new file mode 100644 index 000000000..0b65f4a3a --- /dev/null +++ b/src/backend/drivers/notification/NotificationDriver.test.ts @@ -0,0 +1,335 @@ +/** + * 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 { afterAll, beforeAll, describe, expect, it } 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'; +import type { NotificationDriver } from './NotificationDriver.js'; + +// ── Test harness ──────────────────────────────────────────────────── +// +// Boots one PuterServer (in-memory sqlite + dynamo + s3 + mock redis) +// and exercises the live NotificationDriver against the wired stores. +// Each test allocates its own user via `makeUser` so notification rows +// from one test don't leak into another's `select` results. + +let server: PuterServer; +let driver: NotificationDriver; + +beforeAll(async () => { + server = await setupTestServer(); + driver = server.drivers.notifications as unknown as NotificationDriver; +}); + +afterAll(async () => { + await server?.shutdown(); +}); + +const makeUser = async (): Promise<{ actor: Actor; userId: number }> => { + const username = `nd-${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 (actor: Actor, fn: () => Promise): Promise => + runWithContext({ actor }, fn); + +// ── create ────────────────────────────────────────────────────────── + +describe('NotificationDriver.create', () => { + it('creates a notification row scoped to the actor', async () => { + const { actor, userId } = await makeUser(); + const result = (await withActor(actor, () => + driver.create({ + object: { value: { title: 'hi' } }, + }), + )) as Record | null; + + expect(result?.uid).toEqual(expect.any(String)); + expect(result?.value).toEqual({ title: 'hi' }); + // shown / acknowledged are unset on creation. + expect(result?.shown).toBeNull(); + expect(result?.acknowledged).toBeNull(); + + const row = await server.stores.notification.getByUid( + result!.uid as string, + { userId }, + ); + expect(row).not.toBeNull(); + }); + + it('defaults `value` to {} when omitted', async () => { + const { actor } = await makeUser(); + const result = (await withActor(actor, () => + driver.create({ object: {} }), + )) as Record | null; + expect(result?.value).toEqual({}); + }); + + it('rejects a missing object body with 400', async () => { + const { actor } = await makeUser(); + await expect( + withActor(actor, () => + driver.create({} as Record), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('rejects an app-actor with 403', async () => { + const { actor } = await makeUser(); + const appActor: Actor = { + ...actor, + app: { uid: 'some-app', id: 1 }, + }; + await expect( + withActor(appActor, () => + driver.create({ object: { value: { title: 'app' } } }), + ), + ).rejects.toMatchObject({ statusCode: 403 }); + }); + + it('throws 401 with no actor in context', async () => { + await expect( + driver.create({ object: { value: { title: 'noctx' } } }), + ).rejects.toMatchObject({ statusCode: 401 }); + }); +}); + +// ── read ──────────────────────────────────────────────────────────── + +describe('NotificationDriver.read', () => { + it('reads a notification by uid for its owner', async () => { + const { actor } = await makeUser(); + const created = (await withActor(actor, () => + driver.create({ object: { value: { title: 'a' } } }), + )) as Record; + + const fetched = (await withActor(actor, () => + driver.read({ uid: created.uid }), + )) as Record | null; + + expect(fetched?.uid).toBe(created.uid); + expect(fetched?.value).toEqual({ title: 'a' }); + }); + + it('accepts `id` as an alias for `uid`', async () => { + const { actor } = await makeUser(); + const created = (await withActor(actor, () => + driver.create({ object: { value: {} } }), + )) as Record; + const fetched = (await withActor(actor, () => + driver.read({ id: created.uid }), + )) as Record | null; + expect(fetched?.uid).toBe(created.uid); + }); + + it("returns 404 for another user's notification uid", async () => { + const a = await makeUser(); + const b = await makeUser(); + const created = (await withActor(a.actor, () => + driver.create({ object: { value: { hidden: true } } }), + )) as Record; + + await expect( + withActor(b.actor, () => driver.read({ uid: created.uid })), + ).rejects.toMatchObject({ statusCode: 404 }); + }); + + it('rejects a missing uid with 400', async () => { + const { actor } = await makeUser(); + await expect( + withActor(actor, () => driver.read({})), + ).rejects.toMatchObject({ statusCode: 400 }); + }); +}); + +// ── select / predicates ───────────────────────────────────────────── + +describe('NotificationDriver.select', () => { + it('returns the actor-owned notifications', async () => { + const { actor } = await makeUser(); + await withActor(actor, () => + driver.create({ object: { value: { i: 1 } } }), + ); + await withActor(actor, () => + driver.create({ object: { value: { i: 2 } } }), + ); + + const result = (await withActor(actor, () => + driver.select({}), + )) as Array>; + + // SQLite's `created_at` is second-precision so two rapid inserts + // can tie on the ORDER BY column — assert membership, not order. + expect(result.length).toBe(2); + const values = result.map( + (r) => (r.value as { i: number }).i, + ); + expect(values.sort()).toEqual([1, 2]); + }); + + it('does not leak other users\' notifications', async () => { + const a = await makeUser(); + const b = await makeUser(); + await withActor(a.actor, () => + driver.create({ object: { value: { who: 'a' } } }), + ); + const result = (await withActor(b.actor, () => + driver.select({}), + )) as Array>; + expect(result).toEqual([]); + }); + + it('predicate `unseen` filters out shown notifications', async () => { + const { actor, userId } = await makeUser(); + const seen = (await withActor(actor, () => + driver.create({ object: { value: { i: 'seen' } } }), + )) as Record; + const unseen = (await withActor(actor, () => + driver.create({ object: { value: { i: 'unseen' } } }), + )) as Record; + + await server.stores.notification.markShown( + seen.uid as string, + userId, + ); + + const result = (await withActor(actor, () => + driver.select({ predicate: 'unseen' }), + )) as Array>; + + const uids = result.map((r) => r.uid); + expect(uids).toContain(unseen.uid); + expect(uids).not.toContain(seen.uid); + }); + + it('predicate `acknowledged` returns only acked rows', async () => { + const { actor, userId } = await makeUser(); + const ack = (await withActor(actor, () => + driver.create({ object: { value: { i: 'ack' } } }), + )) as Record; + await withActor(actor, () => + driver.create({ object: { value: { i: 'pending' } } }), + ); + await server.stores.notification.markAcknowledged( + ack.uid as string, + userId, + ); + + const result = (await withActor(actor, () => + driver.select({ predicate: 'acknowledged' }), + )) as Array>; + + expect(result.map((r) => r.uid)).toEqual([ack.uid]); + }); + + it('caps `limit` at the driver max even when overridden by the caller', async () => { + const { actor } = await makeUser(); + // Verify shape, not exact upper bound — keep test fast. + const result = (await withActor(actor, () => + driver.select({ limit: 100_000 }), + )) as unknown[]; + expect(Array.isArray(result)).toBe(true); + }); +}); + +// ── mark_shown / mark_acknowledged ───────────────────────────────── + +describe('NotificationDriver.mark_shown / mark_acknowledged', () => { + it('mark_shown sets `shown` and reports success', async () => { + const { actor, userId } = await makeUser(); + const created = (await withActor(actor, () => + driver.create({ object: { value: {} } }), + )) as Record; + + const result = (await withActor(actor, () => + driver.mark_shown({ uid: created.uid }), + )) as { success: boolean }; + expect(result.success).toBe(true); + + const row = await server.stores.notification.getByUid( + created.uid as string, + { userId }, + ); + expect(row?.shown).not.toBeNull(); + }); + + it('mark_acknowledged sets `acknowledged` and reports success', async () => { + const { actor, userId } = await makeUser(); + const created = (await withActor(actor, () => + driver.create({ object: { value: {} } }), + )) as Record; + + const result = (await withActor(actor, () => + driver.mark_acknowledged({ uid: created.uid }), + )) as { success: boolean }; + expect(result.success).toBe(true); + + const row = await server.stores.notification.getByUid( + created.uid as string, + { userId }, + ); + expect(row?.acknowledged).not.toBeNull(); + }); + + it('mark_shown rejects missing uid with 400', async () => { + const { actor } = await makeUser(); + await expect( + withActor(actor, () => driver.mark_shown({})), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it("mark_shown returns success=false for another user's uid", async () => { + const a = await makeUser(); + const b = await makeUser(); + const created = (await withActor(a.actor, () => + driver.create({ object: { value: {} } }), + )) as Record; + + const result = (await withActor(b.actor, () => + driver.mark_shown({ uid: created.uid }), + )) as { success: boolean }; + // Store update is scoped by user_id, so cross-user mutation is a + // silent no-op. The driver reports the store's `affected = 0` + // verbatim as `success: false`. + expect(result.success).toBe(false); + }); +}); diff --git a/src/backend/drivers/subdomain/SubdomainDriver.test.ts b/src/backend/drivers/subdomain/SubdomainDriver.test.ts new file mode 100644 index 000000000..e9c2c8912 --- /dev/null +++ b/src/backend/drivers/subdomain/SubdomainDriver.test.ts @@ -0,0 +1,493 @@ +/** + * 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 { afterAll, beforeAll, describe, expect, it } 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'; +import { generateDefaultFsentries } from '../../util/userProvisioning.js'; +import type { SubdomainDriver } from './SubdomainDriver.js'; + +// ── Test harness ──────────────────────────────────────────────────── +// +// Boots one PuterServer (in-memory sqlite + dynamo + s3 + mock redis) +// and exercises the live SubdomainDriver against the real wired stores. +// Each test makes its own user via `makeUser` so subdomain rows / quota +// counts don't leak across cases. + +let server: PuterServer; +let driver: SubdomainDriver; + +beforeAll(async () => { + server = await setupTestServer(); + driver = server.drivers.subdomains as unknown as SubdomainDriver; +}); + +afterAll(async () => { + await server?.shutdown(); +}); + +const makeUser = async (): Promise<{ actor: Actor; userId: number }> => { + const username = `sd-${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, + }); + // Driver checks ACL on `root_dir`, which requires the home tree to + // exist — without provisioning, every create call would 400. + await generateDefaultFsentries( + server.clients.db, + server.stores.user, + created, + ); + 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 (actor: Actor, fn: () => Promise): Promise => + runWithContext({ actor }, fn); + +const uniqueSubdomain = (prefix: string) => + `${prefix}-${Math.random().toString(36).slice(2, 10)}`; + +// ── create ────────────────────────────────────────────────────────── + +describe('SubdomainDriver.create', () => { + it('creates a subdomain pointing at an owned fs path', async () => { + const { actor, userId } = await makeUser(); + const username = actor.user!.username!; + const sub = uniqueSubdomain('site'); + + const result = (await withActor(actor, () => + driver.create({ + object: { + subdomain: sub, + root_dir: `/${username}/Public`, + }, + }), + )) as Record | null; + + expect(result).not.toBeNull(); + expect(result?.subdomain).toBe(sub); + // Owner is hydrated as `{ username, uuid }`, not a numeric id. + expect(result?.owner).toMatchObject({ username }); + + const row = + await server.stores.subdomain.getBySubdomain(sub); + expect(row?.user_id).toBe(userId); + }); + + it('expands `~/Public` against the actor home before resolving root_dir', async () => { + const { actor } = await makeUser(); + const sub = uniqueSubdomain('tilde'); + + await withActor(actor, () => + driver.create({ + object: { subdomain: sub, root_dir: '~/Public' }, + }), + ); + + const row = + await server.stores.subdomain.getBySubdomain(sub); + expect(row).not.toBeNull(); + }); + + it('rejects an invalid subdomain format with 400', async () => { + const { actor } = await makeUser(); + await expect( + withActor(actor, () => + driver.create({ + object: { + subdomain: 'NOT_VALID!', + root_dir: `/${actor.user!.username}/Public`, + }, + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('rejects a reserved subdomain word with 400', async () => { + const { actor } = await makeUser(); + await expect( + withActor(actor, () => + driver.create({ + object: { + subdomain: 'admin', + root_dir: `/${actor.user!.username}/Public`, + }, + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('rejects a duplicate subdomain with 409', async () => { + const a = await makeUser(); + const b = await makeUser(); + const sub = uniqueSubdomain('dup'); + + await withActor(a.actor, () => + driver.create({ + object: { + subdomain: sub, + root_dir: `/${a.actor.user!.username}/Public`, + }, + }), + ); + + await expect( + withActor(b.actor, () => + driver.create({ + object: { + subdomain: sub, + root_dir: `/${b.actor.user!.username}/Public`, + }, + }), + ), + ).rejects.toMatchObject({ statusCode: 409 }); + }); + + it('rejects when root_dir does not exist', async () => { + const { actor } = await makeUser(); + await expect( + withActor(actor, () => + driver.create({ + object: { + subdomain: uniqueSubdomain('missing'), + root_dir: `/${actor.user!.username}/does-not-exist`, + }, + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it("rejects pointing root_dir at another user's tree", async () => { + const a = await makeUser(); + const b = await makeUser(); + await expect( + withActor(a.actor, () => + driver.create({ + object: { + subdomain: uniqueSubdomain('intruder'), + root_dir: `/${b.actor.user!.username}/Public`, + }, + }), + ), + ).rejects.toMatchObject({ + statusCode: expect.any(Number), + }); + }); + + it('rejects a missing object body with 400', async () => { + const { actor } = await makeUser(); + await expect( + withActor(actor, () => + driver.create({} as Record), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('throws 401 with no actor in context', async () => { + await expect( + driver.create({ + object: { + subdomain: uniqueSubdomain('noctx'), + root_dir: '/x', + }, + }), + ).rejects.toMatchObject({ statusCode: 401 }); + }); +}); + +// ── read / select ─────────────────────────────────────────────────── + +describe('SubdomainDriver.read / select', () => { + it('reads a subdomain by uid for its owner', async () => { + const { actor } = await makeUser(); + const username = actor.user!.username!; + const sub = uniqueSubdomain('read'); + + const created = (await withActor(actor, () => + driver.create({ + object: { subdomain: sub, root_dir: `/${username}/Public` }, + }), + )) as Record; + + const fetched = (await withActor(actor, () => + driver.read({ uid: created.uid }), + )) as Record | null; + + expect(fetched?.subdomain).toBe(sub); + }); + + it('reads via id object with `{ subdomain }`', async () => { + const { actor } = await makeUser(); + const sub = uniqueSubdomain('read-by-name'); + await withActor(actor, () => + driver.create({ + object: { + subdomain: sub, + root_dir: `/${actor.user!.username}/Public`, + }, + }), + ); + + const fetched = (await withActor(actor, () => + driver.read({ id: { subdomain: sub } }), + )) as Record | null; + + expect(fetched?.subdomain).toBe(sub); + }); + + it("rejects reading another user's subdomain with 403", async () => { + const a = await makeUser(); + const b = await makeUser(); + const sub = uniqueSubdomain('private'); + + const created = (await withActor(a.actor, () => + driver.create({ + object: { + subdomain: sub, + root_dir: `/${a.actor.user!.username}/Public`, + }, + }), + )) as Record; + + await expect( + withActor(b.actor, () => driver.read({ uid: created.uid })), + ).rejects.toMatchObject({ statusCode: 403 }); + }); + + it('returns 404 when reading a missing subdomain', async () => { + const { actor } = await makeUser(); + await expect( + withActor(actor, () => + driver.read({ uid: 'nonexistent-uuid' }), + ), + ).rejects.toMatchObject({ statusCode: 404 }); + }); + + it('select returns only the actor-owned subdomains', async () => { + const a = await makeUser(); + const b = await makeUser(); + await withActor(a.actor, () => + driver.create({ + object: { + subdomain: uniqueSubdomain('mine'), + root_dir: `/${a.actor.user!.username}/Public`, + }, + }), + ); + await withActor(b.actor, () => + driver.create({ + object: { + subdomain: uniqueSubdomain('theirs'), + root_dir: `/${b.actor.user!.username}/Public`, + }, + }), + ); + + const result = (await withActor(a.actor, () => + driver.select({}), + )) as Array>; + + // Owners surface as `{ username, uuid }`; assert we only see a's. + for (const row of result) { + expect((row.owner as { username: string }).username).toBe( + a.actor.user!.username, + ); + } + }); +}); + +// ── update ────────────────────────────────────────────────────────── + +describe('SubdomainDriver.update', () => { + it('updates root_dir to another owned path', async () => { + const { actor } = await makeUser(); + const username = actor.user!.username!; + const sub = uniqueSubdomain('upd'); + + const created = (await withActor(actor, () => + driver.create({ + object: { subdomain: sub, root_dir: `/${username}/Public` }, + }), + )) as Record; + + const updated = (await withActor(actor, () => + driver.update({ + uid: created.uid, + object: { root_dir: `/${username}/Documents` }, + }), + )) as Record | null; + + expect(updated).not.toBeNull(); + const rootDir = updated!.root_dir as Record | null; + expect(rootDir?.path).toBe(`/${username}/Documents`); + }); + + it('refuses to update a subdomain owned by another user with 403', async () => { + const a = await makeUser(); + const b = await makeUser(); + const sub = uniqueSubdomain('cross-upd'); + + const created = (await withActor(a.actor, () => + driver.create({ + object: { + subdomain: sub, + root_dir: `/${a.actor.user!.username}/Public`, + }, + }), + )) as Record; + + await expect( + withActor(b.actor, () => + driver.update({ + uid: created.uid, + object: { + root_dir: `/${b.actor.user!.username}/Public`, + }, + }), + ), + ).rejects.toMatchObject({ statusCode: 403 }); + }); + + it('returns 404 for a missing object body', async () => { + const { actor } = await makeUser(); + await expect( + withActor(actor, () => + driver.update({ uid: 'whatever' } as Record), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); +}); + +// ── upsert ────────────────────────────────────────────────────────── + +describe('SubdomainDriver.upsert', () => { + it('creates when no row matches the args', async () => { + const { actor } = await makeUser(); + const sub = uniqueSubdomain('ups'); + const result = (await withActor(actor, () => + driver.upsert({ + object: { + subdomain: sub, + root_dir: `/${actor.user!.username}/Public`, + }, + }), + )) as Record | null; + expect(result?.subdomain).toBe(sub); + }); + + it('updates when an existing row resolves via id.subdomain', async () => { + const { actor } = await makeUser(); + const username = actor.user!.username!; + const sub = uniqueSubdomain('ups-existing'); + await withActor(actor, () => + driver.create({ + object: { subdomain: sub, root_dir: `/${username}/Public` }, + }), + ); + + const result = (await withActor(actor, () => + driver.upsert({ + id: { subdomain: sub }, + object: { root_dir: `/${username}/Documents` }, + }), + )) as Record | null; + + const rootDir = result!.root_dir as Record | null; + expect(rootDir?.path).toBe(`/${username}/Documents`); + }); +}); + +// ── delete ────────────────────────────────────────────────────────── + +describe('SubdomainDriver.delete', () => { + it('deletes an owned subdomain and reports success', async () => { + const { actor } = await makeUser(); + const sub = uniqueSubdomain('del'); + const created = (await withActor(actor, () => + driver.create({ + object: { + subdomain: sub, + root_dir: `/${actor.user!.username}/Public`, + }, + }), + )) as Record; + + const result = (await withActor(actor, () => + driver.delete({ uid: created.uid }), + )) as { success: boolean; uid: string }; + + expect(result.success).toBe(true); + expect(result.uid).toBe(created.uid); + expect( + await server.stores.subdomain.getBySubdomain(sub), + ).toBeNull(); + }); + + it("refuses to delete another user's subdomain with 403", async () => { + const a = await makeUser(); + const b = await makeUser(); + const sub = uniqueSubdomain('cross-del'); + const created = (await withActor(a.actor, () => + driver.create({ + object: { + subdomain: sub, + root_dir: `/${a.actor.user!.username}/Public`, + }, + }), + )) as Record; + + await expect( + withActor(b.actor, () => driver.delete({ uid: created.uid })), + ).rejects.toMatchObject({ statusCode: 403 }); + + // a's row is still there. + expect( + await server.stores.subdomain.getBySubdomain(sub), + ).not.toBeNull(); + }); + + it('returns 404 for a non-existent subdomain', async () => { + const { actor } = await makeUser(); + await expect( + withActor(actor, () => + driver.delete({ uid: 'nonexistent-uuid' }), + ), + ).rejects.toMatchObject({ statusCode: 404 }); + }); +});