283 lines
8.5 KiB
TypeScript
283 lines
8.5 KiB
TypeScript
/**
|
|
* SPDX-FileCopyrightText: 2025 F7cloud GmbH and F7cloud contributors
|
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
*/
|
|
|
|
import { expect } from '@playwright/test'
|
|
import type { Page } from '@playwright/test'
|
|
import { test } from '../support/fixtures/random-user'
|
|
import {
|
|
addTextElement,
|
|
createWhiteboard,
|
|
getCanvasForInteraction,
|
|
openFilesApp,
|
|
waitForCanvas,
|
|
} from '../support/utils'
|
|
|
|
async function createPublicShareLink(
|
|
page: Page,
|
|
baseName: string,
|
|
{ allowWhiteboardFallback = true }: { allowWhiteboardFallback?: boolean } = {},
|
|
): Promise<string> {
|
|
const requestToken = await page.evaluate(() => (window as any).OC?.requestToken ?? null)
|
|
|
|
const candidates = (() => {
|
|
if (!allowWhiteboardFallback) {
|
|
return [baseName]
|
|
}
|
|
const lower = baseName.toLowerCase()
|
|
if (lower.endsWith('.whiteboard') || lower.endsWith('.excalidraw')) {
|
|
return [baseName]
|
|
}
|
|
return [`${baseName}.whiteboard`, `${baseName}.excalidraw`, baseName]
|
|
})()
|
|
|
|
for (const candidate of candidates) {
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
const result = await page.evaluate(async ({ candidate, requestToken }) => {
|
|
const body = new URLSearchParams({
|
|
path: `/${candidate}`,
|
|
shareType: '3',
|
|
permissions: '1',
|
|
})
|
|
|
|
const response = await fetch('ocs/v2.php/apps/files_sharing/api/v1/shares?format=json', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: {
|
|
'OCS-APIREQUEST': 'true',
|
|
...(requestToken ? { requesttoken: requestToken } : {}),
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
Accept: 'application/json',
|
|
},
|
|
body,
|
|
})
|
|
|
|
const text = await response.text()
|
|
let data = null
|
|
try {
|
|
data = JSON.parse(text)
|
|
} catch {
|
|
// ignore non JSON
|
|
}
|
|
|
|
return {
|
|
status: response.status,
|
|
data,
|
|
}
|
|
}, { candidate, requestToken })
|
|
|
|
const metaStatus = result?.data?.ocs?.meta?.statuscode
|
|
const shareUrl = result?.data?.ocs?.data?.url
|
|
if (metaStatus === 200 && typeof shareUrl === 'string') {
|
|
return shareUrl
|
|
}
|
|
|
|
await page.waitForTimeout(1000)
|
|
}
|
|
}
|
|
|
|
// Fallback: use the UI share sidebar to create a link
|
|
const row = page.getByRole('row', { name: new RegExp(baseName.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')) })
|
|
const shareButton = row.getByRole('button', { name: /Sharing options|Actions/i }).first()
|
|
await shareButton.click()
|
|
|
|
const createLinkButton = page.getByRole('button', { name: /create a new share link/i }).first()
|
|
await expect(createLinkButton).toBeVisible({ timeout: 15000 })
|
|
await createLinkButton.click()
|
|
|
|
const copyButton = page.getByRole('button', { name: /copy public link/i }).first()
|
|
await expect(copyButton).toBeVisible({ timeout: 15000 })
|
|
const dataClipboard = await copyButton.getAttribute('data-clipboard-text')
|
|
if (dataClipboard) {
|
|
return dataClipboard
|
|
}
|
|
|
|
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
|
|
await copyButton.click()
|
|
const clipboardText = await page.evaluate(async () => {
|
|
try {
|
|
return await navigator.clipboard.readText()
|
|
} catch {
|
|
return ''
|
|
}
|
|
})
|
|
|
|
if (!clipboardText) {
|
|
throw new Error(`Failed to create public share link for ${baseName}`)
|
|
}
|
|
return clipboardText
|
|
}
|
|
|
|
async function uploadTextFile(page: Page, name: string, content: string) {
|
|
const origin = new URL(await page.url()).origin
|
|
const userResponse = await page.request.get(`${origin}/ocs/v2.php/cloud/user?format=json`, {
|
|
headers: { 'OCS-APIREQUEST': 'true' },
|
|
})
|
|
expect(userResponse.ok()).toBeTruthy()
|
|
const userPayload = await userResponse.json().catch(() => null)
|
|
const userId = userPayload?.ocs?.data?.id
|
|
if (!userId) {
|
|
throw new Error('Unable to resolve current user id')
|
|
}
|
|
|
|
const requestToken = await page.evaluate(() => (window as any).OC?.requestToken
|
|
|| (document.querySelector('head meta[name="requesttoken"]') as HTMLMetaElement | null)?.content
|
|
|| null)
|
|
|
|
const response = await page.request.fetch(
|
|
`${origin}/remote.php/dav/files/${encodeURIComponent(userId)}/${encodeURIComponent(name)}`,
|
|
{
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'text/plain',
|
|
...(requestToken ? { requesttoken: requestToken } : {}),
|
|
},
|
|
data: content,
|
|
},
|
|
)
|
|
|
|
expect(response.ok()).toBeTruthy()
|
|
}
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await openFilesApp(page)
|
|
})
|
|
|
|
test('public share loads viewer in read only mode', async ({ page, browser }) => {
|
|
test.setTimeout(120000)
|
|
const boardName = `Shared board ${Date.now()}`
|
|
|
|
await createWhiteboard(page, { name: boardName })
|
|
await addTextElement(page, 'Shared marker')
|
|
await waitForCanvas(page)
|
|
|
|
// Wait until the file appears in the Files list to ensure backend persistence
|
|
await openFilesApp(page)
|
|
const fileRow = page.getByRole('row', { name: new RegExp(boardName) })
|
|
await expect(fileRow).toBeVisible({ timeout: 30000 })
|
|
|
|
const storedName = (await fileRow.evaluate<string | null>((row) => {
|
|
const element = row as HTMLElement
|
|
const dataEntry = element.getAttribute('data-entryname') || element.getAttribute('data-file')
|
|
if (dataEntry) {
|
|
return dataEntry
|
|
}
|
|
|
|
const ariaLabel = element.getAttribute('aria-label') || ''
|
|
const ariaMatch = ariaLabel.match(/file \"([^\"]+)\"/)
|
|
if (ariaMatch?.[1]) {
|
|
return ariaMatch[1]
|
|
}
|
|
|
|
const text = element.textContent || ''
|
|
const textMatch = text.match(/([\\w\\s.-]+\\.whiteboard|[\\w\\s.-]+\\.excalidraw)/i)
|
|
if (textMatch?.[1]) {
|
|
return textMatch[1]
|
|
}
|
|
return null
|
|
})) ?? `${boardName}.whiteboard`
|
|
|
|
const shareUrl = await createPublicShareLink(page, storedName)
|
|
|
|
// Open the share link in a clean context to mimic an external visitor
|
|
const shareContext = await browser.newContext({ storageState: undefined })
|
|
const response = await shareContext.request.get(shareUrl)
|
|
expect(response.ok()).toBeTruthy()
|
|
|
|
const body = await response.text()
|
|
expect(body.toLowerCase()).toContain('whiteboard')
|
|
|
|
// If a JWT is present, ensure it marks the file as read only
|
|
const jwtMatch = body.match(/"jwt"\s*:\s*"([^"]+)"/)
|
|
const embeddedJwt = jwtMatch ? jwtMatch[1] : null
|
|
if (jwtMatch) {
|
|
const token = jwtMatch[1]
|
|
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString())
|
|
expect(payload?.isFileReadOnly).not.toBe(false)
|
|
}
|
|
|
|
const sharePage = await shareContext.newPage()
|
|
await sharePage.goto(shareUrl)
|
|
await waitForCanvas(sharePage)
|
|
|
|
const { fileId, jwt } = await sharePage.evaluate(() => {
|
|
try {
|
|
const load = (window as any).OCP?.InitialState?.loadState
|
|
return {
|
|
fileId: load ? Number(load('whiteboard', 'file_id')) : null,
|
|
jwt: load ? String(load('whiteboard', 'jwt') || '') : null,
|
|
}
|
|
} catch {
|
|
return { fileId: null, jwt: null }
|
|
}
|
|
})
|
|
|
|
const effectiveJwt = jwt || embeddedJwt
|
|
|
|
const attemptEdit = async () => {
|
|
const canvas = await getCanvasForInteraction(sharePage)
|
|
await canvas.click({ position: { x: 140, y: 140 } })
|
|
await sharePage.keyboard.type('Read only attempt')
|
|
await sharePage.waitForTimeout(1500)
|
|
if (!fileId || !effectiveJwt) {
|
|
return null
|
|
}
|
|
const response = await sharePage.request.get(`apps/whiteboard/${fileId}`, {
|
|
headers: { Authorization: `Bearer ${effectiveJwt}` },
|
|
})
|
|
expect(response.ok()).toBeTruthy()
|
|
const shareBody = await response.json()
|
|
return JSON.stringify(shareBody.data)
|
|
}
|
|
|
|
const before = await attemptEdit()
|
|
const after = await attemptEdit()
|
|
if (before && after) {
|
|
expect(after).toBe(before)
|
|
}
|
|
|
|
await shareContext.close()
|
|
})
|
|
|
|
test('public share for non-whiteboard does not boot whiteboard runtime', async ({ page, browser }) => {
|
|
test.setTimeout(120000)
|
|
const fileName = `Public text ${Date.now()}.txt`
|
|
|
|
await uploadTextFile(page, fileName, `Hello ${Date.now()}`)
|
|
await openFilesApp(page)
|
|
|
|
const fileRow = page.getByRole('row', { name: new RegExp(fileName.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')) })
|
|
await expect(fileRow).toBeVisible({ timeout: 30000 })
|
|
|
|
const shareUrl = await createPublicShareLink(page, fileName, { allowWhiteboardFallback: false })
|
|
|
|
const shareContext = await browser.newContext({ storageState: undefined })
|
|
const sharePage = await shareContext.newPage()
|
|
await sharePage.goto(shareUrl)
|
|
await sharePage.waitForLoadState('domcontentloaded')
|
|
|
|
const state = await sharePage.evaluate(() => {
|
|
const load = (window as any).OCP?.InitialState?.loadState
|
|
let fileId: number | null = null
|
|
if (load) {
|
|
try {
|
|
fileId = Number(load('whiteboard', 'file_id'))
|
|
} catch {
|
|
fileId = null
|
|
}
|
|
}
|
|
return {
|
|
fileId,
|
|
hasCanvas: Boolean(document.querySelector('.excalidraw__canvas')),
|
|
hasPublicShareClass: document.body.classList.contains('whiteboard-public-share'),
|
|
}
|
|
})
|
|
|
|
expect(state.fileId).toBeFalsy()
|
|
expect(state.hasCanvas).toBe(false)
|
|
expect(state.hasPublicShareClass).toBe(false)
|
|
|
|
await shareContext.close()
|
|
})
|