diff --git a/e2e/bookmarks-api.spec.ts b/e2e/bookmarks-api.spec.ts new file mode 100644 index 00000000..2dd1502d --- /dev/null +++ b/e2e/bookmarks-api.spec.ts @@ -0,0 +1,102 @@ +import { test, expect } from './support/fixtures.js'; +import { waitForHydration } from './support/helpers.js'; + +/** + * Bookmark API end to end against the real database: the create/list/update/ + * delete lifecycle and per-user isolation. Request validation and the 401 + * without a user are covered by the unit tests next to the route handlers; + * the 401 without a session by the generic API guard tests. + */ +test.describe('Bookmarks API', () => { + // BOOKMARK_LIMIT in src/lib/dashboard/types.ts. + const BOOKMARK_LIMIT = 25; + const FIELDS = { + productId: 'trino', + name: 'API Trino', + environment: 'prod', + url: 'https://trino.example.com', + pinned: false + }; + + test('creates, lists, updates and deletes a bookmark', async ({ page }) => { + const created = await page.request.post('/api/bookmarks', { data: FIELDS }); + expect(created.status()).toBe(201); + const bookmark = await created.json(); + expect(bookmark).toEqual({ + ...FIELDS, + id: expect.any(String), + createdAt: expect.any(String) + }); + + try { + const listed = await page.request.get('/api/bookmarks'); + expect(listed.status()).toBe(200); + expect(await listed.json()).toContainEqual(bookmark); + + const updated = await page.request.patch(`/api/bookmarks/${bookmark.id}`, { + data: { pinned: true, name: 'Renamed Trino' } + }); + expect(updated.status()).toBe(200); + expect(await updated.json()).toEqual({ ...bookmark, pinned: true, name: 'Renamed Trino' }); + } finally { + const deleted = await page.request.delete(`/api/bookmarks/${bookmark.id}`); + expect(deleted.status()).toBe(204); + } + + const listed = await page.request.get('/api/bookmarks'); + expect((await listed.json()).map((b: { id: string }) => b.id)).not.toContain(bookmark.id); + const deletedAgain = await page.request.delete(`/api/bookmarks/${bookmark.id}`); + expect(deletedAgain.status()).toBe(404); + }); + + test('rejects bookmarks beyond the per-user limit with 409', async ({ page }) => { + const existing = (await (await page.request.get('/api/bookmarks')).json()).length; + const created: string[] = []; + try { + for (let i = existing; i < BOOKMARK_LIMIT; i++) { + const response = await page.request.post('/api/bookmarks', { + data: { ...FIELDS, name: `Limit ${i}` } + }); + expect(response.status()).toBe(201); + created.push((await response.json()).id); + } + + const rejected = await page.request.post('/api/bookmarks', { data: FIELDS }); + expect(rejected.status()).toBe(409); + } finally { + for (const id of created) await page.request.delete(`/api/bookmarks/${id}`); + } + }); + + test('keeps bookmarks private to their owner', async ({ page, browser, baseURL }) => { + const bookmark = await (await page.request.post('/api/bookmarks', { data: FIELDS })).json(); + + // The mock OIDC server issues a new user for every login. + const otherContext = await browser.newContext({ + baseURL, + storageState: { cookies: [], origins: [] } + }); + try { + const other = await otherContext.newPage(); + await other.goto('/'); + await waitForHydration(other); + await other.getByRole('button', { name: /sign in with sso/i }).click(); + await expect(other).toHaveURL('/'); + + const listed = await other.request.get('/api/bookmarks'); + expect((await listed.json()).map((b: { id: string }) => b.id)).not.toContain(bookmark.id); + const patched = await other.request.patch(`/api/bookmarks/${bookmark.id}`, { + data: { pinned: true } + }); + expect(patched.status()).toBe(404); + const deleted = await other.request.delete(`/api/bookmarks/${bookmark.id}`); + expect(deleted.status()).toBe(404); + + const owned = await page.request.get('/api/bookmarks'); + expect(await owned.json()).toContainEqual(bookmark); + } finally { + await otherContext.close(); + await page.request.delete(`/api/bookmarks/${bookmark.id}`); + } + }); +}); diff --git a/e2e/bookmarks.spec.ts b/e2e/bookmarks.spec.ts index 9b9ca452..111ffaa2 100644 --- a/e2e/bookmarks.spec.ts +++ b/e2e/bookmarks.spec.ts @@ -115,7 +115,8 @@ test.describe('Dashboard bookmarks', () => { test('truncates long names and hostnames instead of overlapping the card buttons', async ({ page }) => { - const longName = 'Very long bookmark name '.repeat(6).trim(); + // Names are capped at 40 characters; wide glyphs still overflow the card. + const longName = 'W'.repeat(40); const longHost = `${'very-long-subdomain-'.repeat(5)}example.com`; await page.addInitScript( ({ name, host }) => { diff --git a/src/lib/components/dashboard/AddBookmarkModal.svelte b/src/lib/components/dashboard/AddBookmarkModal.svelte index 93500d6a..469da0da 100644 --- a/src/lib/components/dashboard/AddBookmarkModal.svelte +++ b/src/lib/components/dashboard/AddBookmarkModal.svelte @@ -4,7 +4,12 @@ import { getProduct, PRODUCTS, type Product } from '$lib/dashboard/products'; import ProductIcon from './ProductIcon.svelte'; import { addBookmark, removeBookmark, updateBookmark } from '$lib/dashboard/bookmarks.svelte.js'; - import { bookmarkFormSchema, bookmarkHostname, type Bookmark } from '$lib/dashboard/types'; + import { + BOOKMARK_MAX_LENGTH, + bookmarkFormSchema, + bookmarkHostname, + type Bookmark + } from '$lib/dashboard/types'; import * as m from '$lib/paraglide/messages.js'; import IconClose from 'virtual:icons/material-symbols/close'; @@ -171,6 +176,7 @@ type="text" bind:value={name} oninput={handleNameInput} + maxlength={BOOKMARK_MAX_LENGTH.name} placeholder={selectedProduct.defaultName} class="input w-full" aria-invalid={nameError} @@ -188,6 +194,7 @@ id="{uid}-env" type="text" bind:value={environment} + maxlength={BOOKMARK_MAX_LENGTH.environment} placeholder={m.bookmark_env_placeholder()} class="input w-full" /> @@ -205,6 +212,7 @@ bind:value={url} oninput={handleUrlInput} onblur={handleUrlBlur} + maxlength={BOOKMARK_MAX_LENGTH.url} placeholder={m.bookmark_url_placeholder()} class="input w-full" aria-invalid={urlError} diff --git a/src/lib/dashboard/product-ids.ts b/src/lib/dashboard/product-ids.ts new file mode 100644 index 00000000..26069794 --- /dev/null +++ b/src/lib/dashboard/product-ids.ts @@ -0,0 +1,19 @@ +/** + * Ids of the products a bookmark can point to; `custom` is a plain link. Kept + * apart from `products.ts` so the server can validate ids without loading the + * logos and messages. + */ +export const PRODUCT_IDS = [ + 'trino', + 'superset', + 'airflow', + 'nifi', + 'druid', + 'opensearch', + 'hdfs', + 'hbase', + 'spark', + 'custom' +] as const; + +export type ProductId = (typeof PRODUCT_IDS)[number]; diff --git a/src/lib/dashboard/products.test.ts b/src/lib/dashboard/products.test.ts index 0db30c57..373726ac 100644 --- a/src/lib/dashboard/products.test.ts +++ b/src/lib/dashboard/products.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { getProduct, PRODUCTS } from './products'; +import { PRODUCT_IDS } from './product-ids'; /** WCAG 2.1 relative luminance of a `#RRGGBB` colour. */ function luminance(hex: string): number { @@ -23,6 +24,10 @@ describe('PRODUCTS', () => { } ); + it('defines a product for every product id, in the same order', () => { + expect(PRODUCTS.map((p) => p.id)).toEqual(PRODUCT_IDS); + }); + it('falls back to the custom product for unknown ids', () => { expect(getProduct('does-not-exist').id).toBe('custom'); }); diff --git a/src/lib/dashboard/products.ts b/src/lib/dashboard/products.ts index 2c7ee9b4..80b638a3 100644 --- a/src/lib/dashboard/products.ts +++ b/src/lib/dashboard/products.ts @@ -1,8 +1,9 @@ import type { Picture } from '@sveltejs/enhanced-img'; import * as m from '$lib/paraglide/messages.js'; +import type { ProductId } from './product-ids'; export interface Product { - id: string; + id: ProductId; /** Proper noun, except for the custom link, which is localised. */ readonly name: string; initials: string; diff --git a/src/lib/dashboard/types.ts b/src/lib/dashboard/types.ts index 1bf89345..391f2886 100644 --- a/src/lib/dashboard/types.ts +++ b/src/lib/dashboard/types.ts @@ -1,10 +1,17 @@ import { z } from 'zod'; +import { PRODUCT_IDS } from './product-ids'; + +/** Maximum field lengths, enforced by the API and as `maxlength` in the form. */ +export const BOOKMARK_MAX_LENGTH = { name: 40, environment: 100, url: 2048 } as const; + +/** Maximum number of bookmarks per user, enforced by the API. */ +export const BOOKMARK_LIMIT = 25; export const bookmarkFormSchema = z.object({ - productId: z.string(), - name: z.string().trim().min(1), - environment: z.string(), - url: z.url({ protocol: /^https?$/ }), + productId: z.string().min(1).max(64), + name: z.string().trim().min(1).max(BOOKMARK_MAX_LENGTH.name), + environment: z.string().trim().max(BOOKMARK_MAX_LENGTH.environment), + url: z.url({ protocol: /^https?$/ }).max(BOOKMARK_MAX_LENGTH.url), pinned: z.boolean() }); @@ -15,6 +22,21 @@ export const bookmarkSchema = bookmarkFormSchema.extend({ export type Bookmark = z.infer; +/** + * Body of a bookmark create request. Stored bookmarks keep any product id, so a + * product removed later still shows as a custom link; new ones must be known. + */ +export const bookmarkCreateSchema = bookmarkFormSchema.extend({ + productId: z.enum(PRODUCT_IDS) +}); + +/** Body of a bookmark update: any subset of the create fields, but at least one. */ +export const bookmarkUpdateSchema = bookmarkCreateSchema + .partial() + .refine((update) => Object.keys(update).length > 0, { message: 'No fields to update' }); + +export type BookmarkUpdate = z.infer; + /** Hostname of a bookmark URL, or the input unchanged if it does not parse. */ export function bookmarkHostname(url: string): string { try { diff --git a/src/lib/server/auth-utils.ts b/src/lib/server/auth-utils.ts index 0bb1e2c7..504b39d1 100644 --- a/src/lib/server/auth-utils.ts +++ b/src/lib/server/auth-utils.ts @@ -1,3 +1,14 @@ +import { error } from '@sveltejs/kit'; + export function getUserId(locals: App.Locals): string { return locals.user?.id ?? 'anonymous'; } + +/** + * The signed-in user's id for per-user API routes. Without OIDC there is no + * user (the auth guard does not run), so such routes answer 401. + */ +export function requireUserId(locals: App.Locals): string { + if (!locals.user) throw error(401, 'Authentication required'); + return locals.user.id; +} diff --git a/src/lib/server/dashboard/bookmarks-db.spec.ts b/src/lib/server/dashboard/bookmarks-db.spec.ts new file mode 100644 index 00000000..5e30f910 --- /dev/null +++ b/src/lib/server/dashboard/bookmarks-db.spec.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { and, asc, eq } from 'drizzle-orm'; +import { userBookmarks } from '$lib/server/schema.js'; +import { BOOKMARK_LIMIT } from '$lib/dashboard/types.js'; +import { listBookmarks, createBookmark, updateBookmark, deleteBookmark } from './bookmarks-db.js'; + +vi.mock('$lib/server/logging', () => ({ + logger: { child: () => ({ info: vi.fn(), debug: vi.fn() }) } +})); + +const { mockDb } = vi.hoisted(() => ({ + mockDb: { select: vi.fn(), insert: vi.fn(), update: vi.fn(), delete: vi.fn(), $count: vi.fn() } +})); + +vi.mock('$lib/server/db.js', () => ({ db: mockDb })); + +type ChainFn = (...args: unknown[]) => MockChain; + +/** Mocked Drizzle query builder: every call returns the same chain, which is + * thenable and resolves to `result`. */ +interface MockChain { + from: Mock; + where: Mock; + orderBy: Mock; + values: Mock; + set: Mock; + returning: Mock; + then: (resolve: (value: unknown) => unknown) => Promise; +} + +function buildChain(result: unknown): MockChain { + const chain: MockChain = { + from: vi.fn(() => chain), + where: vi.fn(() => chain), + orderBy: vi.fn(() => chain), + values: vi.fn(() => chain), + set: vi.fn(() => chain), + returning: vi.fn(() => chain), + then: (resolve) => Promise.resolve(result).then(resolve) + }; + return chain; +} + +const USER_ID = 'user-1'; +const BOOKMARK_ID = '3f1c2a9e-7b4d-4c8a-9e2f-5d6b7a8c9d01'; +const FIELDS = { + productId: 'trino', + name: 'Production Trino', + environment: 'prod', + url: 'https://trino.example.com', + pinned: false +}; +const ROW = { id: BOOKMARK_ID, ...FIELDS, createdAt: new Date('2026-10-27T10:00:00.000Z') }; +const BOOKMARK = { ...ROW, createdAt: '2026-10-27T10:00:00.000Z' }; +const ownBookmark = and(eq(userBookmarks.userId, USER_ID), eq(userBookmarks.id, BOOKMARK_ID)); + +describe('bookmarks-db', () => { + beforeEach(() => vi.clearAllMocks()); + + it('lists the user’s bookmarks oldest first with ISO timestamps', async () => { + const chain = buildChain([ROW]); + mockDb.select.mockReturnValue(chain); + + await expect(listBookmarks(USER_ID)).resolves.toEqual([BOOKMARK]); + expect(chain.from).toHaveBeenCalledWith(userBookmarks); + expect(chain.where).toHaveBeenCalledWith(eq(userBookmarks.userId, USER_ID)); + expect(chain.orderBy).toHaveBeenCalledWith(asc(userBookmarks.createdAt), asc(userBookmarks.id)); + }); + + it('creates a bookmark owned by the user', async () => { + const chain = buildChain([ROW]); + mockDb.$count.mockResolvedValue(BOOKMARK_LIMIT - 1); + mockDb.insert.mockReturnValue(chain); + await expect(createBookmark(USER_ID, FIELDS)).resolves.toEqual(BOOKMARK); + expect(mockDb.$count).toHaveBeenCalledWith(userBookmarks, eq(userBookmarks.userId, USER_ID)); + expect(mockDb.insert).toHaveBeenCalledWith(userBookmarks); + expect(chain.values).toHaveBeenCalledWith({ userId: USER_ID, ...FIELDS }); + }); + + it('creates nothing once the user has reached the bookmark limit', async () => { + mockDb.$count.mockResolvedValue(BOOKMARK_LIMIT); + + await expect(createBookmark(USER_ID, FIELDS)).resolves.toBeNull(); + expect(mockDb.insert).not.toHaveBeenCalled(); + }); + + it('updates only the user’s own bookmark and returns null when there is none', async () => { + const chain = buildChain([{ ...ROW, pinned: true }]); + mockDb.update.mockReturnValue(chain); + + await expect(updateBookmark(USER_ID, BOOKMARK_ID, { pinned: true })).resolves.toEqual({ + ...BOOKMARK, + pinned: true + }); + expect(chain.set).toHaveBeenCalledWith({ pinned: true }); + expect(chain.where).toHaveBeenCalledWith(ownBookmark); + + mockDb.update.mockReturnValue(buildChain([])); + await expect(updateBookmark(USER_ID, BOOKMARK_ID, { pinned: true })).resolves.toBeNull(); + }); + + it('deletes only the user’s own bookmark and reports whether it existed', async () => { + const chain = buildChain([{ id: BOOKMARK_ID }]); + mockDb.delete.mockReturnValue(chain); + + await expect(deleteBookmark(USER_ID, BOOKMARK_ID)).resolves.toBe(true); + expect(chain.where).toHaveBeenCalledWith(ownBookmark); + + mockDb.delete.mockReturnValue(buildChain([])); + await expect(deleteBookmark(USER_ID, BOOKMARK_ID)).resolves.toBe(false); + }); +}); diff --git a/src/lib/server/dashboard/bookmarks-db.ts b/src/lib/server/dashboard/bookmarks-db.ts new file mode 100644 index 00000000..b5a78ca1 --- /dev/null +++ b/src/lib/server/dashboard/bookmarks-db.ts @@ -0,0 +1,98 @@ +import { and, asc, eq } from 'drizzle-orm'; +import { db } from '$lib/server/db.js'; +import { userBookmarks } from '$lib/server/schema.js'; +import { logger } from '$lib/server/logging'; +import { BOOKMARK_LIMIT, type Bookmark, type BookmarkUpdate } from '$lib/dashboard/types.js'; + +const log = logger.child({ module: 'bookmarks-db' }); + +const bookmarkColumns = { + id: userBookmarks.id, + productId: userBookmarks.productId, + name: userBookmarks.name, + environment: userBookmarks.environment, + url: userBookmarks.url, + pinned: userBookmarks.pinned, + createdAt: userBookmarks.createdAt +}; + +type BookmarkRow = { createdAt: Date } & Omit; + +function toBookmark(row: BookmarkRow): Bookmark { + return { ...row, createdAt: row.createdAt.toISOString() }; +} + +/** List a user's bookmarks, oldest first. */ +export async function listBookmarks(userId: string): Promise { + const rows = await db + .select(bookmarkColumns) + .from(userBookmarks) + .where(eq(userBookmarks.userId, userId)) + .orderBy(asc(userBookmarks.createdAt), asc(userBookmarks.id)); + + log.debug({ user_id: userId, bookmark_count: rows.length }, 'listed bookmarks'); + return rows.map(toBookmark); +} + +/** + * Create a bookmark for a user and return it with its generated id and + * timestamp, or null if the user already has `BOOKMARK_LIMIT` bookmarks. + * Concurrent requests can exceed the limit slightly; it guards against + * unbounded growth, not exact counts. + */ +export async function createBookmark( + userId: string, + bookmark: Omit +): Promise { + const count = await db.$count(userBookmarks, eq(userBookmarks.userId, userId)); + if (count >= BOOKMARK_LIMIT) { + log.debug({ user_id: userId, bookmark_count: count }, 'bookmark limit reached'); + return null; + } + + const [row] = await db + .insert(userBookmarks) + .values({ userId, ...bookmark }) + .returning(bookmarkColumns); + + log.debug({ user_id: userId, bookmark_id: row.id }, 'created bookmark'); + return toBookmark(row); +} + +/** + * Update fields of a user's bookmark. Returns the updated bookmark, or null if + * the user has no bookmark with this id. + */ +export async function updateBookmark( + userId: string, + id: string, + update: BookmarkUpdate +): Promise { + const [row] = await db + .update(userBookmarks) + .set(update) + .where(and(eq(userBookmarks.userId, userId), eq(userBookmarks.id, id))) + .returning(bookmarkColumns); + + if (!row) { + log.debug({ user_id: userId, bookmark_id: id }, 'bookmark to update not found'); + return null; + } + log.debug({ user_id: userId, bookmark_id: id }, 'updated bookmark'); + return toBookmark(row); +} + +/** Delete a user's bookmark. Returns false if the user has no bookmark with this id. */ +export async function deleteBookmark(userId: string, id: string): Promise { + const rows = await db + .delete(userBookmarks) + .where(and(eq(userBookmarks.userId, userId), eq(userBookmarks.id, id))) + .returning({ id: userBookmarks.id }); + + if (rows.length === 0) { + log.debug({ user_id: userId, bookmark_id: id }, 'bookmark to delete not found'); + return false; + } + log.debug({ user_id: userId, bookmark_id: id }, 'deleted bookmark'); + return true; +} diff --git a/src/lib/server/metrics.ts b/src/lib/server/metrics.ts index 12101b66..4f886fe7 100644 --- a/src/lib/server/metrics.ts +++ b/src/lib/server/metrics.ts @@ -39,6 +39,13 @@ export const storageSearchHistoryTotal = new Counter({ registers: [register] }); +export const dashboardBookmarksTotal = new Counter({ + name: 'dashboard_bookmarks_total', + help: 'Dashboard bookmark operations executed through the Stackable UI', + labelNames: ['operation', 'outcome'], + registers: [register] +}); + export const storageTransferItemsTotal = new Counter({ name: 'storage_transfer_items_total', help: 'Selected items of storage copy, move and rename transfers, by final status', diff --git a/src/lib/server/migrations/0004_user_bookmarks.sql b/src/lib/server/migrations/0004_user_bookmarks.sql new file mode 100644 index 00000000..73eee411 --- /dev/null +++ b/src/lib/server/migrations/0004_user_bookmarks.sql @@ -0,0 +1,12 @@ +CREATE TABLE "user_bookmarks" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "product_id" text NOT NULL, + "name" text NOT NULL, + "environment" text NOT NULL, + "url" text NOT NULL, + "pinned" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE INDEX "user_bookmarks_user_id_idx" ON "user_bookmarks" USING btree ("user_id"); diff --git a/src/lib/server/migrations/meta/0004_snapshot.json b/src/lib/server/migrations/meta/0004_snapshot.json new file mode 100644 index 00000000..95184a66 --- /dev/null +++ b/src/lib/server/migrations/meta/0004_snapshot.json @@ -0,0 +1,786 @@ +{ + "id": "eb516884-ee2b-4d3e-840f-5b5650b7b17f", + "prevId": "2a7682cb-b94c-4e44-8ad8-06bead816e8b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.storage_download_manifests": { + "name": "storage_download_manifests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "archive": { + "name": "archive", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "storage_download_manifests_user_expiry_idx": { + "name": "storage_download_manifests_user_expiry_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "storage_download_manifests_expiry_idx": { + "name": "storage_download_manifests_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "storage_download_manifests_connection_id_user_storage_connections_id_fk": { + "name": "storage_download_manifests_connection_id_user_storage_connections_id_fk", + "tableFrom": "storage_download_manifests", + "tableTo": "user_storage_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_bookmarks": { + "name": "user_bookmarks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_bookmarks_user_id_idx": { + "name": "user_bookmarks_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_recent_searches": { + "name": "user_recent_searches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "buckets": { + "name": "buckets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "use_regex": { + "name": "use_regex", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "exclude_patterns": { + "name": "exclude_patterns", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "search_path": { + "name": "search_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "max_depth": { + "name": "max_depth", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "search_key": { + "name": "search_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_recent_searches_connection_idx": { + "name": "user_recent_searches_connection_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_recent_searches_connection_id_user_storage_connections_id_fk": { + "name": "user_recent_searches_connection_id_user_storage_connections_id_fk", + "tableFrom": "user_recent_searches", + "tableTo": "user_storage_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_recent_searches_search_key": { + "name": "user_recent_searches_search_key", + "nullsNotDistinct": false, + "columns": ["user_id", "connection_id", "search_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_storage_connections": { + "name": "user_storage_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_payload": { + "name": "encrypted_payload", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "additional_buckets": { + "name": "additional_buckets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_storage_connections_user_id_name_unique": { + "name": "user_storage_connections_user_id_name_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_storage_connection_id": { + "name": "active_storage_connection_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/src/lib/server/migrations/meta/_journal.json b/src/lib/server/migrations/meta/_journal.json index b5290ed9..1f599e9e 100644 --- a/src/lib/server/migrations/meta/_journal.json +++ b/src/lib/server/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1791810607788, "tag": "0003_recent_search_key", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1793094984249, + "tag": "0004_user_bookmarks", + "breakpoints": true } ] } diff --git a/src/lib/server/schema.ts b/src/lib/server/schema.ts index b65acb17..55d62404 100644 --- a/src/lib/server/schema.ts +++ b/src/lib/server/schema.ts @@ -91,3 +91,22 @@ export const storageDownloadManifests = pgTable( index('storage_download_manifests_expiry_idx').on(table.expiresAt) ] ); + +/** + * Dashboard bookmarks per user: links to Stackable product UIs, optionally + * pinned to the sidebar favourites. + */ +export const userBookmarks = pgTable( + 'user_bookmarks', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: text('user_id').notNull(), + productId: text('product_id').notNull(), + name: text('name').notNull(), + environment: text('environment').notNull(), + url: text('url').notNull(), + pinned: boolean('pinned').notNull().default(false), + createdAt: timestamp('created_at', { mode: 'date', withTimezone: true }).defaultNow().notNull() + }, + (table) => [index('user_bookmarks_user_id_idx').on(table.userId)] +); diff --git a/src/routes/(app)/api/bookmarks/+server.ts b/src/routes/(app)/api/bookmarks/+server.ts new file mode 100644 index 00000000..051fd7a7 --- /dev/null +++ b/src/routes/(app)/api/bookmarks/+server.ts @@ -0,0 +1,44 @@ +import { error } from '@sveltejs/kit'; +import { createBookmark, listBookmarks } from '$lib/server/dashboard/bookmarks-db.js'; +import { dashboardBookmarksTotal } from '$lib/server/metrics.js'; +import { requireUserId } from '$lib/server/auth-utils.js'; +import { BOOKMARK_LIMIT, bookmarkCreateSchema } from '$lib/dashboard/types.js'; +import type { RequestHandler } from './$types'; + +/** Lists the current user's bookmarks. */ +export const GET: RequestHandler = async ({ locals }) => { + const userId = requireUserId(locals); + try { + const bookmarks = await listBookmarks(userId); + dashboardBookmarksTotal.inc({ operation: 'list', outcome: 'success' }); + return Response.json(bookmarks); + } catch (err) { + dashboardBookmarksTotal.inc({ operation: 'list', outcome: 'error' }); + throw err; + } +}; + +/** Creates a bookmark for the current user, up to `BOOKMARK_LIMIT`. */ +export const POST: RequestHandler = async ({ request, locals }) => { + const userId = requireUserId(locals); + const parsed = bookmarkCreateSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) throw error(400, 'Invalid bookmark'); + + let bookmark; + try { + bookmark = await createBookmark(userId, parsed.data); + } catch (err) { + dashboardBookmarksTotal.inc({ operation: 'create', outcome: 'error' }); + throw err; + } + if (!bookmark) { + dashboardBookmarksTotal.inc({ operation: 'create', outcome: 'limit_reached' }); + throw error(409, `Bookmark limit of ${BOOKMARK_LIMIT} reached`); + } + dashboardBookmarksTotal.inc({ operation: 'create', outcome: 'success' }); + locals.logger.info( + { user_id: userId, bookmark_id: bookmark.id, product_id: bookmark.productId }, + 'bookmark created' + ); + return Response.json(bookmark, { status: 201 }); +}; diff --git a/src/routes/(app)/api/bookmarks/[id]/+server.ts b/src/routes/(app)/api/bookmarks/[id]/+server.ts new file mode 100644 index 00000000..e2fee0cf --- /dev/null +++ b/src/routes/(app)/api/bookmarks/[id]/+server.ts @@ -0,0 +1,61 @@ +import { error } from '@sveltejs/kit'; +import { z } from 'zod'; +import { deleteBookmark, updateBookmark } from '$lib/server/dashboard/bookmarks-db.js'; +import { dashboardBookmarksTotal } from '$lib/server/metrics.js'; +import { requireUserId } from '$lib/server/auth-utils.js'; +import { bookmarkUpdateSchema } from '$lib/dashboard/types.js'; +import type { RequestHandler } from './$types'; + +/** Bookmark ids are UUIDs; anything else would fail the query. */ +function bookmarkIdFromParams(params: { id: string }): string { + const parsed = z.uuid().safeParse(params.id); + if (!parsed.success) throw error(400, 'Invalid bookmark ID'); + return parsed.data; +} + +/** Updates fields of one of the current user's bookmarks. */ +export const PATCH: RequestHandler = async ({ params, request, locals }) => { + const userId = requireUserId(locals); + const id = bookmarkIdFromParams(params); + const parsed = bookmarkUpdateSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) throw error(400, 'Invalid bookmark update'); + + let bookmark; + try { + bookmark = await updateBookmark(userId, id, parsed.data); + } catch (err) { + dashboardBookmarksTotal.inc({ operation: 'update', outcome: 'error' }); + throw err; + } + if (!bookmark) { + dashboardBookmarksTotal.inc({ operation: 'update', outcome: 'not_found' }); + throw error(404, 'Bookmark not found'); + } + dashboardBookmarksTotal.inc({ operation: 'update', outcome: 'success' }); + locals.logger.info( + { user_id: userId, bookmark_id: id, fields: Object.keys(parsed.data) }, + 'bookmark updated' + ); + return Response.json(bookmark); +}; + +/** Deletes one of the current user's bookmarks. */ +export const DELETE: RequestHandler = async ({ params, locals }) => { + const userId = requireUserId(locals); + const id = bookmarkIdFromParams(params); + + let deleted; + try { + deleted = await deleteBookmark(userId, id); + } catch (err) { + dashboardBookmarksTotal.inc({ operation: 'delete', outcome: 'error' }); + throw err; + } + if (!deleted) { + dashboardBookmarksTotal.inc({ operation: 'delete', outcome: 'not_found' }); + throw error(404, 'Bookmark not found'); + } + dashboardBookmarksTotal.inc({ operation: 'delete', outcome: 'success' }); + locals.logger.info({ user_id: userId, bookmark_id: id }, 'bookmark deleted'); + return new Response(null, { status: 204 }); +}; diff --git a/src/routes/(app)/api/bookmarks/[id]/server.test.ts b/src/routes/(app)/api/bookmarks/[id]/server.test.ts new file mode 100644 index 00000000..9a5037be --- /dev/null +++ b/src/routes/(app)/api/bookmarks/[id]/server.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { bookmarksDb } = vi.hoisted(() => ({ + bookmarksDb: { updateBookmark: vi.fn(), deleteBookmark: vi.fn() } +})); + +vi.mock('$lib/server/dashboard/bookmarks-db.js', () => bookmarksDb); + +import { PATCH, DELETE } from './+server.js'; + +const BOOKMARK_ID = '3f1c2a9e-7b4d-4c8a-9e2f-5d6b7a8c9d01'; +const BOOKMARK = { + id: BOOKMARK_ID, + productId: 'trino', + name: 'Production Trino', + environment: 'prod', + url: 'https://trino.example.com', + pinned: true, + createdAt: '2026-10-27T10:00:00.000Z' +}; + +function mockEvent( + method: string, + body?: unknown, + { id = BOOKMARK_ID, user = { id: 'user-1' } as { id: string } | null } = {} +) { + const request = new Request(`http://localhost/api/bookmarks/${id}`, { + method, + ...(body === undefined ? {} : { body: typeof body === 'string' ? body : JSON.stringify(body) }) + }); + return { + request, + params: { id }, + locals: { user, logger: { debug: vi.fn(), info: vi.fn() } } + } as unknown as Parameters[0]; +} + +describe('/api/bookmarks/[id]', () => { + beforeEach(() => vi.clearAllMocks()); + + it('updates only the submitted fields of the user’s bookmark', async () => { + bookmarksDb.updateBookmark.mockResolvedValue(BOOKMARK); + + const response = await PATCH(mockEvent('PATCH', { pinned: true })); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual(BOOKMARK); + expect(bookmarksDb.updateBookmark).toHaveBeenCalledWith('user-1', BOOKMARK_ID, { + pinned: true + }); + }); + + it.each([ + ['malformed JSON', '{not json'], + ['no known fields', { unknown: true }], + ['an invalid URL', { url: 'ftp://example.com' }], + ['an unknown product', { productId: 'unknown' }] + ])('rejects an update with %s with 400', async (_name, body) => { + await expect(PATCH(mockEvent('PATCH', body))).rejects.toMatchObject({ status: 400 }); + expect(bookmarksDb.updateBookmark).not.toHaveBeenCalled(); + }); + + it('deletes the user’s bookmark', async () => { + bookmarksDb.deleteBookmark.mockResolvedValue(true); + + const response = await DELETE(mockEvent('DELETE')); + + expect(response.status).toBe(204); + expect(bookmarksDb.deleteBookmark).toHaveBeenCalledWith('user-1', BOOKMARK_ID); + }); + + it('answers 404 for bookmarks the user does not have', async () => { + bookmarksDb.updateBookmark.mockResolvedValue(null); + bookmarksDb.deleteBookmark.mockResolvedValue(false); + + await expect(PATCH(mockEvent('PATCH', { pinned: true }))).rejects.toMatchObject({ + status: 404 + }); + await expect(DELETE(mockEvent('DELETE'))).rejects.toMatchObject({ status: 404 }); + }); + + it('answers 400 for ids that are not UUIDs without querying', async () => { + await expect(DELETE(mockEvent('DELETE', undefined, { id: 'nope' }))).rejects.toMatchObject({ + status: 400 + }); + expect(bookmarksDb.deleteBookmark).not.toHaveBeenCalled(); + }); + + it('answers 401 without a signed-in user', async () => { + await expect(PATCH(mockEvent('PATCH', { pinned: true }, { user: null }))).rejects.toMatchObject( + { status: 401 } + ); + await expect(DELETE(mockEvent('DELETE', undefined, { user: null }))).rejects.toMatchObject({ + status: 401 + }); + }); +}); diff --git a/src/routes/(app)/api/bookmarks/server.test.ts b/src/routes/(app)/api/bookmarks/server.test.ts new file mode 100644 index 00000000..d085291b --- /dev/null +++ b/src/routes/(app)/api/bookmarks/server.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { bookmarksDb } = vi.hoisted(() => ({ + bookmarksDb: { listBookmarks: vi.fn(), createBookmark: vi.fn() } +})); + +vi.mock('$lib/server/dashboard/bookmarks-db.js', () => bookmarksDb); + +import { GET, POST } from './+server.js'; + +const FIELDS = { + productId: 'trino', + name: 'Production Trino', + environment: 'prod', + url: 'https://trino.example.com', + pinned: false +}; +const BOOKMARK = { + ...FIELDS, + id: '3f1c2a9e-7b4d-4c8a-9e2f-5d6b7a8c9d01', + createdAt: '2026-10-27T10:00:00.000Z' +}; + +function mockEvent(method: string, body?: unknown, user: { id: string } | null = { id: 'user-1' }) { + const request = new Request('http://localhost/api/bookmarks', { + method, + ...(body === undefined ? {} : { body: typeof body === 'string' ? body : JSON.stringify(body) }) + }); + return { + request, + locals: { user, logger: { debug: vi.fn(), info: vi.fn() } } + } as unknown as Parameters[0]; +} + +describe('/api/bookmarks', () => { + beforeEach(() => vi.clearAllMocks()); + + it('lists the requesting user’s bookmarks', async () => { + bookmarksDb.listBookmarks.mockResolvedValue([BOOKMARK]); + + const response = await GET(mockEvent('GET')); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual([BOOKMARK]); + expect(bookmarksDb.listBookmarks).toHaveBeenCalledWith('user-1'); + }); + + it('creates a bookmark from the trimmed form fields', async () => { + bookmarksDb.createBookmark.mockResolvedValue(BOOKMARK); + + const response = await POST( + mockEvent('POST', { ...FIELDS, name: ' Production Trino ', environment: ' prod ' }) + ); + + expect(response.status).toBe(201); + await expect(response.json()).resolves.toEqual(BOOKMARK); + expect(bookmarksDb.createBookmark).toHaveBeenCalledWith('user-1', FIELDS); + }); + + it.each([ + ['malformed JSON', '{not json'], + ['an empty name', { ...FIELDS, name: ' ' }], + ['an overlong name', { ...FIELDS, name: 'x'.repeat(41) }], + ['a non-http URL', { ...FIELDS, url: 'javascript:alert(1)' }], + ['a missing field', { name: 'Trino', url: 'https://trino.example.com' }], + ['an unknown product', { ...FIELDS, productId: 'unknown' }], + ['an empty product', { ...FIELDS, productId: '' }] + ])('rejects %s with 400', async (_name, body) => { + await expect(POST(mockEvent('POST', body))).rejects.toMatchObject({ status: 400 }); + expect(bookmarksDb.createBookmark).not.toHaveBeenCalled(); + }); + + it('answers 409 once the user has reached the bookmark limit', async () => { + bookmarksDb.createBookmark.mockResolvedValue(null); + + await expect(POST(mockEvent('POST', FIELDS))).rejects.toMatchObject({ status: 409 }); + }); + + it('answers 401 without a signed-in user', async () => { + await expect(GET(mockEvent('GET', undefined, null))).rejects.toMatchObject({ status: 401 }); + await expect(POST(mockEvent('POST', FIELDS, null))).rejects.toMatchObject({ status: 401 }); + expect(bookmarksDb.listBookmarks).not.toHaveBeenCalled(); + expect(bookmarksDb.createBookmark).not.toHaveBeenCalled(); + }); +});