From 8eaec588921e24bfec9976cc4ac7f21d49fc9430 Mon Sep 17 00:00:00 2001 From: Johannes Fleck Date: Tue, 27 Oct 2026 11:35:50 +0100 Subject: [PATCH 1/7] feat: limit bookmark field lengths (#277) Cap name (40), environment (100), URL (2048) and product id (64) in the bookmark form schema, and set matching maxlength on the dialog inputs so the form cannot submit values the upcoming bookmarks API rejects. --- src/lib/components/dashboard/AddBookmarkModal.svelte | 10 +++++++++- src/lib/dashboard/types.ts | 11 +++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/lib/components/dashboard/AddBookmarkModal.svelte b/src/lib/components/dashboard/AddBookmarkModal.svelte index 6fb16b65..4e1f8636 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/types.ts b/src/lib/dashboard/types.ts index 1bf89345..d985ec27 100644 --- a/src/lib/dashboard/types.ts +++ b/src/lib/dashboard/types.ts @@ -1,10 +1,13 @@ import { z } from 'zod'; +/** 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; + 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().max(64), + name: z.string().trim().min(1).max(BOOKMARK_MAX_LENGTH.name), + environment: z.string().max(BOOKMARK_MAX_LENGTH.environment), + url: z.url({ protocol: /^https?$/ }).max(BOOKMARK_MAX_LENGTH.url), pinned: z.boolean() }); From 97a64080a84e47d16e10ad2eb0bec5afcbeb91de Mon Sep 17 00:00:00 2001 From: Johannes Fleck Date: Tue, 27 Oct 2026 11:49:22 +0100 Subject: [PATCH 2/7] feat: add bookmarks API backed by the database (#277) Add a user_bookmarks table (migration 0004) and a /api/bookmarks REST API (GET/POST, PATCH/DELETE by id) scoped to the signed-in user. Requests without a user, e.g. with OIDC disabled, are answered with 401. Operations are counted in dashboard_bookmarks_total. The dashboard still uses localStorage; switching it to the API follows. --- e2e/bookmarks-api.spec.ts | 81 ++ src/lib/dashboard/types.ts | 7 + src/lib/server/auth-utils.ts | 11 + src/lib/server/dashboard/bookmarks-db.spec.ts | 102 +++ src/lib/server/dashboard/bookmarks-db.ts | 87 ++ src/lib/server/metrics.ts | 7 + .../server/migrations/0004_user_bookmarks.sql | 12 + .../server/migrations/meta/0004_snapshot.json | 786 ++++++++++++++++++ src/lib/server/migrations/meta/_journal.json | 7 + src/lib/server/schema.ts | 19 + src/routes/(app)/api/bookmarks/+server.ts | 43 + .../(app)/api/bookmarks/[id]/+server.ts | 61 ++ .../(app)/api/bookmarks/[id]/server.test.ts | 96 +++ src/routes/(app)/api/bookmarks/server.test.ts | 75 ++ 14 files changed, 1394 insertions(+) create mode 100644 e2e/bookmarks-api.spec.ts create mode 100644 src/lib/server/dashboard/bookmarks-db.spec.ts create mode 100644 src/lib/server/dashboard/bookmarks-db.ts create mode 100644 src/lib/server/migrations/0004_user_bookmarks.sql create mode 100644 src/lib/server/migrations/meta/0004_snapshot.json create mode 100644 src/routes/(app)/api/bookmarks/+server.ts create mode 100644 src/routes/(app)/api/bookmarks/[id]/+server.ts create mode 100644 src/routes/(app)/api/bookmarks/[id]/server.test.ts create mode 100644 src/routes/(app)/api/bookmarks/server.test.ts diff --git a/e2e/bookmarks-api.spec.ts b/e2e/bookmarks-api.spec.ts new file mode 100644 index 00000000..1b6e24e2 --- /dev/null +++ b/e2e/bookmarks-api.spec.ts @@ -0,0 +1,81 @@ +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', () => { + 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('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/src/lib/dashboard/types.ts b/src/lib/dashboard/types.ts index d985ec27..30f7ec55 100644 --- a/src/lib/dashboard/types.ts +++ b/src/lib/dashboard/types.ts @@ -18,6 +18,13 @@ export const bookmarkSchema = bookmarkFormSchema.extend({ export type Bookmark = z.infer; +/** Body of a bookmark update: any subset of the form fields, but at least one. */ +export const bookmarkUpdateSchema = bookmarkFormSchema + .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..a5adbc9a --- /dev/null +++ b/src/lib/server/dashboard/bookmarks-db.spec.ts @@ -0,0 +1,102 @@ +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 { 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() } +})); + +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 = '00000000-0000-0000-0000-000000000001'; +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.insert.mockReturnValue(chain); + await expect(createBookmark(USER_ID, FIELDS)).resolves.toEqual(BOOKMARK); + expect(mockDb.insert).toHaveBeenCalledWith(userBookmarks); + expect(chain.values).toHaveBeenCalledWith({ userId: USER_ID, ...FIELDS }); + }); + + 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..4d03d713 --- /dev/null +++ b/src/lib/server/dashboard/bookmarks-db.ts @@ -0,0 +1,87 @@ +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 type { Bookmark, 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. */ +export async function createBookmark( + userId: string, + bookmark: Omit +): Promise { + 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..6a65a3b8 --- /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 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..9ee5a302 --- /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", + "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..58d93726 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' }).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..0a81524d --- /dev/null +++ b/src/routes/(app)/api/bookmarks/+server.ts @@ -0,0 +1,43 @@ +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 { bookmarkFormSchema } 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' }); + locals.logger.debug( + { user_id: userId, bookmark_count: bookmarks.length }, + 'bookmarks returned' + ); + return Response.json(bookmarks); + } catch (err) { + dashboardBookmarksTotal.inc({ operation: 'list', outcome: 'error' }); + throw err; + } +}; + +/** Creates a bookmark for the current user. */ +export const POST: RequestHandler = async ({ request, locals }) => { + const userId = requireUserId(locals); + const parsed = bookmarkFormSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) throw error(400, 'Invalid bookmark'); + + try { + const bookmark = await createBookmark(userId, parsed.data); + 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 }); + } catch (err) { + dashboardBookmarksTotal.inc({ operation: 'create', outcome: 'error' }); + throw err; + } +}; 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..3bbf3fca --- /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 cannot exist and would fail the query. */ +function bookmarkIdFromParams(params: { id: string }): string { + const parsed = z.guid().safeParse(params.id); + if (!parsed.success) throw error(404, 'Bookmark not found'); + 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..1eac1434 --- /dev/null +++ b/src/routes/(app)/api/bookmarks/[id]/server.test.ts @@ -0,0 +1,96 @@ +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 = '00000000-0000-0000-0000-000000000001'; +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' }] + ])('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 404 for ids that are not UUIDs without querying', async () => { + await expect(DELETE(mockEvent('DELETE', undefined, { id: 'nope' }))).rejects.toMatchObject({ + status: 404 + }); + 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..3e8da0c8 --- /dev/null +++ b/src/routes/(app)/api/bookmarks/server.test.ts @@ -0,0 +1,75 @@ +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: '00000000-0000-0000-0000-000000000001', + 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 ' })); + + 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' }] + ])('rejects %s with 400', async (_name, body) => { + await expect(POST(mockEvent('POST', body))).rejects.toMatchObject({ status: 400 }); + expect(bookmarksDb.createBookmark).not.toHaveBeenCalled(); + }); + + 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(); + }); +}); From f6c53b98034c29c0a2bdb47953bc37e898ae5397 Mon Sep 17 00:00:00 2001 From: Johannes Fleck Date: Tue, 27 Oct 2026 15:27:12 +0100 Subject: [PATCH 3/7] test: keep the truncation E2E bookmark within the name limit (#277) --- e2e/bookmarks.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 }) => { From 293168064e4929469ecf5949e41999cd243ccd5f Mon Sep 17 00:00:00 2001 From: Johannes Fleck Date: Tue, 27 Oct 2026 16:21:46 +0100 Subject: [PATCH 4/7] fix: store bookmark creation times with time zone (#277) --- src/lib/server/migrations/0004_user_bookmarks.sql | 2 +- src/lib/server/migrations/meta/0004_snapshot.json | 2 +- src/lib/server/schema.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/server/migrations/0004_user_bookmarks.sql b/src/lib/server/migrations/0004_user_bookmarks.sql index 6a65a3b8..73eee411 100644 --- a/src/lib/server/migrations/0004_user_bookmarks.sql +++ b/src/lib/server/migrations/0004_user_bookmarks.sql @@ -6,7 +6,7 @@ CREATE TABLE "user_bookmarks" ( "environment" text NOT NULL, "url" text NOT NULL, "pinned" boolean DEFAULT false NOT NULL, - "created_at" timestamp DEFAULT now() 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 index 9ee5a302..95184a66 100644 --- a/src/lib/server/migrations/meta/0004_snapshot.json +++ b/src/lib/server/migrations/meta/0004_snapshot.json @@ -176,7 +176,7 @@ }, "created_at": { "name": "created_at", - "type": "timestamp", + "type": "timestamp with time zone", "primaryKey": false, "notNull": true, "default": "now()" diff --git a/src/lib/server/schema.ts b/src/lib/server/schema.ts index 58d93726..55d62404 100644 --- a/src/lib/server/schema.ts +++ b/src/lib/server/schema.ts @@ -106,7 +106,7 @@ export const userBookmarks = pgTable( environment: text('environment').notNull(), url: text('url').notNull(), pinned: boolean('pinned').notNull().default(false), - createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull() + createdAt: timestamp('created_at', { mode: 'date', withTimezone: true }).defaultNow().notNull() }, (table) => [index('user_bookmarks_user_id_idx').on(table.userId)] ); From d49d3774cb17b97ca799a1f5404bfc72a629db87 Mon Sep 17 00:00:00 2001 From: Johannes Fleck Date: Tue, 27 Oct 2026 16:22:13 +0100 Subject: [PATCH 5/7] refactor: drop the duplicate bookmark list log (#277) --- src/routes/(app)/api/bookmarks/+server.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/routes/(app)/api/bookmarks/+server.ts b/src/routes/(app)/api/bookmarks/+server.ts index 0a81524d..2e563f4e 100644 --- a/src/routes/(app)/api/bookmarks/+server.ts +++ b/src/routes/(app)/api/bookmarks/+server.ts @@ -11,10 +11,6 @@ export const GET: RequestHandler = async ({ locals }) => { try { const bookmarks = await listBookmarks(userId); dashboardBookmarksTotal.inc({ operation: 'list', outcome: 'success' }); - locals.logger.debug( - { user_id: userId, bookmark_count: bookmarks.length }, - 'bookmarks returned' - ); return Response.json(bookmarks); } catch (err) { dashboardBookmarksTotal.inc({ operation: 'list', outcome: 'error' }); From 387c78bcda6d2a47b168c09c2d3db5bfe3f589e3 Mon Sep 17 00:00:00 2001 From: Johannes Fleck Date: Tue, 27 Oct 2026 16:23:05 +0100 Subject: [PATCH 6/7] fix: validate bookmark product ids, ids and environments (#277) New bookmarks must name a known product; stored ones keep any non-empty id and show unknown ones as a custom link. Malformed bookmark ids are rejected with 400 like other id routes, and environments are trimmed. --- src/lib/dashboard/product-ids.ts | 19 +++++++++++++++++++ src/lib/dashboard/products.test.ts | 5 +++++ src/lib/dashboard/products.ts | 3 ++- src/lib/dashboard/types.ts | 17 +++++++++++++---- src/lib/server/dashboard/bookmarks-db.spec.ts | 2 +- src/routes/(app)/api/bookmarks/+server.ts | 4 ++-- .../(app)/api/bookmarks/[id]/+server.ts | 6 +++--- .../(app)/api/bookmarks/[id]/server.test.ts | 9 +++++---- src/routes/(app)/api/bookmarks/server.test.ts | 10 +++++++--- 9 files changed, 57 insertions(+), 18 deletions(-) create mode 100644 src/lib/dashboard/product-ids.ts 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 30f7ec55..ac6ce71f 100644 --- a/src/lib/dashboard/types.ts +++ b/src/lib/dashboard/types.ts @@ -1,12 +1,13 @@ 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; export const bookmarkFormSchema = z.object({ - productId: z.string().max(64), + productId: z.string().min(1).max(64), name: z.string().trim().min(1).max(BOOKMARK_MAX_LENGTH.name), - environment: z.string().max(BOOKMARK_MAX_LENGTH.environment), + environment: z.string().trim().max(BOOKMARK_MAX_LENGTH.environment), url: z.url({ protocol: /^https?$/ }).max(BOOKMARK_MAX_LENGTH.url), pinned: z.boolean() }); @@ -18,8 +19,16 @@ export const bookmarkSchema = bookmarkFormSchema.extend({ export type Bookmark = z.infer; -/** Body of a bookmark update: any subset of the form fields, but at least one. */ -export const bookmarkUpdateSchema = bookmarkFormSchema +/** + * 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' }); diff --git a/src/lib/server/dashboard/bookmarks-db.spec.ts b/src/lib/server/dashboard/bookmarks-db.spec.ts index a5adbc9a..74f5701b 100644 --- a/src/lib/server/dashboard/bookmarks-db.spec.ts +++ b/src/lib/server/dashboard/bookmarks-db.spec.ts @@ -41,7 +41,7 @@ function buildChain(result: unknown): MockChain { } const USER_ID = 'user-1'; -const BOOKMARK_ID = '00000000-0000-0000-0000-000000000001'; +const BOOKMARK_ID = '3f1c2a9e-7b4d-4c8a-9e2f-5d6b7a8c9d01'; const FIELDS = { productId: 'trino', name: 'Production Trino', diff --git a/src/routes/(app)/api/bookmarks/+server.ts b/src/routes/(app)/api/bookmarks/+server.ts index 2e563f4e..fbf8bd9c 100644 --- a/src/routes/(app)/api/bookmarks/+server.ts +++ b/src/routes/(app)/api/bookmarks/+server.ts @@ -2,7 +2,7 @@ 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 { bookmarkFormSchema } from '$lib/dashboard/types.js'; +import { bookmarkCreateSchema } from '$lib/dashboard/types.js'; import type { RequestHandler } from './$types'; /** Lists the current user's bookmarks. */ @@ -21,7 +21,7 @@ export const GET: RequestHandler = async ({ locals }) => { /** Creates a bookmark for the current user. */ export const POST: RequestHandler = async ({ request, locals }) => { const userId = requireUserId(locals); - const parsed = bookmarkFormSchema.safeParse(await request.json().catch(() => null)); + const parsed = bookmarkCreateSchema.safeParse(await request.json().catch(() => null)); if (!parsed.success) throw error(400, 'Invalid bookmark'); try { diff --git a/src/routes/(app)/api/bookmarks/[id]/+server.ts b/src/routes/(app)/api/bookmarks/[id]/+server.ts index 3bbf3fca..e2fee0cf 100644 --- a/src/routes/(app)/api/bookmarks/[id]/+server.ts +++ b/src/routes/(app)/api/bookmarks/[id]/+server.ts @@ -6,10 +6,10 @@ 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 cannot exist and would fail the query. */ +/** Bookmark ids are UUIDs; anything else would fail the query. */ function bookmarkIdFromParams(params: { id: string }): string { - const parsed = z.guid().safeParse(params.id); - if (!parsed.success) throw error(404, 'Bookmark not found'); + const parsed = z.uuid().safeParse(params.id); + if (!parsed.success) throw error(400, 'Invalid bookmark ID'); return parsed.data; } diff --git a/src/routes/(app)/api/bookmarks/[id]/server.test.ts b/src/routes/(app)/api/bookmarks/[id]/server.test.ts index 1eac1434..9a5037be 100644 --- a/src/routes/(app)/api/bookmarks/[id]/server.test.ts +++ b/src/routes/(app)/api/bookmarks/[id]/server.test.ts @@ -8,7 +8,7 @@ vi.mock('$lib/server/dashboard/bookmarks-db.js', () => bookmarksDb); import { PATCH, DELETE } from './+server.js'; -const BOOKMARK_ID = '00000000-0000-0000-0000-000000000001'; +const BOOKMARK_ID = '3f1c2a9e-7b4d-4c8a-9e2f-5d6b7a8c9d01'; const BOOKMARK = { id: BOOKMARK_ID, productId: 'trino', @@ -53,7 +53,8 @@ describe('/api/bookmarks/[id]', () => { it.each([ ['malformed JSON', '{not json'], ['no known fields', { unknown: true }], - ['an invalid URL', { url: 'ftp://example.com' }] + ['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(); @@ -78,9 +79,9 @@ describe('/api/bookmarks/[id]', () => { await expect(DELETE(mockEvent('DELETE'))).rejects.toMatchObject({ status: 404 }); }); - it('answers 404 for ids that are not UUIDs without querying', async () => { + it('answers 400 for ids that are not UUIDs without querying', async () => { await expect(DELETE(mockEvent('DELETE', undefined, { id: 'nope' }))).rejects.toMatchObject({ - status: 404 + status: 400 }); expect(bookmarksDb.deleteBookmark).not.toHaveBeenCalled(); }); diff --git a/src/routes/(app)/api/bookmarks/server.test.ts b/src/routes/(app)/api/bookmarks/server.test.ts index 3e8da0c8..4d975048 100644 --- a/src/routes/(app)/api/bookmarks/server.test.ts +++ b/src/routes/(app)/api/bookmarks/server.test.ts @@ -17,7 +17,7 @@ const FIELDS = { }; const BOOKMARK = { ...FIELDS, - id: '00000000-0000-0000-0000-000000000001', + id: '3f1c2a9e-7b4d-4c8a-9e2f-5d6b7a8c9d01', createdAt: '2026-10-27T10:00:00.000Z' }; @@ -48,7 +48,9 @@ describe('/api/bookmarks', () => { it('creates a bookmark from the trimmed form fields', async () => { bookmarksDb.createBookmark.mockResolvedValue(BOOKMARK); - const response = await POST(mockEvent('POST', { ...FIELDS, name: ' Production Trino ' })); + const response = await POST( + mockEvent('POST', { ...FIELDS, name: ' Production Trino ', environment: ' prod ' }) + ); expect(response.status).toBe(201); await expect(response.json()).resolves.toEqual(BOOKMARK); @@ -60,7 +62,9 @@ describe('/api/bookmarks', () => { ['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' }] + ['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(); From 46de70ee95e15f8cd45953ae263e3fd3309dac61 Mon Sep 17 00:00:00 2001 From: Johannes Fleck Date: Tue, 27 Oct 2026 16:23:11 +0100 Subject: [PATCH 7/7] feat: limit bookmarks to 25 per user (#277) Creating a bookmark beyond the limit is rejected with 409, so the list that every page loads stays small. --- e2e/bookmarks-api.spec.ts | 21 +++++++++++++++++ src/lib/dashboard/types.ts | 3 +++ src/lib/server/dashboard/bookmarks-db.spec.ts | 12 +++++++++- src/lib/server/dashboard/bookmarks-db.ts | 17 +++++++++++--- src/routes/(app)/api/bookmarks/+server.ts | 23 +++++++++++-------- src/routes/(app)/api/bookmarks/server.test.ts | 6 +++++ 6 files changed, 69 insertions(+), 13 deletions(-) diff --git a/e2e/bookmarks-api.spec.ts b/e2e/bookmarks-api.spec.ts index 1b6e24e2..2dd1502d 100644 --- a/e2e/bookmarks-api.spec.ts +++ b/e2e/bookmarks-api.spec.ts @@ -8,6 +8,8 @@ import { waitForHydration } from './support/helpers.js'; * 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', @@ -47,6 +49,25 @@ test.describe('Bookmarks API', () => { 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(); diff --git a/src/lib/dashboard/types.ts b/src/lib/dashboard/types.ts index ac6ce71f..391f2886 100644 --- a/src/lib/dashboard/types.ts +++ b/src/lib/dashboard/types.ts @@ -4,6 +4,9 @@ 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().min(1).max(64), name: z.string().trim().min(1).max(BOOKMARK_MAX_LENGTH.name), diff --git a/src/lib/server/dashboard/bookmarks-db.spec.ts b/src/lib/server/dashboard/bookmarks-db.spec.ts index 74f5701b..5e30f910 100644 --- a/src/lib/server/dashboard/bookmarks-db.spec.ts +++ b/src/lib/server/dashboard/bookmarks-db.spec.ts @@ -1,6 +1,7 @@ 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', () => ({ @@ -8,7 +9,7 @@ vi.mock('$lib/server/logging', () => ({ })); const { mockDb } = vi.hoisted(() => ({ - mockDb: { select: vi.fn(), insert: vi.fn(), update: vi.fn(), delete: vi.fn() } + mockDb: { select: vi.fn(), insert: vi.fn(), update: vi.fn(), delete: vi.fn(), $count: vi.fn() } })); vi.mock('$lib/server/db.js', () => ({ db: mockDb })); @@ -68,12 +69,21 @@ describe('bookmarks-db', () => { 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); diff --git a/src/lib/server/dashboard/bookmarks-db.ts b/src/lib/server/dashboard/bookmarks-db.ts index 4d03d713..b5a78ca1 100644 --- a/src/lib/server/dashboard/bookmarks-db.ts +++ b/src/lib/server/dashboard/bookmarks-db.ts @@ -2,7 +2,7 @@ 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 type { Bookmark, BookmarkUpdate } from '$lib/dashboard/types.js'; +import { BOOKMARK_LIMIT, type Bookmark, type BookmarkUpdate } from '$lib/dashboard/types.js'; const log = logger.child({ module: 'bookmarks-db' }); @@ -34,11 +34,22 @@ export async function listBookmarks(userId: string): Promise { return rows.map(toBookmark); } -/** Create a bookmark for a user and return it with its generated id and timestamp. */ +/** + * 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 { +): 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 }) diff --git a/src/routes/(app)/api/bookmarks/+server.ts b/src/routes/(app)/api/bookmarks/+server.ts index fbf8bd9c..051fd7a7 100644 --- a/src/routes/(app)/api/bookmarks/+server.ts +++ b/src/routes/(app)/api/bookmarks/+server.ts @@ -2,7 +2,7 @@ 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 { bookmarkCreateSchema } from '$lib/dashboard/types.js'; +import { BOOKMARK_LIMIT, bookmarkCreateSchema } from '$lib/dashboard/types.js'; import type { RequestHandler } from './$types'; /** Lists the current user's bookmarks. */ @@ -18,22 +18,27 @@ export const GET: RequestHandler = async ({ locals }) => { } }; -/** Creates a bookmark for the current user. */ +/** 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 { - const bookmark = await createBookmark(userId, parsed.data); - 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 }); + 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/server.test.ts b/src/routes/(app)/api/bookmarks/server.test.ts index 4d975048..d085291b 100644 --- a/src/routes/(app)/api/bookmarks/server.test.ts +++ b/src/routes/(app)/api/bookmarks/server.test.ts @@ -70,6 +70,12 @@ describe('/api/bookmarks', () => { 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 });