diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-06-01 20:46:41 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-06-01 20:46:41 +0100 |
| commit | ea1d0023bfee55358ebb1a96f3d06e783a219c0d (patch) | |
| tree | 5bddd451728cb7dd377574a9ea1ea591bca069c4 /packages/e2e_tests | |
| parent | 3afe1e21df6dcc0483e74e0db02d9d82af32ecea (diff) | |
| download | karakeep-ea1d0023bfee55358ebb1a96f3d06e783a219c0d.tar.zst | |
feat: Add support for public lists (#1511)
* WIP: public lists
* Drop viewing modes
* Add the public endpoint for assets
* regen the openapi spec
* proper handling for different asset types
* Add num bookmarks and a no bookmark banner
* Correctly set page title
* Add a not-found page
* merge the RSS and public list endpoints
* Add e2e tests for the public endpoints
* Redesign the share list modal
* Make NEXTAUTH_SECRET not required
* propery render text bookmarks
* rebase migration
* fix public token tests
* Add more tests
Diffstat (limited to 'packages/e2e_tests')
| -rw-r--r-- | packages/e2e_tests/docker-compose.yml | 1 | ||||
| -rw-r--r-- | packages/e2e_tests/tests/api/public.test.ts | 322 | ||||
| -rw-r--r-- | packages/e2e_tests/vitest.config.ts | 3 |
3 files changed, 326 insertions, 0 deletions
diff --git a/packages/e2e_tests/docker-compose.yml b/packages/e2e_tests/docker-compose.yml index 201db154..e1fe46bb 100644 --- a/packages/e2e_tests/docker-compose.yml +++ b/packages/e2e_tests/docker-compose.yml @@ -10,6 +10,7 @@ services: environment: DATA_DIR: /tmp NEXTAUTH_SECRET: secret + NEXTAUTH_URL: http://localhost:${KARAKEEP_PORT:-3000} MEILI_MASTER_KEY: dummy MEILI_ADDR: http://meilisearch:7700 BROWSER_WEB_URL: http://chrome:9222 diff --git a/packages/e2e_tests/tests/api/public.test.ts b/packages/e2e_tests/tests/api/public.test.ts new file mode 100644 index 00000000..54ef79ea --- /dev/null +++ b/packages/e2e_tests/tests/api/public.test.ts @@ -0,0 +1,322 @@ +import { assert, beforeEach, describe, expect, inject, it } from "vitest"; +import { z } from "zod"; + +import { createSignedToken } from "../../../shared/signedTokens"; +import { zAssetSignedTokenSchema } from "../../../shared/types/assets"; +import { BookmarkTypes } from "../../../shared/types/bookmarks"; +import { createTestUser, uploadTestAsset } from "../../utils/api"; +import { waitUntil } from "../../utils/general"; +import { getTrpcClient } from "../../utils/trpc"; + +describe("Public API", () => { + const port = inject("karakeepPort"); + + if (!port) { + throw new Error("Missing required environment variables"); + } + + let apiKey: string; // For the primary test user + + async function seedDatabase(currentApiKey: string) { + const trpcClient = getTrpcClient(currentApiKey); + + // Create two lists + const publicList = await trpcClient.lists.create.mutate({ + name: "Public List", + icon: "🚀", + type: "manual", + }); + + await trpcClient.lists.edit.mutate({ + listId: publicList.id, + public: true, + }); + + // Create two bookmarks + const createBookmark1 = await trpcClient.bookmarks.createBookmark.mutate({ + title: "Test Bookmark #1", + url: "http://nginx:80/hello.html", + type: BookmarkTypes.LINK, + }); + + // Create a second bookmark with an asset + const file = new File(["test content"], "test.pdf", { + type: "application/pdf", + }); + + const uploadResponse = await uploadTestAsset(currentApiKey, port, file); + const createBookmark2 = await trpcClient.bookmarks.createBookmark.mutate({ + title: "Test Bookmark #2", + type: BookmarkTypes.ASSET, + assetType: "pdf", + assetId: uploadResponse.assetId, + }); + + await trpcClient.lists.addToList.mutate({ + listId: publicList.id, + bookmarkId: createBookmark1.id, + }); + await trpcClient.lists.addToList.mutate({ + listId: publicList.id, + bookmarkId: createBookmark2.id, + }); + + return { publicList, createBookmark1, createBookmark2 }; + } + + beforeEach(async () => { + apiKey = await createTestUser(); + }); + + it("should get public bookmarks", async () => { + const { publicList } = await seedDatabase(apiKey); + const trpcClient = getTrpcClient(apiKey); + + const res = await trpcClient.publicBookmarks.getPublicBookmarksInList.query( + { + listId: publicList.id, + }, + ); + + expect(res.bookmarks.length).toBe(2); + }); + + it("should be able to access the assets of the public bookmarks", async () => { + const { publicList, createBookmark1, createBookmark2 } = + await seedDatabase(apiKey); + + const trpcClient = getTrpcClient(apiKey); + // Wait for link bookmark to be crawled and have a banner image (screenshot) + await waitUntil( + async () => { + const res = await trpcClient.bookmarks.getBookmark.query({ + bookmarkId: createBookmark1.id, + }); + assert(res.content.type === BookmarkTypes.LINK); + // Check for screenshotAssetId as bannerImageUrl might be derived from it or original imageUrl + return !!res.content.screenshotAssetId || !!res.content.imageUrl; + }, + "Bookmark is crawled and has banner info", + 20000, // Increased timeout as crawling can take time + ); + + const res = await trpcClient.publicBookmarks.getPublicBookmarksInList.query( + { + listId: publicList.id, + }, + ); + + const b1Resp = res.bookmarks.find((b) => b.id === createBookmark1.id); + expect(b1Resp).toBeDefined(); + const b2Resp = res.bookmarks.find((b) => b.id === createBookmark2.id); + expect(b2Resp).toBeDefined(); + + assert(b1Resp!.content.type === BookmarkTypes.LINK); + assert(b2Resp!.content.type === BookmarkTypes.ASSET); + + { + // Banner image fetch for link bookmark + assert( + b1Resp!.bannerImageUrl, + "Link bookmark should have a bannerImageUrl", + ); + const assetFetch = await fetch(b1Resp!.bannerImageUrl); + expect(assetFetch.status).toBe(200); + } + + { + // Actual asset fetch for asset bookmark + assert( + b2Resp!.content.assetUrl, + "Asset bookmark should have an assetUrl", + ); + const assetFetch = await fetch(b2Resp!.content.assetUrl); + expect(assetFetch.status).toBe(200); + } + }); + + it("Accessing non public list should fail", async () => { + const trpcClient = getTrpcClient(apiKey); + const nonPublicList = await trpcClient.lists.create.mutate({ + name: "Non Public List", + icon: "🚀", + type: "manual", + }); + + await expect( + trpcClient.publicBookmarks.getPublicBookmarksInList.query({ + listId: nonPublicList.id, + }), + ).rejects.toThrow(/List not found/); + }); + + describe("Public asset token validation", () => { + let userId: string; + let assetId: string; // Asset belonging to the primary user (userId) + + beforeEach(async () => { + const trpcClient = getTrpcClient(apiKey); + const whoami = await trpcClient.users.whoami.query(); + userId = whoami.id; + const assetUpload = await uploadTestAsset( + apiKey, + port, + new File(["test content for token validation"], "token_test.pdf", { + type: "application/pdf", + }), + ); + assetId = assetUpload.assetId; + }); + + it("should succeed with a valid token", async () => { + const token = createSignedToken( + { + assetId, + userId, + } as z.infer<typeof zAssetSignedTokenSchema>, + Date.now() + 60000, // Expires in 60 seconds + ); + const res = await fetch( + `http://localhost:${port}/api/public/assets/${assetId}?token=${token}`, + ); + expect(res.status).toBe(200); + expect((await res.blob()).type).toBe("application/pdf"); + }); + + it("should fail without a token", async () => { + const res = await fetch( + `http://localhost:${port}/api/public/assets/${assetId}`, + ); + expect(res.status).toBe(400); // Bad Request due to missing token query param + }); + + it("should fail with a malformed token string (e.g., not base64)", async () => { + const res = await fetch( + `http://localhost:${port}/api/public/assets/${assetId}?token=thisIsNotValidBase64!@#`, + ); + expect(res.status).toBe(403); + expect(await res.json()).toEqual( + expect.objectContaining({ error: "Invalid or expired token" }), + ); + }); + + it("should fail with a token having a structurally invalid inner payload", async () => { + // Payload that doesn't conform to zAssetSignedTokenSchema (e.g. misspelled key) + const malformedInnerPayload = { + asset_id_mispelled: assetId, + userId: userId, + }; + const token = createSignedToken( + malformedInnerPayload, + Date.now() + 60000, + ); + const res = await fetch( + `http://localhost:${port}/api/public/assets/${assetId}?token=${token}`, + ); + expect(res.status).toBe(403); + expect(await res.json()).toEqual( + expect.objectContaining({ error: "Invalid or expired token" }), + ); + }); + + it("should fail after token expiry", async () => { + const token = createSignedToken( + { + assetId, + userId, + } as z.infer<typeof zAssetSignedTokenSchema>, + Date.now() + 1000, // Expires in 1 second + ); + + // Wait for more than 1 second to ensure expiry + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const res = await fetch( + `http://localhost:${port}/api/public/assets/${assetId}?token=${token}`, + ); + expect(res.status).toBe(403); + expect(await res.json()).toEqual( + expect.objectContaining({ error: "Invalid or expired token" }), + ); + }); + + it("should fail when using a valid token for a different asset", async () => { + const anotherAssetUpload = await uploadTestAsset( + apiKey, // Same user + port, + new File(["other content"], "other_asset.pdf", { + type: "application/pdf", + }), + ); + const anotherAssetId = anotherAssetUpload.assetId; + + // Token is valid for 'anotherAssetId' + const tokenForAnotherAsset = createSignedToken( + { + assetId: anotherAssetId, + userId, + } as z.infer<typeof zAssetSignedTokenSchema>, + Date.now() + 60000, + ); + + // Attempt to use this token to access the original 'assetId' + const res = await fetch( + `http://localhost:${port}/api/public/assets/${assetId}?token=${tokenForAnotherAsset}`, + ); + expect(res.status).toBe(403); + expect(await res.json()).toEqual( + expect.objectContaining({ error: "Invalid or expired token" }), + ); + }); + + it("should fail if token's userId does not own the requested assetId (expect 404)", async () => { + // User1 (primary, `apiKey`, `userId`) owns `assetId` (from beforeEach) + + // Create User2 - ensure unique email for user creation + const apiKeyUser2 = await createTestUser(); + const trpcClientUser2 = getTrpcClient(apiKeyUser2); + const whoamiUser2 = await trpcClientUser2.users.whoami.query(); + const userIdUser2 = whoamiUser2.id; + + // Generate a token where the payload claims assetId is being accessed by userIdUser2, + // but assetId actually belongs to the original userId. + const tokenForUser2AttemptingAsset1 = createSignedToken( + { + assetId: assetId, // assetId belongs to user1 (userId) + userId: userIdUser2, // token claims user2 is accessing it + } as z.infer<typeof zAssetSignedTokenSchema>, + Date.now() + 60000, + ); + + // User2 attempts to access assetId (owned by User1) using a token that has User2's ID in its payload. + // The API route will use userIdUser2 from the token to query the DB for assetId. + // Since assetId is not owned by userIdUser2, the DB query will find nothing. + const res = await fetch( + `http://localhost:${port}/api/public/assets/${assetId}?token=${tokenForUser2AttemptingAsset1}`, + ); + expect(res.status).toBe(404); + expect(await res.json()).toEqual( + expect.objectContaining({ error: "Asset not found" }), + ); + }); + + it("should fail for a token referencing a non-existent assetId (expect 404)", async () => { + const nonExistentAssetId = `nonexistent-asset-${Date.now()}`; + const token = createSignedToken( + { + assetId: nonExistentAssetId, + userId, // Valid userId from the primary user + } as z.infer<typeof zAssetSignedTokenSchema>, + Date.now() + 60000, + ); + + const res = await fetch( + `http://localhost:${port}/api/public/assets/${nonExistentAssetId}?token=${token}`, + ); + expect(res.status).toBe(404); + expect(await res.json()).toEqual( + expect.objectContaining({ error: "Asset not found" }), + ); + }); + }); +}); diff --git a/packages/e2e_tests/vitest.config.ts b/packages/e2e_tests/vitest.config.ts index bb1c7ea4..2735f1e2 100644 --- a/packages/e2e_tests/vitest.config.ts +++ b/packages/e2e_tests/vitest.config.ts @@ -14,5 +14,8 @@ export default defineConfig({ teardownTimeout: 30000, include: ["tests/**/*.test.ts"], testTimeout: 60000, + env: { + NEXTAUTH_SECRET: "secret", + }, }, }); |
