import { beforeEach, describe, expect, test } from "vitest"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; import type { APICallerType, CustomTestContext } from "../testUtils"; import { defaultBeforeEach } from "../testUtils"; beforeEach(defaultBeforeEach(true)); /** * Helper function to add a collaborator and have them accept the invitation */ async function addAndAcceptCollaborator( ownerApi: APICallerType, collaboratorApi: APICallerType, listId: string, role: "viewer" | "editor", ) { const collaboratorUser = await collaboratorApi.users.whoami(); // Owner invites the collaborator const { invitationId } = await ownerApi.lists.addCollaborator({ listId, email: collaboratorUser.email!, role, }); // Collaborator accepts the invitation await collaboratorApi.lists.acceptInvitation({ invitationId, }); } describe("Shared Lists", () => { describe("List Collaboration Management", () => { test("should allow owner to add a collaborator by email", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; // Create a list as owner const list = await ownerApi.lists.create({ name: "Test Shared List", icon: "📚", type: "manual", }); // Get collaborator email const collaboratorUser = await collaboratorApi.users.whoami(); const collaboratorEmail = collaboratorUser.email!; // Add collaborator (creates pending invitation) await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "viewer", ); // Verify collaborator was added const { collaborators, owner } = await ownerApi.lists.getCollaborators({ listId: list.id, }); expect(collaborators).toHaveLength(1); expect(collaborators[0].user.email).toBe(collaboratorEmail); expect(collaborators[0].role).toBe("viewer"); // Verify owner is included const ownerUser = await ownerApi.users.whoami(); expect(owner).toBeDefined(); expect(owner?.email).toBe(ownerUser.email); }); test("should not allow adding owner as collaborator", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); const ownerUser = await ownerApi.users.whoami(); await expect( ownerApi.lists.addCollaborator({ listId: list.id, email: ownerUser.email!, role: "viewer", }), ).rejects.toThrow("Cannot add the list owner as a collaborator"); }); test("should not allow adding duplicate collaborator", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; const { invitationId } = await ownerApi.lists.addCollaborator({ listId: list.id, email: collaboratorEmail, role: "viewer", }); // Try to add same collaborator again (should fail - pending invitation exists) await expect( ownerApi.lists.addCollaborator({ listId: list.id, email: collaboratorEmail, role: "editor", }), ).rejects.toThrow("User already has a pending invitation for this list"); // Accept the invitation await collaboratorApi.lists.acceptInvitation({ invitationId, }); // Try to add them again after they're a collaborator await expect( ownerApi.lists.addCollaborator({ listId: list.id, email: collaboratorEmail, role: "editor", }), ).rejects.toThrow("User is already a collaborator on this list"); }); test("should allow owner to update collaborator role", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); const collaboratorUser = await collaboratorApi.users.whoami(); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "viewer", ); // Update role to editor await ownerApi.lists.updateCollaboratorRole({ listId: list.id, userId: collaboratorUser.id, role: "editor", }); // Verify role was updated const { collaborators, owner } = await ownerApi.lists.getCollaborators({ listId: list.id, }); expect(collaborators[0].role).toBe("editor"); expect(owner).toBeDefined(); }); test("should allow owner to remove collaborator", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); const collaboratorUser = await collaboratorApi.users.whoami(); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "viewer", ); // Remove collaborator await ownerApi.lists.removeCollaborator({ listId: list.id, userId: collaboratorUser.id, }); // Verify collaborator was removed const { collaborators, owner } = await ownerApi.lists.getCollaborators({ listId: list.id, }); expect(collaborators).toHaveLength(0); expect(owner).toBeDefined(); }); test("should include owner information in getCollaborators response", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); const ownerUser = await ownerApi.users.whoami(); const { collaborators, owner } = await ownerApi.lists.getCollaborators({ listId: list.id, }); // Verify owner information is present expect(owner).toBeDefined(); expect(owner?.id).toBe(ownerUser.id); expect(owner?.name).toBe(ownerUser.name); expect(owner?.email).toBe(ownerUser.email); // List with no collaborators should still have owner expect(collaborators).toHaveLength(0); }); test("should remove collaborator's bookmarks when they are removed", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); // Owner adds a bookmark const ownerBookmark = await ownerApi.bookmarks.createBookmark({ type: BookmarkTypes.TEXT, text: "Owner's bookmark", }); await ownerApi.lists.addToList({ listId: list.id, bookmarkId: ownerBookmark.id, }); const collaboratorUser = await collaboratorApi.users.whoami(); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "editor", ); // Collaborator adds their own bookmark const collabBookmark = await collaboratorApi.bookmarks.createBookmark({ type: BookmarkTypes.TEXT, text: "Collaborator's bookmark", }); await collaboratorApi.lists.addToList({ listId: list.id, bookmarkId: collabBookmark.id, }); // Verify both bookmarks are in the list const bookmarksBefore = await ownerApi.bookmarks.getBookmarks({ listId: list.id, }); expect(bookmarksBefore.bookmarks).toHaveLength(2); // Remove collaborator await ownerApi.lists.removeCollaborator({ listId: list.id, userId: collaboratorUser.id, }); // Verify only owner's bookmark remains in the list const bookmarksAfter = await ownerApi.bookmarks.getBookmarks({ listId: list.id, }); expect(bookmarksAfter.bookmarks).toHaveLength(1); expect(bookmarksAfter.bookmarks[0].id).toBe(ownerBookmark.id); // Verify collaborator's bookmark still exists (just not in the list) const collabBookmarkStillExists = await collaboratorApi.bookmarks.getBookmark({ bookmarkId: collabBookmark.id, }); expect(collabBookmarkStillExists.id).toBe(collabBookmark.id); }); test("should allow collaborator to leave list", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "viewer", ); // Collaborator leaves the list await collaboratorApi.lists.leaveList({ listId: list.id, }); // Verify collaborator was removed const { collaborators, owner } = await ownerApi.lists.getCollaborators({ listId: list.id, }); expect(collaborators).toHaveLength(0); expect(owner).toBeDefined(); // Verify list no longer appears in shared lists const { lists: allLists } = await collaboratorApi.lists.list(); const sharedLists = allLists.filter( (l) => l.userRole === "viewer" || l.userRole === "editor", ); expect(sharedLists.find((l) => l.id === list.id)).toBeUndefined(); }); test("should remove collaborator's bookmarks when they leave list", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); // Owner adds a bookmark const ownerBookmark = await ownerApi.bookmarks.createBookmark({ type: BookmarkTypes.TEXT, text: "Owner's bookmark", }); await ownerApi.lists.addToList({ listId: list.id, bookmarkId: ownerBookmark.id, }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "editor", ); // Collaborator adds their own bookmark const collabBookmark = await collaboratorApi.bookmarks.createBookmark({ type: BookmarkTypes.TEXT, text: "Collaborator's bookmark", }); await collaboratorApi.lists.addToList({ listId: list.id, bookmarkId: collabBookmark.id, }); // Verify both bookmarks are in the list const bookmarksBefore = await ownerApi.bookmarks.getBookmarks({ listId: list.id, }); expect(bookmarksBefore.bookmarks).toHaveLength(2); // Collaborator leaves the list await collaboratorApi.lists.leaveList({ listId: list.id, }); // Verify only owner's bookmark remains in the list const bookmarksAfter = await ownerApi.bookmarks.getBookmarks({ listId: list.id, }); expect(bookmarksAfter.bookmarks).toHaveLength(1); expect(bookmarksAfter.bookmarks[0].id).toBe(ownerBookmark.id); // Verify collaborator's bookmark still exists (just not in the list) const collabBookmarkStillExists = await collaboratorApi.bookmarks.getBookmark({ bookmarkId: collabBookmark.id, }); expect(collabBookmarkStillExists.id).toBe(collabBookmark.id); }); test("should not allow owner to leave their own list", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); await expect( ownerApi.lists.leaveList({ listId: list.id, }), ).rejects.toThrow( "List owners cannot leave their own list. Delete the list instead.", ); }); test("should not allow non-collaborator to manage collaborators", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const thirdUserApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); const thirdUser = await thirdUserApi.users.whoami(); // Third user tries to add themselves as collaborator await expect( thirdUserApi.lists.addCollaborator({ listId: list.id, email: thirdUser.email!, role: "viewer", }), ).rejects.toThrow("List not found"); }); }); describe("List Access and Visibility", () => { test("should show shared list in list endpoint", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "viewer", ); const { lists: allLists } = await collaboratorApi.lists.list(); const sharedLists = allLists.filter( (l) => l.userRole === "viewer" || l.userRole === "editor", ); expect(sharedLists).toHaveLength(1); expect(sharedLists[0].id).toBe(list.id); expect(sharedLists[0].name).toBe("Shared List"); }); test("should allow collaborator to get list details", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "viewer", ); const retrievedList = await collaboratorApi.lists.get({ listId: list.id, }); expect(retrievedList.id).toBe(list.id); expect(retrievedList.name).toBe("Shared List"); expect(retrievedList.userRole).toBe("viewer"); }); test("should not allow non-collaborator to access list", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const thirdUserApi = apiCallers[2]; const list = await ownerApi.lists.create({ name: "Private List", icon: "📚", type: "manual", }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "viewer", ); await expect( thirdUserApi.lists.get({ listId: list.id, }), ).rejects.toThrow("List not found"); }); test("should show correct userRole for owner", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const list = await ownerApi.lists.create({ name: "My List", icon: "📚", type: "manual", }); const retrievedList = await ownerApi.lists.get({ listId: list.id, }); expect(retrievedList.userRole).toBe("owner"); }); test("should show correct userRole for editor", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "editor", ); const retrievedList = await collaboratorApi.lists.get({ listId: list.id, }); expect(retrievedList.userRole).toBe("editor"); }); }); describe("Bookmark Access in Shared Lists", () => { test("should allow collaborator to view bookmarks in shared list", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; // Owner creates list and bookmark const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); const bookmark = await ownerApi.bookmarks.createBookmark({ type: BookmarkTypes.TEXT, text: "Shared bookmark", }); await ownerApi.lists.addToList({ listId: list.id, bookmarkId: bookmark.id, }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "viewer", ); // Collaborator fetches bookmarks from shared list const bookmarks = await collaboratorApi.bookmarks.getBookmarks({ listId: list.id, }); expect(bookmarks.bookmarks).toHaveLength(1); expect(bookmarks.bookmarks[0].id).toBe(bookmark.id); }); test("should hide owner-specific bookmark state from collaborators", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); const bookmark = await ownerApi.bookmarks.createBookmark({ type: BookmarkTypes.TEXT, text: "Shared bookmark", }); await ownerApi.bookmarks.updateBookmark({ bookmarkId: bookmark.id, archived: true, favourited: true, note: "Private note", }); await ownerApi.lists.addToList({ listId: list.id, bookmarkId: bookmark.id, }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "viewer", ); const ownerView = await ownerApi.bookmarks.getBookmarks({ listId: list.id, }); const collaboratorView = await collaboratorApi.bookmarks.getBookmarks({ listId: list.id, }); const ownerBookmark = ownerView.bookmarks.find( (b) => b.id === bookmark.id, ); expect(ownerBookmark?.favourited).toBe(true); expect(ownerBookmark?.archived).toBe(true); expect(ownerBookmark?.note).toBe("Private note"); const collaboratorBookmark = collaboratorView.bookmarks.find( (b) => b.id === bookmark.id, ); expect(collaboratorBookmark?.favourited).toBe(false); expect(collaboratorBookmark?.archived).toBe(false); expect(collaboratorBookmark?.note).toBeNull(); }); // Note: Asset handling for shared bookmarks is tested via the REST API in e2e tests // This is because tRPC tests don't have easy access to file upload functionality test("should allow collaborator to view individual shared bookmark", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); const bookmark = await ownerApi.bookmarks.createBookmark({ type: BookmarkTypes.TEXT, text: "Shared bookmark", }); await ownerApi.lists.addToList({ listId: list.id, bookmarkId: bookmark.id, }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "viewer", ); // Collaborator gets individual bookmark const response = await collaboratorApi.bookmarks.getBookmark({ bookmarkId: bookmark.id, }); expect(response.id).toBe(bookmark.id); }); test("should not show shared bookmarks on collaborator's homepage", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); const sharedBookmark = await ownerApi.bookmarks.createBookmark({ type: BookmarkTypes.TEXT, text: "Shared bookmark", }); await ownerApi.lists.addToList({ listId: list.id, bookmarkId: sharedBookmark.id, }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "viewer", ); // Collaborator creates their own bookmark const ownBookmark = await collaboratorApi.bookmarks.createBookmark({ type: BookmarkTypes.TEXT, text: "My own bookmark", }); // Fetch all bookmarks (no listId filter) const allBookmarks = await collaboratorApi.bookmarks.getBookmarks({}); // Should only see own bookmark, not shared one expect(allBookmarks.bookmarks).toHaveLength(1); expect(allBookmarks.bookmarks[0].id).toBe(ownBookmark.id); }); test("should not allow non-collaborator to access shared bookmark", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const thirdUserApi = apiCallers[2]; // User 3 will be the non-collaborator const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); const bookmark = await ownerApi.bookmarks.createBookmark({ type: BookmarkTypes.TEXT, text: "Shared bookmark", }); await ownerApi.lists.addToList({ listId: list.id, bookmarkId: bookmark.id, }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "viewer", ); // Don't add thirdUserApi as a collaborator // Third user tries to access the bookmark await expect( thirdUserApi.bookmarks.getBookmark({ bookmarkId: bookmark.id, }), ).rejects.toThrow("Bookmark not found"); }); test("should show all bookmarks in shared list regardless of owner", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); // Owner adds a bookmark const ownerBookmark = await ownerApi.bookmarks.createBookmark({ type: BookmarkTypes.TEXT, text: "Owner's bookmark", }); await ownerApi.lists.addToList({ listId: list.id, bookmarkId: ownerBookmark.id, }); // Share list with collaborator as editor await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "editor", ); // Collaborator adds their own bookmark const collabBookmark = await collaboratorApi.bookmarks.createBookmark({ type: BookmarkTypes.TEXT, text: "Collaborator's bookmark", }); await collaboratorApi.lists.addToList({ listId: list.id, bookmarkId: collabBookmark.id, }); // Both users should see both bookmarks in the list const ownerView = await ownerApi.bookmarks.getBookmarks({ listId: list.id, }); const collabView = await collaboratorApi.bookmarks.getBookmarks({ listId: list.id, }); expect(ownerView.bookmarks).toHaveLength(2); expect(collabView.bookmarks).toHaveLength(2); }); }); describe("Bookmark Editing Permissions", () => { test("should not allow viewer to add bookmarks to list", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "viewer", ); // Viewer creates their own bookmark const bookmark = await collaboratorApi.bookmarks.createBookmark({ type: BookmarkTypes.TEXT, text: "My bookmark", }); // Viewer tries to add it to shared list await expect( collaboratorApi.lists.addToList({ listId: list.id, bookmarkId: bookmark.id, }), ).rejects.toThrow("User is not allowed to edit this list"); }); test("should allow editor to add bookmarks to list", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "editor", ); // Editor creates their own bookmark const bookmark = await collaboratorApi.bookmarks.createBookmark({ type: BookmarkTypes.TEXT, text: "My bookmark", }); // Editor adds it to shared list await collaboratorApi.lists.addToList({ listId: list.id, bookmarkId: bookmark.id, }); // Verify bookmark was added const bookmarks = await ownerApi.bookmarks.getBookmarks({ listId: list.id, }); expect(bookmarks.bookmarks).toHaveLength(1); expect(bookmarks.bookmarks[0].id).toBe(bookmark.id); }); test("should not allow viewer to remove bookmarks from list", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); const bookmark = await ownerApi.bookmarks.createBookmark({ type: BookmarkTypes.TEXT, text: "Test bookmark", }); await ownerApi.lists.addToList({ listId: list.id, bookmarkId: bookmark.id, }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "viewer", ); // Viewer tries to remove bookmark await expect( collaboratorApi.lists.removeFromList({ listId: list.id, bookmarkId: bookmark.id, }), ).rejects.toThrow("User is not allowed to edit this list"); }); test("should allow editor to remove bookmarks from list", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); const bookmark = await ownerApi.bookmarks.createBookmark({ type: BookmarkTypes.TEXT, text: "Test bookmark", }); await ownerApi.lists.addToList({ listId: list.id, bookmarkId: bookmark.id, }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "editor", ); // Editor removes bookmark await collaboratorApi.lists.removeFromList({ listId: list.id, bookmarkId: bookmark.id, }); // Verify bookmark was removed const bookmarks = await ownerApi.bookmarks.getBookmarks({ listId: list.id, }); expect(bookmarks.bookmarks).toHaveLength(0); }); test("should not allow collaborator to edit bookmark they don't own", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); const bookmark = await ownerApi.bookmarks.createBookmark({ type: BookmarkTypes.TEXT, text: "Owner's bookmark", }); await ownerApi.lists.addToList({ listId: list.id, bookmarkId: bookmark.id, }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "editor", ); // Collaborator tries to edit owner's bookmark await expect( collaboratorApi.bookmarks.updateBookmark({ bookmarkId: bookmark.id, title: "Modified title", }), ).rejects.toThrow("User is not allowed to access resource"); }); test("should not allow collaborator to delete bookmark they don't own", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); const bookmark = await ownerApi.bookmarks.createBookmark({ type: BookmarkTypes.TEXT, text: "Owner's bookmark", }); await ownerApi.lists.addToList({ listId: list.id, bookmarkId: bookmark.id, }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "editor", ); // Collaborator tries to delete owner's bookmark await expect( collaboratorApi.bookmarks.deleteBookmark({ bookmarkId: bookmark.id, }), ).rejects.toThrow("User is not allowed to access resource"); }); }); describe("List Management Permissions", () => { test("should not allow collaborator to edit list metadata", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "editor", ); // Collaborator tries to edit list await expect( collaboratorApi.lists.edit({ listId: list.id, name: "Modified Name", }), ).rejects.toThrow("User is not allowed to manage this list"); }); test("should not allow collaborator to delete list", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "editor", ); // Collaborator tries to delete list await expect( collaboratorApi.lists.delete({ listId: list.id, }), ).rejects.toThrow("User is not allowed to manage this list"); }); test("should not allow collaborator to manage other collaborators", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const thirdUserApi = apiCallers[2]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "editor", ); const thirdUserEmail = (await thirdUserApi.users.whoami()).email!; // Collaborator tries to add another user await expect( collaboratorApi.lists.addCollaborator({ listId: list.id, email: thirdUserEmail, role: "viewer", }), ).rejects.toThrow("User is not allowed to manage this list"); }); test("should only allow collaborators to view collaborator list", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const thirdUserApi = apiCallers[2]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "viewer", ); // Collaborator can view collaborators const { collaborators, owner } = await collaboratorApi.lists.getCollaborators({ listId: list.id, }); expect(collaborators).toHaveLength(1); expect(owner).toBeDefined(); // Non-collaborator cannot view await expect( thirdUserApi.lists.getCollaborators({ listId: list.id, }), ).rejects.toThrow("List not found"); }); }); describe("Access After Removal", () => { test("should revoke access after removing collaborator", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); const bookmark = await ownerApi.bookmarks.createBookmark({ type: BookmarkTypes.TEXT, text: "Shared bookmark", }); await ownerApi.lists.addToList({ listId: list.id, bookmarkId: bookmark.id, }); const collaboratorUser = await collaboratorApi.users.whoami(); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "viewer", ); // Verify collaborator has access to list const bookmarksBefore = await collaboratorApi.bookmarks.getBookmarks({ listId: list.id, }); expect(bookmarksBefore.bookmarks).toHaveLength(1); // Verify collaborator has access to individual bookmark const bookmarkBefore = await collaboratorApi.bookmarks.getBookmark({ bookmarkId: bookmark.id, }); expect(bookmarkBefore.id).toBe(bookmark.id); // Remove collaborator await ownerApi.lists.removeCollaborator({ listId: list.id, userId: collaboratorUser.id, }); // Verify list access is revoked await expect( collaboratorApi.bookmarks.getBookmarks({ listId: list.id, }), ).rejects.toThrow("List not found"); // Verify bookmark access is revoked await expect( collaboratorApi.bookmarks.getBookmark({ bookmarkId: bookmark.id, }), ).rejects.toThrow("Bookmark not found"); }); test("should revoke access after leaving list", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "viewer", ); // Collaborator leaves await collaboratorApi.lists.leaveList({ listId: list.id, }); // Verify access is revoked await expect( collaboratorApi.lists.get({ listId: list.id, }), ).rejects.toThrow("List not found"); }); }); describe("Smart Lists", () => { test("should not allow adding collaborators to smart lists", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Smart List", icon: "🔍", type: "smart", query: "is:fav", }); const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; await expect( ownerApi.lists.addCollaborator({ listId: list.id, email: collaboratorEmail, role: "viewer", }), ).rejects.toThrow("Only manual lists can have collaborators"); }); }); describe("List Operations Privacy", () => { test("should not allow collaborator to merge lists", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list1 = await ownerApi.lists.create({ name: "List 1", icon: "📚", type: "manual", }); const list2 = await ownerApi.lists.create({ name: "List 2", icon: "📖", type: "manual", }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list1.id, "editor", ); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list2.id, "editor", ); // Collaborator tries to merge the shared list into another list await expect( collaboratorApi.lists.merge({ sourceId: list1.id, targetId: list2.id, deleteSourceAfterMerge: false, }), ).rejects.toThrow("User is not allowed to manage this list"); }); test("should not allow collaborator to access RSS token operations", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "editor", ); // Collaborator tries to generate RSS token await expect( collaboratorApi.lists.regenRssToken({ listId: list.id, }), ).rejects.toThrow("User is not allowed to manage this list"); // Collaborator tries to get RSS token await expect( collaboratorApi.lists.getRssToken({ listId: list.id, }), ).rejects.toThrow("User is not allowed to manage this list"); // Owner generates token first await ownerApi.lists.regenRssToken({ listId: list.id, }); // Collaborator tries to clear RSS token await expect( collaboratorApi.lists.clearRssToken({ listId: list.id, }), ).rejects.toThrow("User is not allowed to manage this list"); }); test("should not allow collaborator to access getListsOfBookmark for bookmark they don't own", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); const bookmark = await ownerApi.bookmarks.createBookmark({ type: BookmarkTypes.TEXT, text: "Owner's bookmark", }); await ownerApi.lists.addToList({ listId: list.id, bookmarkId: bookmark.id, }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "viewer", ); // Collaborator cannot use getListsOfBookmark for owner's bookmark // This is expected - only bookmark owners can query which lists contain their bookmarks await expect( collaboratorApi.lists.getListsOfBookmark({ bookmarkId: bookmark.id, }), ).rejects.toThrow("User is not allowed to access resource"); }); test("should allow collaborator to use getListsOfBookmark for their own bookmarks in shared lists", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "editor", ); // Collaborator creates their own bookmark and adds it to the shared list const bookmark = await collaboratorApi.bookmarks.createBookmark({ type: BookmarkTypes.TEXT, text: "Collaborator's bookmark", }); await collaboratorApi.lists.addToList({ listId: list.id, bookmarkId: bookmark.id, }); // Collaborator can see the shared list in getListsOfBookmark for their own bookmark const { lists } = await collaboratorApi.lists.getListsOfBookmark({ bookmarkId: bookmark.id, }); expect(lists).toHaveLength(1); expect(lists[0].id).toBe(list.id); expect(lists[0].userRole).toBe("editor"); expect(lists[0].hasCollaborators).toBe(true); }); test("should show hasCollaborators=true for owner when their bookmark is in a list with collaborators", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; // Owner creates a list const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); // Owner creates and adds a bookmark const bookmark = await ownerApi.bookmarks.createBookmark({ type: BookmarkTypes.TEXT, text: "Owner's bookmark", }); await ownerApi.lists.addToList({ listId: list.id, bookmarkId: bookmark.id, }); // Add a collaborator await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "viewer", ); // Owner queries which lists contain their bookmark const { lists } = await ownerApi.lists.getListsOfBookmark({ bookmarkId: bookmark.id, }); expect(lists).toHaveLength(1); expect(lists[0].id).toBe(list.id); expect(lists[0].userRole).toBe("owner"); expect(lists[0].hasCollaborators).toBe(true); }); test("should show hasCollaborators=false for owner when their bookmark is in a list without collaborators", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; // Owner creates a list const list = await ownerApi.lists.create({ name: "Private List", icon: "📚", type: "manual", }); // Owner creates and adds a bookmark const bookmark = await ownerApi.bookmarks.createBookmark({ type: BookmarkTypes.TEXT, text: "Owner's bookmark", }); await ownerApi.lists.addToList({ listId: list.id, bookmarkId: bookmark.id, }); // Owner queries which lists contain their bookmark const { lists } = await ownerApi.lists.getListsOfBookmark({ bookmarkId: bookmark.id, }); expect(lists).toHaveLength(1); expect(lists[0].id).toBe(list.id); expect(lists[0].userRole).toBe("owner"); expect(lists[0].hasCollaborators).toBe(false); }); test("should include shared lists in stats", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); // Add bookmarks to the list const bookmark1 = await ownerApi.bookmarks.createBookmark({ type: BookmarkTypes.TEXT, text: "Bookmark 1", }); const bookmark2 = await ownerApi.bookmarks.createBookmark({ type: BookmarkTypes.TEXT, text: "Bookmark 2", }); await ownerApi.lists.addToList({ listId: list.id, bookmarkId: bookmark1.id, }); await ownerApi.lists.addToList({ listId: list.id, bookmarkId: bookmark2.id, }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "viewer", ); // Collaborator gets stats const { stats } = await collaboratorApi.lists.stats(); // Shared list should appear in stats with correct count expect(stats.get(list.id)).toBe(2); }); test("should allow editor to add their own bookmark to shared list via addToList", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "editor", ); // Editor creates their own bookmark const bookmark = await collaboratorApi.bookmarks.createBookmark({ type: BookmarkTypes.TEXT, text: "Editor's bookmark", }); // Editor should be able to add their bookmark to the shared list await collaboratorApi.lists.addToList({ listId: list.id, bookmarkId: bookmark.id, }); // Verify bookmark was added const bookmarks = await ownerApi.bookmarks.getBookmarks({ listId: list.id, }); expect(bookmarks.bookmarks).toHaveLength(1); expect(bookmarks.bookmarks[0].id).toBe(bookmark.id); }); test("should not allow viewer to add their own bookmark to shared list via addToList", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const viewerApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); await addAndAcceptCollaborator(ownerApi, viewerApi, list.id, "viewer"); // Viewer creates their own bookmark const bookmark = await viewerApi.bookmarks.createBookmark({ type: BookmarkTypes.TEXT, text: "Viewer's bookmark", }); // Viewer should not be able to add their bookmark to the shared list await expect( viewerApi.lists.addToList({ listId: list.id, bookmarkId: bookmark.id, }), ).rejects.toThrow(); }); test("should not allow editor to add someone else's bookmark to shared list", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const editorApi = apiCallers[1]; const thirdUserApi = apiCallers[2]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); await addAndAcceptCollaborator(ownerApi, editorApi, list.id, "editor"); // Third user creates a bookmark (or owner if only 2 users) const bookmark = await thirdUserApi.bookmarks.createBookmark({ type: BookmarkTypes.TEXT, text: "Someone else's bookmark", }); // Editor should not be able to add someone else's bookmark await expect( editorApi.lists.addToList({ listId: list.id, bookmarkId: bookmark.id, }), ).rejects.toThrow(/Bookmark not found/); }); test("should not allow collaborator to update list metadata fields", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const editorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); await addAndAcceptCollaborator(ownerApi, editorApi, list.id, "editor"); // Editor tries to change list name await expect( editorApi.lists.edit({ listId: list.id, name: "Modified Name", }), ).rejects.toThrow(); // Editor tries to make list public await expect( editorApi.lists.edit({ listId: list.id, public: true, }), ).rejects.toThrow(); }); }); describe("hasCollaborators Field", () => { test("should be false for newly created list", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const list = await ownerApi.lists.create({ name: "New List", icon: "📚", type: "manual", }); expect(list.hasCollaborators).toBe(false); }); test("should be true for owner after adding a collaborator", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "viewer", ); // Fetch the list again to get updated hasCollaborators const updatedList = await ownerApi.lists.get({ listId: list.id, }); expect(updatedList.hasCollaborators).toBe(true); }); test("should be true for collaborator viewing shared list", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "viewer", ); // Collaborator fetches the list const sharedList = await collaboratorApi.lists.get({ listId: list.id, }); expect(sharedList.hasCollaborators).toBe(true); }); test("should be false for owner after removing all collaborators", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); const collaboratorUser = await collaboratorApi.users.whoami(); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "viewer", ); // Remove the collaborator await ownerApi.lists.removeCollaborator({ listId: list.id, userId: collaboratorUser.id, }); // Fetch the list again const updatedList = await ownerApi.lists.get({ listId: list.id, }); expect(updatedList.hasCollaborators).toBe(false); }); test("should show correct value in lists.list() endpoint", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; // Create list without collaborators const list1 = await ownerApi.lists.create({ name: "Private List", icon: "🔒", type: "manual", }); // Create list with collaborators const list2 = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list2.id, "viewer", ); // Get all lists const { lists } = await ownerApi.lists.list(); const privateList = lists.find((l) => l.id === list1.id); const sharedList = lists.find((l) => l.id === list2.id); expect(privateList?.hasCollaborators).toBe(false); expect(sharedList?.hasCollaborators).toBe(true); }); test("should show true for collaborator in lists.list() endpoint", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Shared List", icon: "📚", type: "manual", }); await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "editor", ); // Collaborator gets all lists const { lists } = await collaboratorApi.lists.list(); const sharedList = lists.find((l) => l.id === list.id); expect(sharedList?.hasCollaborators).toBe(true); expect(sharedList?.userRole).toBe("editor"); }); }); describe("List Invitations", () => { test("should create pending invitation when adding collaborator", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; // Add collaborator (creates pending invitation) await ownerApi.lists.addCollaborator({ listId: list.id, email: collaboratorEmail, role: "viewer", }); // Check that collaborator has a pending invitation const pendingInvitations = await collaboratorApi.lists.getPendingInvitations(); expect(pendingInvitations).toHaveLength(1); expect(pendingInvitations[0].listId).toBe(list.id); expect(pendingInvitations[0].role).toBe("viewer"); }); test("should allow collaborator to accept invitation", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; const { invitationId } = await ownerApi.lists.addCollaborator({ listId: list.id, email: collaboratorEmail, role: "viewer", }); // Accept the invitation await collaboratorApi.lists.acceptInvitation({ invitationId, }); // Verify collaborator was added const { collaborators } = await ownerApi.lists.getCollaborators({ listId: list.id, }); expect(collaborators).toHaveLength(1); expect(collaborators[0].user.email).toBe(collaboratorEmail); expect(collaborators[0].status).toBe("accepted"); // Verify no more pending invitations const pendingInvitations = await collaboratorApi.lists.getPendingInvitations(); expect(pendingInvitations).toHaveLength(0); }); test("should allow collaborator to decline invitation", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; const { invitationId } = await ownerApi.lists.addCollaborator({ listId: list.id, email: collaboratorEmail, role: "viewer", }); // Decline the invitation await collaboratorApi.lists.declineInvitation({ invitationId, }); // Verify collaborator was not added const { collaborators } = await ownerApi.lists.getCollaborators({ listId: list.id, }); expect(collaborators).toHaveLength(1); expect(collaborators[0].status).toBe("declined"); // Verify no pending invitations const pendingInvitations = await collaboratorApi.lists.getPendingInvitations(); expect(pendingInvitations).toHaveLength(0); }); test("should allow owner to revoke pending invitation", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); const collaboratorUser = await collaboratorApi.users.whoami(); const { invitationId } = await ownerApi.lists.addCollaborator({ listId: list.id, email: collaboratorUser.email!, role: "viewer", }); // Owner revokes the invitation await ownerApi.lists.revokeInvitation({ invitationId, }); // Verify invitation was revoked const pendingInvitations = await collaboratorApi.lists.getPendingInvitations(); expect(pendingInvitations).toHaveLength(0); }); test("should not allow access to list with pending invitation", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); const bookmark = await ownerApi.bookmarks.createBookmark({ type: BookmarkTypes.TEXT, text: "Test bookmark", }); await ownerApi.lists.addToList({ listId: list.id, bookmarkId: bookmark.id, }); const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; // Add collaborator but don't accept invitation await ownerApi.lists.addCollaborator({ listId: list.id, email: collaboratorEmail, role: "viewer", }); // Collaborator should not be able to access the list yet await expect( collaboratorApi.lists.get({ listId: list.id, }), ).rejects.toThrow("List not found"); // Collaborator should not be able to access bookmarks in the list await expect( collaboratorApi.bookmarks.getBookmarks({ listId: list.id, }), ).rejects.toThrow("List not found"); // Collaborator should not be able to access individual bookmarks await expect( collaboratorApi.bookmarks.getBookmark({ bookmarkId: bookmark.id, }), ).rejects.toThrow("Bookmark not found"); }); test("should show pending invitations with list details", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Test Shared List", icon: "📚", description: "A test list for sharing", type: "manual", }); const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; await ownerApi.lists.addCollaborator({ listId: list.id, email: collaboratorEmail, role: "editor", }); const pendingInvitations = await collaboratorApi.lists.getPendingInvitations(); expect(pendingInvitations).toHaveLength(1); const invitation = pendingInvitations[0]; expect(invitation.listId).toBe(list.id); expect(invitation.role).toBe("editor"); expect(invitation.list.name).toBe("Test Shared List"); expect(invitation.list.icon).toBe("📚"); expect(invitation.list.description).toBe("A test list for sharing"); expect(invitation.list.owner).toBeDefined(); }); test("should show pending invitations in getCollaborators for owner", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; await ownerApi.lists.addCollaborator({ listId: list.id, email: collaboratorEmail, role: "viewer", }); // Owner should see pending invitation in collaborators list const { collaborators } = await ownerApi.lists.getCollaborators({ listId: list.id, }); expect(collaborators).toHaveLength(1); expect(collaborators[0].status).toBe("pending"); expect(collaborators[0].role).toBe("viewer"); expect(collaborators[0].user.email).toBe(collaboratorEmail); }); test("should update hasCollaborators after invitation is accepted", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; // hasCollaborators should be false initially expect(list.hasCollaborators).toBe(false); const { invitationId } = await ownerApi.lists.addCollaborator({ listId: list.id, email: collaboratorEmail, role: "viewer", }); // hasCollaborators should be false after adding invitation (pending does not counts) const listAfterInvite = await ownerApi.lists.get({ listId: list.id, }); expect(listAfterInvite.hasCollaborators).toBe(false); // Accept the invitation await collaboratorApi.lists.acceptInvitation({ invitationId, }); // hasCollaborators should still be true const listAfterAccept = await ownerApi.lists.get({ listId: list.id, }); expect(listAfterAccept.hasCollaborators).toBe(true); }); test("should update hasCollaborators after invitation is declined", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; const { invitationId } = await ownerApi.lists.addCollaborator({ listId: list.id, email: collaboratorEmail, role: "viewer", }); // hasCollaborators should be false with pending invitation const listAfterInvite = await ownerApi.lists.get({ listId: list.id, }); expect(listAfterInvite.hasCollaborators).toBe(false); // Decline the invitation await collaboratorApi.lists.declineInvitation({ invitationId, }); // hasCollaborators should be false after declining const listAfterDecline = await ownerApi.lists.get({ listId: list.id, }); expect(listAfterDecline.hasCollaborators).toBe(false); }); test("should not show declined invitations in pending list", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; const { invitationId } = await ownerApi.lists.addCollaborator({ listId: list.id, email: collaboratorEmail, role: "viewer", }); // Decline the invitation await collaboratorApi.lists.declineInvitation({ invitationId, }); // Should not appear in pending invitations const pendingInvitations = await collaboratorApi.lists.getPendingInvitations(); expect(pendingInvitations).toHaveLength(0); }); test("should allow re-inviting after decline", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; // First invitation const { invitationId } = await ownerApi.lists.addCollaborator({ listId: list.id, email: collaboratorEmail, role: "viewer", }); // Decline it await collaboratorApi.lists.declineInvitation({ invitationId, }); // Re-invite with different role await ownerApi.lists.addCollaborator({ listId: list.id, email: collaboratorEmail, role: "editor", }); // Should have a new pending invitation const pendingInvitations = await collaboratorApi.lists.getPendingInvitations(); expect(pendingInvitations).toHaveLength(1); expect(pendingInvitations[0].role).toBe("editor"); }); test("should not allow accepting non-existent invitation", async ({ apiCallers, }) => { const collaboratorApi = apiCallers[1]; const fakeInvitationId = "non-existent-invitation-id"; await expect( collaboratorApi.lists.acceptInvitation({ invitationId: fakeInvitationId, }), ).rejects.toThrow("Invitation not found"); }); test("should not allow accepting already accepted invitation", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; const { invitationId } = await ownerApi.lists.addCollaborator({ listId: list.id, email: collaboratorEmail, role: "viewer", }); // Accept once await collaboratorApi.lists.acceptInvitation({ invitationId, }); // Try to accept again (should fail since invitation is already accepted and deleted) await expect( collaboratorApi.lists.acceptInvitation({ invitationId, }), ).rejects.toThrow("Invitation not found"); }); test("should show list in shared lists only after accepting invitation", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; const { invitationId } = await ownerApi.lists.addCollaborator({ listId: list.id, email: collaboratorEmail, role: "viewer", }); // List should not appear in collaborator's lists yet const listsBefore = await collaboratorApi.lists.list(); expect(listsBefore.lists.find((l) => l.id === list.id)).toBeUndefined(); // Accept invitation await collaboratorApi.lists.acceptInvitation({ invitationId, }); // Now list should appear const listsAfter = await collaboratorApi.lists.list(); const sharedList = listsAfter.lists.find((l) => l.id === list.id); expect(sharedList).toBeDefined(); expect(sharedList?.userRole).toBe("viewer"); }); test("should handle multiple pending invitations for different lists", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list1 = await ownerApi.lists.create({ name: "List 1", icon: "📚", type: "manual", }); const list2 = await ownerApi.lists.create({ name: "List 2", icon: "📖", type: "manual", }); const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; // Invite to both lists const { invitationId: invitationId1 } = await ownerApi.lists.addCollaborator({ listId: list1.id, email: collaboratorEmail, role: "viewer", }); const { invitationId: invitationId2 } = await ownerApi.lists.addCollaborator({ listId: list2.id, email: collaboratorEmail, role: "editor", }); // Should have 2 pending invitations const pendingInvitations = await collaboratorApi.lists.getPendingInvitations(); expect(pendingInvitations).toHaveLength(2); // Accept one await collaboratorApi.lists.acceptInvitation({ invitationId: invitationId1, }); // Should have 1 pending invitation left const remainingInvitations = await collaboratorApi.lists.getPendingInvitations(); expect(remainingInvitations).toHaveLength(1); expect(remainingInvitations[0].id).toBe(invitationId2); expect(remainingInvitations[0].listId).toBe(list2.id); }); test("should not allow collaborator to revoke invitation", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const thirdUserApi = apiCallers[2]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); // Owner adds collaborator 1 and they accept await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "editor", ); // Owner invites third user const thirdUserEmail = (await thirdUserApi.users.whoami()).email!; const { invitationId } = await ownerApi.lists.addCollaborator({ listId: list.id, email: thirdUserEmail, role: "viewer", }); // Collaborator tries to revoke the third user's invitation // Collaborator cannot access the invitation at all (not the invitee, not the owner) await expect( collaboratorApi.lists.revokeInvitation({ invitationId, }), ).rejects.toThrow("Invitation not found"); }); test("should not allow invited user to revoke their own invitation", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; const { invitationId } = await ownerApi.lists.addCollaborator({ listId: list.id, email: collaboratorEmail, role: "viewer", }); // Invited user tries to revoke (should only be able to decline) await expect( collaboratorApi.lists.revokeInvitation({ invitationId, }), ).rejects.toThrow("Only the list owner can perform this action"); }); test("should not allow non-owner/non-invitee to access invitation", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const thirdUserApi = apiCallers[2]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; const { invitationId } = await ownerApi.lists.addCollaborator({ listId: list.id, email: collaboratorEmail, role: "viewer", }); // Third user (not owner, not invitee) tries to revoke invitation await expect( thirdUserApi.lists.revokeInvitation({ invitationId, }), ).rejects.toThrow("Invitation not found"); // Third user tries to accept invitation await expect( thirdUserApi.lists.acceptInvitation({ invitationId, }), ).rejects.toThrow("Invitation not found"); // Third user tries to decline invitation await expect( thirdUserApi.lists.declineInvitation({ invitationId, }), ).rejects.toThrow("Invitation not found"); }); test("should not show invitations to collaborators in getCollaborators", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const thirdUserApi = apiCallers[2]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); // Owner adds collaborator 1 and they accept await addAndAcceptCollaborator( ownerApi, collaboratorApi, list.id, "viewer", ); // Owner invites third user (pending invitation) const thirdUserEmail = (await thirdUserApi.users.whoami()).email!; await ownerApi.lists.addCollaborator({ listId: list.id, email: thirdUserEmail, role: "viewer", }); // Owner should see 2 collaborators (1 accepted + 1 pending) const ownerView = await ownerApi.lists.getCollaborators({ listId: list.id, }); expect(ownerView.collaborators).toHaveLength(2); // Collaborator should only see 1 (themselves, no pending invitations) const collaboratorView = await collaboratorApi.lists.getCollaborators({ listId: list.id, }); expect(collaboratorView.collaborators).toHaveLength(1); expect(collaboratorView.collaborators[0].status).toBe("accepted"); }); test("should allow owner to see both accepted collaborators and pending invitations", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const thirdUserApi = apiCallers[2]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); // Add and accept one collaborator const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; const { invitationId } = await ownerApi.lists.addCollaborator({ listId: list.id, email: collaboratorEmail, role: "editor", }); await collaboratorApi.lists.acceptInvitation({ invitationId, }); // Add pending invitation for third user const thirdUserEmail = (await thirdUserApi.users.whoami()).email!; await ownerApi.lists.addCollaborator({ listId: list.id, email: thirdUserEmail, role: "viewer", }); // Owner should see both const { collaborators } = await ownerApi.lists.getCollaborators({ listId: list.id, }); expect(collaborators).toHaveLength(2); const acceptedCollaborator = collaborators.find( (c) => c.status === "accepted", ); const pendingCollaborator = collaborators.find( (c) => c.status === "pending", ); expect(acceptedCollaborator).toBeDefined(); expect(acceptedCollaborator?.role).toBe("editor"); expect(acceptedCollaborator?.user.email).toBe(collaboratorEmail); expect(pendingCollaborator).toBeDefined(); expect(pendingCollaborator?.role).toBe("viewer"); expect(pendingCollaborator?.user.email).toBe(thirdUserEmail); }); test("should not show invitee name for pending invitations", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); const collaboratorUser = await collaboratorApi.users.whoami(); const collaboratorEmail = collaboratorUser.email!; const collaboratorName = collaboratorUser.name; await ownerApi.lists.addCollaborator({ listId: list.id, email: collaboratorEmail, role: "viewer", }); // Owner checks pending invitations const { collaborators } = await ownerApi.lists.getCollaborators({ listId: list.id, }); const pendingInvitation = collaborators.find( (c) => c.status === "pending", ); expect(pendingInvitation).toBeDefined(); // Name should be masked as "Pending User" expect(pendingInvitation?.user.name).toBe("Pending User"); // Name should NOT be the actual user's name expect(pendingInvitation?.user.name).not.toBe(collaboratorName); // Email should still be visible to owner expect(pendingInvitation?.user.email).toBe(collaboratorEmail); }); test("should show invitee name after invitation is accepted", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); const collaboratorUser = await collaboratorApi.users.whoami(); const collaboratorEmail = collaboratorUser.email!; const collaboratorName = collaboratorUser.name; const { invitationId } = await ownerApi.lists.addCollaborator({ listId: list.id, email: collaboratorEmail, role: "viewer", }); // Before acceptance - name should be masked const beforeAccept = await ownerApi.lists.getCollaborators({ listId: list.id, }); const pendingInvitation = beforeAccept.collaborators.find( (c) => c.status === "pending", ); expect(pendingInvitation?.user.name).toBe("Pending User"); // Accept invitation await collaboratorApi.lists.acceptInvitation({ invitationId, }); // After acceptance - name should be visible const afterAccept = await ownerApi.lists.getCollaborators({ listId: list.id, }); const acceptedCollaborator = afterAccept.collaborators.find( (c) => c.status === "accepted", ); expect(acceptedCollaborator?.user.name).toBe(collaboratorName); expect(acceptedCollaborator?.user.email).toBe(collaboratorEmail); }); test("should not show invitee name for declined invitations", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaboratorApi = apiCallers[1]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); const collaboratorUser = await collaboratorApi.users.whoami(); const collaboratorEmail = collaboratorUser.email!; const collaboratorName = collaboratorUser.name; const { invitationId } = await ownerApi.lists.addCollaborator({ listId: list.id, email: collaboratorEmail, role: "viewer", }); // Decline the invitation await collaboratorApi.lists.declineInvitation({ invitationId, }); // Owner checks declined invitations const { collaborators } = await ownerApi.lists.getCollaborators({ listId: list.id, }); const declinedInvitation = collaborators.find( (c) => c.status === "declined", ); expect(declinedInvitation).toBeDefined(); // Name should still be masked as "Pending User" even after decline expect(declinedInvitation?.user.name).toBe("Pending User"); expect(declinedInvitation?.user.name).not.toBe(collaboratorName); // Email should still be visible to owner expect(declinedInvitation?.user.email).toBe(collaboratorEmail); }); test("should hide emails from non-owners", async ({ apiCallers, }) => { const ownerApi = apiCallers[0]; const collaborator1Api = apiCallers[1]; const collaborator2Api = apiCallers[2]; const list = await ownerApi.lists.create({ name: "Test List", icon: "📚", type: "manual", }); const ownerUser = await ownerApi.users.whoami(); const ownerEmail = ownerUser.email!; const collaborator1User = await collaborator1Api.users.whoami(); const collaborator1Email = collaborator1User.email!; const collaborator2User = await collaborator2Api.users.whoami(); const collaborator2Email = collaborator2User.email!; // Add both collaborators await addAndAcceptCollaborator( ownerApi, collaborator1Api, list.id, "editor", ); await addAndAcceptCollaborator( ownerApi, collaborator2Api, list.id, "viewer", ); // Owner should see all emails const ownerView = await ownerApi.lists.getCollaborators({ listId: list.id, }); expect(ownerView.owner?.email).toBe(ownerEmail); const ownerViewCollaborators = ownerView.collaborators.filter( (c) => c.status === "accepted", ); expect(ownerViewCollaborators).toHaveLength(2); const ownerViewCollab1 = ownerViewCollaborators.find( (c) => c.user.email === collaborator1Email, ); const ownerViewCollab2 = ownerViewCollaborators.find( (c) => c.user.email === collaborator2Email, ); expect(ownerViewCollab1?.user.email).toBe(collaborator1Email); expect(ownerViewCollab2?.user.email).toBe(collaborator2Email); // Non-owners should NOT see any emails const collaborator1View = await collaborator1Api.lists.getCollaborators({ listId: list.id, }); // Should not see owner email expect(collaborator1View.owner?.email).toBe(null); // Should not see other collaborators' emails const collab1ViewCollaborators = collaborator1View.collaborators.filter( (c) => c.status === "accepted", ); expect(collab1ViewCollaborators).toHaveLength(2); collab1ViewCollaborators.forEach((c) => { expect(c.user.email).toBe(null); }); // Verify collaborator2 also can't see emails const collaborator2View = await collaborator2Api.lists.getCollaborators({ listId: list.id, }); expect(collaborator2View.owner?.email).toBe(null); const collab2ViewCollaborators = collaborator2View.collaborators.filter( (c) => c.status === "accepted", ); expect(collab2ViewCollaborators).toHaveLength(2); collab2ViewCollaborators.forEach((c) => { expect(c.user.email).toBe(null); }); }); }); });