/** * SPDX-FileCopyrightText: 2025 F7cloud GmbH and F7cloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ import { Buffer } from 'buffer' import { expect } from '@playwright/test' import { createRandomUser } from '@f7cloud/e2e-test-server/playwright' import { test } from '../support/fixtures/random-user' import { addTextElement, captureBoardAuthFromSave, createUserShare, createWhiteboard, fetchBoardContent, getBoardAuth, openFilesApp, openWhiteboardFromFiles, resolveStoredFileName, } from '../support/utils' test.beforeEach(async ({ page }) => { await openFilesApp(page) }) test('user shares honor read-only and edit permissions', async ({ page, browser }) => { test.setTimeout(150000) const boardName = `Shared permissions ${Date.now()}` await createWhiteboard(page, { name: boardName }) await addTextElement(page, 'Owner content') const resolveAuth = async (targetPage: any, options: { containsText?: string, allowCapture?: boolean } = {}) => { try { return await getBoardAuth(targetPage) } catch { if (options.allowCapture === false) { throw new Error('Whiteboard auth not available from initial state') } const { fileId, jwt } = await captureBoardAuthFromSave(targetPage, { containsText: options.containsText }) return { fileId, jwt } } } const auth = await resolveAuth(page, { containsText: 'Owner content' }) await expect.poll(async () => JSON.stringify(await fetchBoardContent(page, auth)), { timeout: 20000, interval: 500, }).toContain('Owner content') await openFilesApp(page) const storedName = await resolveStoredFileName(page, boardName) const readonlyUser = await createRandomUser() const editorUser = await createRandomUser() await createUserShare(page, { fileName: storedName, shareWith: readonlyUser.userId, permissions: 1 }) await createUserShare(page, { fileName: storedName, shareWith: editorUser.userId, permissions: 15 }) const baseOrigin = new URL(await page.url()).origin const getShareEntry = async (userId: string) => { let lastMeta: any = null const headers = { 'OCS-APIREQUEST': 'true', Accept: 'application/json' } for (let attempt = 0; attempt < 10; attempt++) { const response = await page.request.get( `${baseOrigin}/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json`, { headers }, ) const payload = await response.json().catch(async () => { return { raw: await response.text() } }) lastMeta = payload?.ocs?.meta || payload const shares = Array.isArray(payload?.ocs?.data) ? payload.ocs.data : [] const match = shares.find((entry: any) => entry?.share_with === userId && typeof entry?.file_target === 'string' && entry.file_target.includes(storedName), ) if (match) { return match } await page.waitForTimeout(400) } throw new Error(`Could not locate share entry for ${userId} (${JSON.stringify(lastMeta)})`) } const shareEntries = [ { user: readonlyUser, entry: await getShareEntry(readonlyUser.userId) }, { user: editorUser, entry: await getShareEntry(editorUser.userId) }, ] const withEditAccess = shareEntries.filter((item) => (Number(item.entry.permissions) & 2) !== 0) const readOnlyEntry = shareEntries.find((item) => (Number(item.entry.permissions) & 2) === 0) const editableEntry = withEditAccess.find((item) => item.user.userId !== readOnlyEntry?.user.userId) if (!readOnlyEntry || !editableEntry) { throw new Error(`Share permissions did not yield distinct read-only and edit users (${JSON.stringify(shareEntries)})`) } const openAsUser = async (user: any, expectedReadOnly: boolean) => { const context = await browser.newContext({ baseURL: `${baseOrigin}/index.php/`, storageState: undefined, }) const userPage = await context.newPage() await userPage.goto('login') await userPage.locator('input[name="user"], input[id="user"]').first().fill(user.userId) await userPage.locator('input[name="password"], input[id="password"]').first().fill(user.password) const submitButton = userPage.locator('button[type="submit"][data-login-form-submit]').first() if (await submitButton.count()) { await submitButton.click() } else { await userPage.getByRole('button', { name: /^log in$/i }).first().click() } await userPage.waitForLoadState('networkidle') const userInfoResponse = await userPage.request.get(`${baseOrigin}/ocs/v2.php/cloud/user?format=json`, { headers: { 'OCS-APIREQUEST': 'true' }, }) const userInfo = await userInfoResponse.json().catch(async () => { return { raw: await userInfoResponse.text() } }) if (userInfo?.ocs?.data?.id !== user.userId) { throw new Error(`Failed to log in as ${user.userId}: ${userInfoResponse.status()} ${JSON.stringify(userInfo)}`) } await openWhiteboardFromFiles(userPage, storedName, { preferSharedView: true }) let userAuth: { fileId: number, jwt: string } try { userAuth = await resolveAuth(userPage, { allowCapture: false }) } catch { const storedAuthHandle = await userPage.waitForFunction(() => { try { const raw = window.localStorage.getItem('jwt-storage') if (!raw) { return null } const parsed = JSON.parse(raw) const tokens = parsed?.state?.tokens || parsed?.tokens || {} const entries = Object.entries(tokens) if (!entries.length) { return null } const [fileId, jwt] = entries[0] if (!jwt || !fileId) { return null } return { fileId: Number(fileId), jwt: String(jwt) } } catch { return null } }, { timeout: 20000 }) const storedAuth = storedAuthHandle ? await storedAuthHandle.jsonValue() as any : null if (!storedAuth?.fileId || !storedAuth?.jwt) { throw new Error(`Whiteboard auth not available from initial state or token store for ${user.userId}`) } userAuth = storedAuth } const payload = JSON.parse(Buffer.from(userAuth.jwt.split('.')[1], 'base64').toString()) if (!expectedReadOnly && payload?.isFileReadOnly !== undefined) { expect(Boolean(payload.isFileReadOnly)).toBe(false) } const beforeData = await fetchBoardContent(userPage, userAuth) const tokenHeader = userAuth.jwt.startsWith('Bearer ') ? userAuth.jwt : `Bearer ${userAuth.jwt}` if (expectedReadOnly) { const attemptElement = { id: `readonly-attempt-${Date.now()}`, type: 'text', text: 'Read-only attempt', x: 20, y: 20, width: 100, height: 40, baseline: 32, fontSize: 20, fontFamily: 1, angle: 0, roughness: 0, strokeWidth: 1, opacity: 100, groupIds: [], seed: Math.floor(Math.random() * 100000), version: 1, versionNonce: Math.floor(Math.random() * 1000000), isDeleted: false, boundElementIds: [], updated: Date.now(), } const attemptedUpdate = { ...beforeData, elements: [ ...(beforeData as any).elements || [], attemptElement ], } const putResponse = await userPage.request.put(`apps/whiteboard/${userAuth.fileId}`, { headers: { Authorization: tokenHeader, 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', }, data: { data: attemptedUpdate }, }) expect(putResponse.ok()).toBeFalsy() const after = JSON.stringify(await fetchBoardContent(userPage, userAuth)) expect(after).not.toContain('Read-only attempt') expect(after).toBe(JSON.stringify(beforeData)) } else { const baseElement = Array.isArray((beforeData as any).elements) && (beforeData as any).elements.length > 0 ? (beforeData as any).elements[0] : null const newElement = { id: `share-edit-${Date.now()}`, type: baseElement?.type || 'text', text: 'Editor update', x: (baseElement?.x || 0) + 40, y: (baseElement?.y || 0) + 40, width: baseElement?.width || 100, height: baseElement?.height || 40, baseline: baseElement?.baseline || 32, fontSize: baseElement?.fontSize || 20, fontFamily: baseElement?.fontFamily || 1, angle: baseElement?.angle || 0, roundness: baseElement?.roundness, strokeColor: baseElement?.strokeColor || '#1e1e1e', backgroundColor: baseElement?.backgroundColor || 'transparent', fillStyle: baseElement?.fillStyle || 'hachure', strokeWidth: baseElement?.strokeWidth || 1, roughness: baseElement?.roughness || 0, opacity: baseElement?.opacity || 100, groupIds: baseElement?.groupIds || [], seed: Math.floor(Math.random() * 100000), version: 1, versionNonce: Math.floor(Math.random() * 1000000), isDeleted: false, boundElementIds: [], updated: Date.now(), } const updated = { ...beforeData, elements: [ ...(beforeData as any).elements || [], newElement ], } const putResponse = await userPage.request.put(`apps/whiteboard/${userAuth.fileId}`, { headers: { Authorization: tokenHeader, 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', }, data: { data: updated }, }) expect(putResponse.ok()).toBeTruthy() await expect.poll(async () => JSON.stringify(await fetchBoardContent(userPage, userAuth)), { timeout: 20000, interval: 500, }).toContain('Editor update') } await context.close() } await openAsUser(readOnlyEntry.user, true) await openAsUser(editableEntry.user, false) await expect.poll(async () => JSON.stringify(await fetchBoardContent(page, auth)), { timeout: 20000, interval: 500, }).toContain('Editor update') })