From 27f7e4697c890cb3277de63049eda372a26cb4a9 Mon Sep 17 00:00:00 2001 From: David Dreschner Date: Sat, 11 Jul 2026 05:47:55 +0200 Subject: [PATCH 01/16] chore(cypress): Try to fix flaky cypress files tests Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: David Dreschner --- cypress/e2e/files/FilesUtils.ts | 34 +++++++++++++++++++++++++++++---- cypress/support/e2e.ts | 12 ++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/cypress/e2e/files/FilesUtils.ts b/cypress/e2e/files/FilesUtils.ts index 2d7a28ac6d677..38d75ffaea0fc 100644 --- a/cypress/e2e/files/FilesUtils.ts +++ b/cypress/e2e/files/FilesUtils.ts @@ -62,6 +62,34 @@ export function getInlineActionEntryForFile(file: string, actionId: string) { return cy.get(`[data-cy-files-list-row-name="${CSS.escape(file)}"] [data-cy-files-list-row-action="${CSS.escape(actionId)}"]`) } +/** + * Open the actions menu of a file row and wait until it is displayed. + * + * Click exactly once, then wait: while the popover opens, the toggle + * already reports aria-expanded="true" although the menu is still hidden — + * the popover positions itself over several frames, which can take seconds + * on slow (CI) runners. Clicking again in that state toggles the menu + * closed and tangles the popover's show and hide transitions into a stuck, + * permanently invisible popover. Clicking is only safe while the toggle + * reports "closed", e.g. after an auto-close caused by a stray outside + * click. + * + * @param getActionButton query for the actions menu toggle of the row + */ +function openActionsMenu(getActionButton: () => Cypress.Chainable>) { + getActionButton().then(($toggle) => { + if ($toggle.attr('aria-expanded') !== 'true') { + cy.wrap($toggle).click({ force: true }) // force to avoid issues with overlaying file list header + } + }) + getActionButton() + .should('have.attr', 'aria-controls') + .then((menuId) => { + // Generous timeout for the popover to finish positioning on slow runners + cy.get(`#${menuId}`, { timeout: 20000 }).should('be.visible') + }) +} + /** * * @param fileid @@ -70,8 +98,7 @@ export function getInlineActionEntryForFile(file: string, actionId: string) { export function triggerActionForFileId(fileid: number, actionId: string) { getActionButtonForFileId(fileid) .scrollIntoView() - getActionButtonForFileId(fileid) - .click({ force: true }) // force to avoid issues with overlaying file list header + openActionsMenu(() => getActionButtonForFileId(fileid)) getActionEntryForFileId(fileid, actionId) .find('button') .should('be.visible') @@ -86,8 +113,7 @@ export function triggerActionForFileId(fileid: number, actionId: string) { export function triggerActionForFile(filename: string, actionId: string) { getActionButtonForFile(filename) .scrollIntoView() - getActionButtonForFile(filename) - .click({ force: true }) // force to avoid issues with overlaying file list header + openActionsMenu(() => getActionButtonForFile(filename)) getActionEntryForFile(filename, actionId) .find('button') .should('be.visible') diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 30c1f008550e1..36e08a12514ee 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -26,3 +26,15 @@ Cypress.on('window:before:load', (win) => { } } }) + +// Optional CPU throttling to reproduce CI-like renderer slowness locally. +// Usage: CYPRESS_CPU_THROTTLE=8 npx cypress run ... +const cpuThrottle = Number(Cypress.env('CPU_THROTTLE')) +if (cpuThrottle > 1) { + beforeEach(() => { + Cypress.automation('remote:debugger:protocol', { + command: 'Emulation.setCPUThrottlingRate', + params: { rate: cpuThrottle }, + }) + }) +} From a206e88d6eccdfa8317c8fc33e34ad3f58cf4b44 Mon Sep 17 00:00:00 2001 From: David Dreschner Date: Sat, 11 Jul 2026 07:11:17 +0200 Subject: [PATCH 02/16] fix(cypress): Try to analyze cypress failures Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: David Dreschner --- .../public-share/view_file-drop.cy.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/cypress/e2e/files_sharing/public-share/view_file-drop.cy.ts b/cypress/e2e/files_sharing/public-share/view_file-drop.cy.ts index c3c289774ccb2..5c81d011351bc 100644 --- a/cypress/e2e/files_sharing/public-share/view_file-drop.cy.ts +++ b/cypress/e2e/files_sharing/public-share/view_file-drop.cy.ts @@ -131,9 +131,19 @@ describe('files_sharing: Public share - File drop', { testIsolation: true }, () cy.wait('@uploadFile') - cy.findByRole('progressbar') - .should('be.visible') - .and((el) => { expect(Number.parseInt(el.attr('value') ?? '0')).be.gte(50) }) + // More than one progressbar can exist (upload picker and file drop + // view) and some of them stay hidden, so assert that any visible + // one reports the expected progress. + cy.findAllByRole('progressbar') + .should(($bars) => { + const visible = $bars.toArray().filter((el) => Cypress.$(el).is(':visible')) + const summary = $bars.toArray() + .map((el) => `${el.tagName}[value=${el.getAttribute('value')} visible=${Cypress.$(el).is(':visible')}]`) + .join(', ') + expect(visible.length, `visible progressbar (${summary})`).to.be.gte(1) + const values = visible.map((el) => Number.parseInt(el.getAttribute('value') ?? '0')) + expect(Math.max(...values), `upload progress (${summary})`).to.be.gte(50) + }) // continue second request .then(() => resolve(null)) From 68ca6542fa438de276f0d2cb704a3713741b6d2b Mon Sep 17 00:00:00 2001 From: David Dreschner Date: Sat, 11 Jul 2026 18:28:47 +0200 Subject: [PATCH 03/16] chore(cypress): Try to analyze failures Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: David Dreschner --- cypress.config.ts | 7 +++++ cypress/e2e/files/FilesUtils.ts | 29 ++++++++++++++---- cypress/support/e2e.ts | 53 +++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 5 deletions(-) diff --git a/cypress.config.ts b/cypress.config.ts index cec4bf5c8d6f7..64e217ca7aa52 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -71,6 +71,13 @@ export default defineConfig({ // because Cypress.env() and other options are local to the current spec file. const data: Record = {} on('task', { + // Print a message to the Node/terminal stdout (browser console.log + // is not piped to the cypress run output in headless mode). + log(message) { + // eslint-disable-next-line no-console + console.log(message) + return null + }, setVariable({ key, value }) { data[key] = value return null diff --git a/cypress/e2e/files/FilesUtils.ts b/cypress/e2e/files/FilesUtils.ts index 38d75ffaea0fc..fee3461a221dd 100644 --- a/cypress/e2e/files/FilesUtils.ts +++ b/cypress/e2e/files/FilesUtils.ts @@ -82,12 +82,31 @@ function openActionsMenu(getActionButton: () => Cypress.Chainable { - // Generous timeout for the popover to finish positioning on slow runners - cy.get(`#${menuId}`, { timeout: 20000 }).should('be.visible') + // DIAGNOSTIC: poll and record aria-expanded/visible so a hard failure reveals + // whether the menu was stuck positioning (expanded stays true) or was closed + // (expanded flips back to false). No recovery — we want the raw failure. + const transitions: string[] = [] + const poll = (elapsed: number) => { + getActionButton().then(($toggle) => { + const expanded = $toggle.attr('aria-expanded') + const menuId = $toggle.attr('aria-controls') + const visible = Boolean(menuId) && Cypress.$(`#${menuId}`).is(':visible') + const state = `expanded=${expanded},visible=${visible}` + if (state !== transitions[transitions.length - 1]) { + transitions.push(`${elapsed}ms:${state}`) + } + if (visible) { + return + } + if (elapsed >= 20000) { + throw new Error(`Actions menu did not open. transitions=[${transitions.join(' -> ')}]`) + } + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(200) + poll(elapsed + 200) }) + } + poll(0) } /** diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 36e08a12514ee..3e6b99ea8e0ab 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -38,3 +38,56 @@ if (cpuThrottle > 1) { }) }) } + +// Repro-only: delay file-preview responses so the row re-render they trigger +// lands during the actions-menu open window (the flake we are chasing). +// Usage: CYPRESS_PREVIEW_DELAY=1500 npx cypress run ... +const previewDelay = Number(Cypress.env('PREVIEW_DELAY')) +if (previewDelay > 0) { + beforeEach(() => { + cy.intercept('GET', '**/core/preview?**', (req) => { + req.on('response', (res) => { res.setDelay(previewDelay) }) + }).as('delayedPreview') + }) + // Report to the terminal how many preview requests were intercepted/delayed, + // so we can confirm the repro knob is actually engaging. + afterEach(() => { + cy.get<{ length: number }>('@delayedPreview.all', { log: false }).then((calls) => { + cy.task('log', `[preview-delay] delayed ${calls?.length ?? 0} preview request(s) by ${previewDelay}ms`) + }) + }) +} + +// Repro-only: deterministically recreate the real flake mechanism — a file-row +// preview finishing loading re-renders the row and closes a just-opened actions +// menu. A MutationObserver watches for a row action toggle reporting +// aria-expanded="true" and, at that instant, forces the row's preview to +// reload so its @load handler fires a reactive re-render right on top of the +// opening menu. Usage: CYPRESS_FORCE_RERENDER=1 npx cypress run ... +if (Cypress.env('FORCE_RERENDER')) { + Cypress.on('window:before:load', (win) => { + const forceReloadPreviewForToggle = (toggle: Element) => { + const row = toggle.closest('[data-cy-files-list-row]') + const img = row?.querySelector('.files-list__row-icon-preview, img') + if (img?.src) { + const src = img.src + img.src = '' + // Reassign on the next frame so the browser refetches and re-fires @load + win.requestAnimationFrame(() => { img.src = src.includes('?') ? `${src}&_r=${Date.now()}` : `${src}?_r=${Date.now()}` }) + } + } + const observer = new win.MutationObserver((mutations) => { + for (const m of mutations) { + const target = m.target as Element + if (m.attributeName === 'aria-expanded' + && target.getAttribute('aria-expanded') === 'true' + && target.closest('[data-cy-files-list-row-actions]')) { + forceReloadPreviewForToggle(target) + } + } + }) + win.document.addEventListener('DOMContentLoaded', () => { + observer.observe(win.document.body, { attributes: true, subtree: true, attributeFilter: ['aria-expanded'] }) + }) + }) +} From c61261d59f5f7e3cade5013b999232e5a897d4d1 Mon Sep 17 00:00:00 2001 From: David Dreschner Date: Sat, 11 Jul 2026 18:38:04 +0200 Subject: [PATCH 04/16] chore(cypress): Try to analyze failures Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: David Dreschner --- cypress/e2e/files/FilesUtils.ts | 11 +++++++--- .../e2e/files_versions/filesVersionsUtils.ts | 22 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/cypress/e2e/files/FilesUtils.ts b/cypress/e2e/files/FilesUtils.ts index fee3461a221dd..4517d1dafbbff 100644 --- a/cypress/e2e/files/FilesUtils.ts +++ b/cypress/e2e/files/FilesUtils.ts @@ -82,9 +82,10 @@ function openActionsMenu(getActionButton: () => Cypress.Chainable { getActionButton().then(($toggle) => { @@ -96,9 +97,13 @@ function openActionsMenu(getActionButton: () => Cypress.Chainable 400) { + cy.task('log', `[menu-open] SLOW rowActions opened after ${elapsed}ms transitions=[${transitions.join(' -> ')}]`, { log: false }) + } return } if (elapsed >= 20000) { + cy.task('log', `[menu-open] FAILED rowActions transitions=[${transitions.join(' -> ')}]`, { log: false }) throw new Error(`Actions menu did not open. transitions=[${transitions.join(' -> ')}]`) } // eslint-disable-next-line cypress/no-unnecessary-waiting diff --git a/cypress/e2e/files_versions/filesVersionsUtils.ts b/cypress/e2e/files_versions/filesVersionsUtils.ts index ae23dca409789..848dc03311219 100644 --- a/cypress/e2e/files_versions/filesVersionsUtils.ts +++ b/cypress/e2e/files_versions/filesVersionsUtils.ts @@ -48,6 +48,28 @@ export function toggleVersionMenu(index: number) { export function triggerVersionAction(index: number, actionName: string) { toggleVersionMenu(index) + // DIAGNOSTIC: the version-item menu is a separate NcActions instance; record + // whether the requested action ever becomes visible after the toggle click. + const poll = (elapsed: number) => { + cy.get('#tab-files_versions [data-files-versions-version]').eq(index).find('button').then(($toggle) => { + const expanded = $toggle.attr('aria-expanded') + const visible = Cypress.$(`[data-cy-files-versions-version-action="${actionName}"]:visible`).length > 0 + if (visible) { + if (elapsed > 400) { + cy.task('log', `[version-menu] SLOW action=${actionName} visible after ${elapsed}ms (lastExpanded=${expanded})`, { log: false }) + } + return + } + if (elapsed >= 15000) { + cy.task('log', `[version-menu] FAILED action=${actionName} never visible (lastExpanded=${expanded})`, { log: false }) + return + } + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(200) + poll(elapsed + 200) + }) + } + poll(0) cy.get(`[data-cy-files-versions-version-action="${actionName}"]`).filter(':visible').click() } From 457873a64faff62282510b5dfbaaafc6d51e70ed Mon Sep 17 00:00:00 2001 From: David Dreschner Date: Sat, 11 Jul 2026 18:56:01 +0200 Subject: [PATCH 05/16] chore(cypress): Try to analyze failures Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: David Dreschner --- cypress/e2e/files/FilesUtils.ts | 34 +++---------------- .../e2e/files_versions/filesVersionsUtils.ts | 28 ++++----------- 2 files changed, 11 insertions(+), 51 deletions(-) diff --git a/cypress/e2e/files/FilesUtils.ts b/cypress/e2e/files/FilesUtils.ts index 4517d1dafbbff..38d75ffaea0fc 100644 --- a/cypress/e2e/files/FilesUtils.ts +++ b/cypress/e2e/files/FilesUtils.ts @@ -82,36 +82,12 @@ function openActionsMenu(getActionButton: () => Cypress.Chainable { - getActionButton().then(($toggle) => { - const expanded = $toggle.attr('aria-expanded') - const menuId = $toggle.attr('aria-controls') - const visible = Boolean(menuId) && Cypress.$(`#${menuId}`).is(':visible') - const state = `expanded=${expanded},visible=${visible}` - if (state !== transitions[transitions.length - 1]) { - transitions.push(`${elapsed}ms:${state}`) - } - if (visible) { - if (elapsed > 400) { - cy.task('log', `[menu-open] SLOW rowActions opened after ${elapsed}ms transitions=[${transitions.join(' -> ')}]`, { log: false }) - } - return - } - if (elapsed >= 20000) { - cy.task('log', `[menu-open] FAILED rowActions transitions=[${transitions.join(' -> ')}]`, { log: false }) - throw new Error(`Actions menu did not open. transitions=[${transitions.join(' -> ')}]`) - } - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.wait(200) - poll(elapsed + 200) + getActionButton() + .should('have.attr', 'aria-controls') + .then((menuId) => { + // Generous timeout for the popover to finish positioning on slow runners + cy.get(`#${menuId}`, { timeout: 20000 }).should('be.visible') }) - } - poll(0) } /** diff --git a/cypress/e2e/files_versions/filesVersionsUtils.ts b/cypress/e2e/files_versions/filesVersionsUtils.ts index 848dc03311219..9a94648644539 100644 --- a/cypress/e2e/files_versions/filesVersionsUtils.ts +++ b/cypress/e2e/files_versions/filesVersionsUtils.ts @@ -48,28 +48,6 @@ export function toggleVersionMenu(index: number) { export function triggerVersionAction(index: number, actionName: string) { toggleVersionMenu(index) - // DIAGNOSTIC: the version-item menu is a separate NcActions instance; record - // whether the requested action ever becomes visible after the toggle click. - const poll = (elapsed: number) => { - cy.get('#tab-files_versions [data-files-versions-version]').eq(index).find('button').then(($toggle) => { - const expanded = $toggle.attr('aria-expanded') - const visible = Cypress.$(`[data-cy-files-versions-version-action="${actionName}"]:visible`).length > 0 - if (visible) { - if (elapsed > 400) { - cy.task('log', `[version-menu] SLOW action=${actionName} visible after ${elapsed}ms (lastExpanded=${expanded})`, { log: false }) - } - return - } - if (elapsed >= 15000) { - cy.task('log', `[version-menu] FAILED action=${actionName} never visible (lastExpanded=${expanded})`, { log: false }) - return - } - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.wait(200) - poll(elapsed + 200) - }) - } - poll(0) cy.get(`[data-cy-files-versions-version-action="${actionName}"]`).filter(':visible').click() } @@ -84,6 +62,12 @@ export function restoreVersion(index: number) { cy.intercept('MOVE', '**/dav/versions/*/versions/**').as('restoreVersion') triggerVersionAction(index, 'restore') cy.wait('@restoreVersion') + // After the restore, the sidebar re-fetches the versions list via a debounced + // watcher (see FilesVersionsSidebarTab.vue) and only then relabels the entries + // (e.g. drops the "Initial version" label). Waiting only for the MOVE above + // races that refresh on slow runners, so also wait for the versions reload + // (aliased in openVersionsPanel) before asserting on the updated list. + cy.wait('@getVersions') } export function deleteVersion(index: number) { From 6f1508611d4ef3d35111169a276defbd10132678 Mon Sep 17 00:00:00 2001 From: David Dreschner Date: Sat, 11 Jul 2026 20:52:15 +0200 Subject: [PATCH 06/16] fix(locks): Try to debug concurrent locks Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: David Dreschner --- apps/files_versions/lib/Storage.php | 11 ++++- .../lib/Versions/VersionManager.php | 43 ++++++++++++++++++- cypress/e2e/files/FilesUtils.ts | 38 +++++++++++----- .../e2e/files_versions/filesVersionsUtils.ts | 29 ++++++++++--- 4 files changed, 102 insertions(+), 19 deletions(-) diff --git a/apps/files_versions/lib/Storage.php b/apps/files_versions/lib/Storage.php index bd21c4f92b35e..c638316b7c30c 100644 --- a/apps/files_versions/lib/Storage.php +++ b/apps/files_versions/lib/Storage.php @@ -423,7 +423,16 @@ private static function copyFileContents($view, $path1, $path2) { [$storage2, $internalPath2] = $view->resolvePath($path2); $view->lockFile($path1, ILockingProvider::LOCK_EXCLUSIVE); - $view->lockFile($path2, ILockingProvider::LOCK_EXCLUSIVE); + try { + $view->lockFile($path2, ILockingProvider::LOCK_EXCLUSIVE); + } catch (\Throwable $e) { + // Acquiring the second lock can fail (e.g. the target file is + // transiently locked by a concurrent job under load). Release the + // first lock we already hold so a retry does not collide with a + // leaked lock. + $view->unlockFile($path1, ILockingProvider::LOCK_EXCLUSIVE); + throw $e; + } try { // TODO add a proper way of overwriting a file while maintaining file ids diff --git a/apps/files_versions/lib/Versions/VersionManager.php b/apps/files_versions/lib/Versions/VersionManager.php index 2e91a063b7021..1a398d1b341ef 100644 --- a/apps/files_versions/lib/Versions/VersionManager.php +++ b/apps/files_versions/lib/Versions/VersionManager.php @@ -22,8 +22,10 @@ use OCP\Files\Node; use OCP\Files\Storage\IStorage; use OCP\IUser; +use OCP\Lock\LockedException; use OCP\Lock\ManuallyLockedException; use OCP\Server; +use Psr\Log\LoggerInterface; class VersionManager implements IVersionManager, IDeletableVersionBackend, INeedSyncVersionBackend, IMetadataVersionBackend { @@ -100,7 +102,7 @@ public function createVersion(IUser $user, FileInfo $file) { #[\Override] public function rollback(IVersion $version) { $backend = $version->getBackend(); - $result = self::handleAppLocks(fn (): ?bool => $backend->rollback($version)); + $result = self::handleAppLocks(fn (): ?bool => self::retryOnLock(fn (): ?bool => $backend->rollback($version))); // rollback doesn't have a return type yet and some implementations don't return anything if ($result === null || $result === true) { $this->dispatcher->dispatchTyped(new VersionRestoredEvent($version)); @@ -180,6 +182,45 @@ public function setMetadataValue(Node $node, int $revision, string $key, string } } + /** + * Retry the rollback while the target file is transiently locked. + * + * Restoring a version needs an exclusive lock on the live file. Under load, + * a concurrent operation on the same file — e.g. the versions expiration + * job — can hold a lock on it, which makes the exclusive lock fail with a + * LockedException and the whole restore return HTTP 500. That lock is not + * held by our own request (so it is released independently of us) and is + * short lived, so we keep retrying with a growing backoff until it clears + * or we exceed a generous overall budget. + * + * @param callable $callback function performing the rollback + * @return bool|null + * @throws LockedException if the file stays locked for the whole budget + */ + private static function retryOnLock(callable $callback): ?bool { + // Total time we are willing to wait for a concurrent lock to clear. + $budgetMs = 15000; + $waitedMs = 0; + $backoffMs = 100; + for ($attempt = 1; ; $attempt++) { + try { + return $callback(); + } catch (LockedException $e) { + if ($waitedMs >= $budgetMs) { + throw $e; + } + Server::get(LoggerInterface::class)->debug( + 'Version rollback hit a locked file, retrying (attempt {attempt}, waited {waited}ms)', + ['attempt' => $attempt, 'waited' => $waitedMs, 'app' => 'files_versions', 'exception' => $e], + ); + usleep($backoffMs * 1000); + $waitedMs += $backoffMs; + // Grow the backoff but cap it so we keep probing regularly. + $backoffMs = min($backoffMs * 2, 1000); + } + } + } + /** * Catch ManuallyLockedException and retry in app context if possible. * diff --git a/cypress/e2e/files/FilesUtils.ts b/cypress/e2e/files/FilesUtils.ts index 38d75ffaea0fc..2da62b231a670 100644 --- a/cypress/e2e/files/FilesUtils.ts +++ b/cypress/e2e/files/FilesUtils.ts @@ -77,17 +77,35 @@ export function getInlineActionEntryForFile(file: string, actionId: string) { * @param getActionButton query for the actions menu toggle of the row */ function openActionsMenu(getActionButton: () => Cypress.Chainable>) { - getActionButton().then(($toggle) => { - if ($toggle.attr('aria-expanded') !== 'true') { - cy.wrap($toggle).click({ force: true }) // force to avoid issues with overlaying file list header - } - }) - getActionButton() - .should('have.attr', 'aria-controls') - .then((menuId) => { - // Generous timeout for the popover to finish positioning on slow runners - cy.get(`#${menuId}`, { timeout: 20000 }).should('be.visible') + // The menu open has two failure modes on slow runners, needing opposite + // responses: + // - The click is lost because the row's handler is not attached yet, so + // the toggle stays collapsed (aria-expanded="false"). We must click + // again. + // - The menu is opening but the popover is still positioning over a few + // frames (aria-expanded="true", not yet visible). Clicking again here + // would toggle it closed and tangle the show/hide transitions, so we + // must only wait. + // Poll accordingly until the menu is actually displayed. + const poll = (elapsed: number) => { + getActionButton().then(($toggle) => { + const menuId = $toggle.attr('aria-controls') + if (menuId && Cypress.$(`#${menuId}`).is(':visible')) { + return + } + if (elapsed >= 20000) { + throw new Error(`Actions menu did not open (aria-expanded=${$toggle.attr('aria-expanded')})`) + } + // Only (re)open while collapsed; never click a menu that is mid-open. + if ($toggle.attr('aria-expanded') !== 'true') { + cy.wrap($toggle).click({ force: true }) // force to avoid issues with overlaying file list header + } + // eslint-disable-next-line cypress/no-unnecessary-waiting -- give the popover a moment to open/position before re-checking + cy.wait(250) + poll(elapsed + 250) }) + } + poll(0) } /** diff --git a/cypress/e2e/files_versions/filesVersionsUtils.ts b/cypress/e2e/files_versions/filesVersionsUtils.ts index 9a94648644539..7bff93b6d7494 100644 --- a/cypress/e2e/files_versions/filesVersionsUtils.ts +++ b/cypress/e2e/files_versions/filesVersionsUtils.ts @@ -60,14 +60,29 @@ export function nameVersion(index: number, name: string) { export function restoreVersion(index: number) { cy.intercept('MOVE', '**/dav/versions/*/versions/**').as('restoreVersion') + cy.intercept('PROPFIND', '**/dav/versions/*/versions/**').as('anyVersionsPropfind') triggerVersionAction(index, 'restore') - cy.wait('@restoreVersion') - // After the restore, the sidebar re-fetches the versions list via a debounced - // watcher (see FilesVersionsSidebarTab.vue) and only then relabels the entries - // (e.g. drops the "Initial version" label). Waiting only for the MOVE above - // races that refresh on slow runners, so also wait for the versions reload - // (aliased in openVersionsPanel) before asserting on the updated list. - cy.wait('@getVersions') + cy.wait('@restoreVersion').then(({ request, response }) => { + cy.task('log', `[restore MOVE] status=${response?.statusCode} url=${(request?.url ?? '').split('/versions/').pop()} dest=${request?.headers?.destination ?? '-'}`, { log: false }) + }) + // DIAGNOSTIC: log the version-row labels over time after the restore, to see + // how long the client-side re-sort takes and whether any PROPFIND fires. + const poll = (elapsed: number) => { + cy.document({ log: false }).then((doc) => { + const rows = Array.from(doc.querySelectorAll('[data-files-versions-version]')) + const labels = rows + .map((el, i) => `${i}:'${(el.querySelector('[data-cy-files-version-label]')?.textContent ?? '').trim()}'`) + .join(' | ') + cy.task('log', `[restore+${elapsed}ms] rows=${rows.length} ${labels}`, { log: false }) + }) + if (elapsed >= 12000) { + return + } + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500) + poll(elapsed + 500) + } + poll(0) } export function deleteVersion(index: number) { From 89d776ea9c34e654b5db0cdcba75b7ea919f891e Mon Sep 17 00:00:00 2001 From: David Dreschner Date: Sat, 11 Jul 2026 21:04:16 +0200 Subject: [PATCH 07/16] chore(cypress): Try to analyze failures Signed-off-by: David Dreschner --- cypress.config.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/cypress.config.ts b/cypress.config.ts index 64e217ca7aa52..30a056a5378ea 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -26,11 +26,7 @@ export default defineConfig({ viewportHeight: 720, // Tries again when in run mode (cypress run) e.g. on CI - retries: { - runMode: 5, - // do not retry in `cypress open` - openMode: 0, - }, + retries: 0, // Needed to trigger `after:run` events with cypress open experimentalInteractiveRunEvents: true, From a6923c4d2e7e72ea94b25cbc76f1982321dad5a0 Mon Sep 17 00:00:00 2001 From: David Dreschner Date: Sat, 11 Jul 2026 22:22:46 +0200 Subject: [PATCH 08/16] fix(locking): Try to analyze locked files during e2e test Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: David Dreschner --- apps/files_versions/lib/Storage.php | 14 ++++++ apps/files_versions/tests/VersioningTest.php | 52 ++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/apps/files_versions/lib/Storage.php b/apps/files_versions/lib/Storage.php index c638316b7c30c..b7ce2d53a202d 100644 --- a/apps/files_versions/lib/Storage.php +++ b/apps/files_versions/lib/Storage.php @@ -426,6 +426,20 @@ private static function copyFileContents($view, $path1, $path2) { try { $view->lockFile($path2, ILockingProvider::LOCK_EXCLUSIVE); } catch (\Throwable $e) { + // DIAGNOSTIC: dump all currently-held file locks to identify the + // concurrent holder blocking the restore (lock: -1 exclusive, >0 shared). + try { + $db = \OCP\Server::get(\OCP\IDBConnection::class); + $q = $db->getQueryBuilder(); + $q->select('key', 'lock')->from('file_locks') + ->where($q->expr()->neq('lock', $q->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))); + $held = $q->executeQuery()->fetchAll(); + \OCP\Server::get(\Psr\Log\LoggerInterface::class)->error( + '[restore-lock-diag] target=' . $internalPath2 . ' heldLocks=' . json_encode($held), + ['app' => 'files_versions'], + ); + } catch (\Throwable $ignore) { + } // Acquiring the second lock can fail (e.g. the target file is // transiently locked by a concurrent job under load). Release the // first lock we already hold so a retry does not collide with a diff --git a/apps/files_versions/tests/VersioningTest.php b/apps/files_versions/tests/VersioningTest.php index e0e738bf81237..91d39930ede99 100644 --- a/apps/files_versions/tests/VersioningTest.php +++ b/apps/files_versions/tests/VersioningTest.php @@ -27,6 +27,8 @@ use OCP\IConfig; use OCP\IUser; use OCP\IUserManager; +use OCP\Lock\ILockingProvider; +use OCP\Lock\LockedException; use OCP\Server; use OCP\Share\IShare; use OCP\User\Exceptions\UserNotFoundException; @@ -763,6 +765,56 @@ function ($p) use (&$params): void { ); } + /** + * Restoring a version needs an exclusive lock on the live file. When another + * request holds a shared (read) lock on that file at the same time — most + * notably the versions sidebar generating a preview of the file via + * OCA\Files_Versions\Controller\PreviewController — the exclusive lock cannot + * be acquired and the restore throws a LockedException (surfacing as an HTTP + * 500). This test reproduces that collision deterministically. + */ + public function testRestoreFailsWhileFileIsReadLocked(): void { + $filePath = self::TEST_VERSIONS_USER . '/files/sub/test.txt'; + $this->rootView->mkdir(self::TEST_VERSIONS_USER . '/files/sub'); + $this->rootView->file_put_contents($filePath, 'test file'); + $fileInfo = $this->rootView->getFileInfo($filePath); + + // Create a single older version to restore to. + $t2 = time() - 60 * 60 * 24 * 14; + $v2 = self::USERS_VERSIONS_ROOT . '/sub/test.txt.v' . $t2; + $this->rootView->mkdir(self::USERS_VERSIONS_ROOT . '/sub'); + $this->rootView->file_put_contents($v2, 'version2'); + $fileInfoV2 = $this->rootView->getFileInfo($v2); + $versionEntity = new VersionEntity(); + $versionEntity->setFileId($fileInfo->getId()); + $versionEntity->setTimestamp($t2); + $versionEntity->setSize($fileInfoV2->getSize()); + $versionEntity->setMimetype($this->mimeTypeLoader->getId($fileInfoV2->getMimetype())); + $versionEntity->setMetadata([]); + $this->versionsMapper->insert($versionEntity); + + // Simulate a concurrent version-preview read holding a shared lock. + $userView = new View('/' . self::TEST_VERSIONS_USER . '/files'); + $userView->lockFile('/sub/test.txt', ILockingProvider::LOCK_SHARED); + + // The low-level restore takes an exclusive lock on the live file, which + // must fail while the shared lock is held — this is the bug behind the 500. + $threw = false; + try { + Storage::rollback('/sub/test.txt', $t2, $this->user1); + } catch (LockedException $e) { + $threw = true; + } + $this->assertTrue($threw, 'Restore should fail with LockedException while the file is read-locked'); + $this->assertEquals('test file', $this->rootView->file_get_contents($filePath), 'File must be unchanged after the failed restore'); + + // Once the read lock is released, the very same restore succeeds — proving + // the shared lock is the sole cause of the failure. + $userView->unlockFile('/sub/test.txt', ILockingProvider::LOCK_SHARED); + $this->assertTrue(Storage::rollback('/sub/test.txt', $t2, $this->user1)); + $this->assertEquals('version2', $this->rootView->file_get_contents($filePath)); + } + private function doTestRestore(): void { $filePath = self::TEST_VERSIONS_USER . '/files/sub/test.txt'; $this->rootView->file_put_contents($filePath, 'test file'); From 6fe51249594486c719c8d8dd45e111ef7c313e01 Mon Sep 17 00:00:00 2001 From: David Dreschner Date: Sat, 11 Jul 2026 22:28:45 +0200 Subject: [PATCH 09/16] chore: Fix lint errors Signed-off-by: David Dreschner --- cypress/support/e2e.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 3e6b99ea8e0ab..cc0b63287a4c0 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -46,7 +46,9 @@ const previewDelay = Number(Cypress.env('PREVIEW_DELAY')) if (previewDelay > 0) { beforeEach(() => { cy.intercept('GET', '**/core/preview?**', (req) => { - req.on('response', (res) => { res.setDelay(previewDelay) }) + req.on('response', (res) => { + res.setDelay(previewDelay) + }) }).as('delayedPreview') }) // Report to the terminal how many preview requests were intercepted/delayed, @@ -73,7 +75,9 @@ if (Cypress.env('FORCE_RERENDER')) { const src = img.src img.src = '' // Reassign on the next frame so the browser refetches and re-fires @load - win.requestAnimationFrame(() => { img.src = src.includes('?') ? `${src}&_r=${Date.now()}` : `${src}?_r=${Date.now()}` }) + win.requestAnimationFrame(() => { + img.src = src.includes('?') ? `${src}&_r=${Date.now()}` : `${src}?_r=${Date.now()}` + }) } } const observer = new win.MutationObserver((mutations) => { From 747d33e6b19a6900383a0abb7810cd8308557701 Mon Sep 17 00:00:00 2001 From: David Dreschner Date: Sun, 12 Jul 2026 01:19:20 +0200 Subject: [PATCH 10/16] chore(cypress): Try to analyze failures Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: David Dreschner --- cypress/e2e/files/FilesUtils.ts | 2 +- .../e2e/files_versions/filesVersionsUtils.ts | 64 ++++++++++--------- 2 files changed, 36 insertions(+), 30 deletions(-) diff --git a/cypress/e2e/files/FilesUtils.ts b/cypress/e2e/files/FilesUtils.ts index 2da62b231a670..ffa1eedb4aadd 100644 --- a/cypress/e2e/files/FilesUtils.ts +++ b/cypress/e2e/files/FilesUtils.ts @@ -76,7 +76,7 @@ export function getInlineActionEntryForFile(file: string, actionId: string) { * * @param getActionButton query for the actions menu toggle of the row */ -function openActionsMenu(getActionButton: () => Cypress.Chainable>) { +export function openActionsMenu(getActionButton: () => Cypress.Chainable>) { // The menu open has two failure modes on slow runners, needing opposite // responses: // - The click is lost because the row's handler is not attached yet, so diff --git a/cypress/e2e/files_versions/filesVersionsUtils.ts b/cypress/e2e/files_versions/filesVersionsUtils.ts index 7bff93b6d7494..7c5efc8970c7e 100644 --- a/cypress/e2e/files_versions/filesVersionsUtils.ts +++ b/cypress/e2e/files_versions/filesVersionsUtils.ts @@ -7,7 +7,7 @@ import type { User } from '@nextcloud/e2e-test-server/cypress' import type { ShareSetting } from '../files_sharing/FilesSharingUtils.ts' import { basename } from '@nextcloud/paths' -import { triggerActionForFile } from '../files/FilesUtils.ts' +import { openActionsMenu, triggerActionForFile } from '../files/FilesUtils.ts' import { createShare } from '../files_sharing/FilesSharingUtils.ts' export function uploadThreeVersions(user: User, fileName: string) { @@ -39,15 +39,40 @@ export function openVersionsPanel(fileName: string) { cy.get('#tab-files_versions').should('be.visible', { timeout: 10000 }) } -export function toggleVersionMenu(index: number) { - cy.get('#tab-files_versions [data-files-versions-version]') +function getVersionMenuToggle(index: number) { + return cy.get('#tab-files_versions [data-files-versions-version]') .eq(index) .find('button') - .click() +} + +/** + * Open a version's actions menu. The version rows use the same NcActions menu + * as the file list, which on slow (CI) runners can drop the opening click or + * take several frames to display the popover — so open it with the shared + * robust helper instead of a single naive click. + * + * @param index the version row index + */ +export function openVersionMenu(index: number) { + openActionsMenu(() => getVersionMenuToggle(index)) +} + +/** + * Close a version's actions menu that was opened with openVersionMenu. Clicking + * the toggle while it is expanded collapses the popover. + * + * @param index the version row index + */ +export function closeVersionMenu(index: number) { + getVersionMenuToggle(index).then(($toggle) => { + if ($toggle.attr('aria-expanded') === 'true') { + cy.wrap($toggle).click({ force: true }) + } + }) } export function triggerVersionAction(index: number, actionName: string) { - toggleVersionMenu(index) + openVersionMenu(index) cy.get(`[data-cy-files-versions-version-action="${actionName}"]`).filter(':visible').click() } @@ -60,29 +85,8 @@ export function nameVersion(index: number, name: string) { export function restoreVersion(index: number) { cy.intercept('MOVE', '**/dav/versions/*/versions/**').as('restoreVersion') - cy.intercept('PROPFIND', '**/dav/versions/*/versions/**').as('anyVersionsPropfind') triggerVersionAction(index, 'restore') - cy.wait('@restoreVersion').then(({ request, response }) => { - cy.task('log', `[restore MOVE] status=${response?.statusCode} url=${(request?.url ?? '').split('/versions/').pop()} dest=${request?.headers?.destination ?? '-'}`, { log: false }) - }) - // DIAGNOSTIC: log the version-row labels over time after the restore, to see - // how long the client-side re-sort takes and whether any PROPFIND fires. - const poll = (elapsed: number) => { - cy.document({ log: false }).then((doc) => { - const rows = Array.from(doc.querySelectorAll('[data-files-versions-version]')) - const labels = rows - .map((el, i) => `${i}:'${(el.querySelector('[data-cy-files-version-label]')?.textContent ?? '').trim()}'`) - .join(' | ') - cy.task('log', `[restore+${elapsed}ms] rows=${rows.length} ${labels}`, { log: false }) - }) - if (elapsed >= 12000) { - return - } - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.wait(500) - poll(elapsed + 500) - } - poll(0) + cy.wait('@restoreVersion') } export function deleteVersion(index: number) { @@ -92,9 +96,11 @@ export function deleteVersion(index: number) { } export function doesNotHaveAction(index: number, actionName: string) { - toggleVersionMenu(index) + openVersionMenu(index) cy.get(`[data-cy-files-versions-version-action="${actionName}"]`).should('not.exist') - toggleVersionMenu(index) + // Close the menu again so its entries do not leak into the next assertion + // (the action query above is global). + closeVersionMenu(index) } export function assertVersionContent(index: number, expectedContent: string) { From 65836b1612488b403e392931709ebaac6a31a546 Mon Sep 17 00:00:00 2001 From: David Dreschner Date: Sun, 12 Jul 2026 01:46:52 +0200 Subject: [PATCH 11/16] chore(cypress): Try to analyze failures Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: David Dreschner --- core/Controller/CSRFTokenController.php | 7 ++++ core/Controller/LoginController.php | 39 +++++++++++++++++ .../files_versions/login_flake_repro.cy.ts | 39 +++++++++++++++++ cypress/support/commands.ts | 42 +++++++++++++++++++ 4 files changed, 127 insertions(+) create mode 100644 cypress/e2e/files_versions/login_flake_repro.cy.ts diff --git a/core/Controller/CSRFTokenController.php b/core/Controller/CSRFTokenController.php index c44b5f99ea95e..fd82ebd98d06f 100644 --- a/core/Controller/CSRFTokenController.php +++ b/core/Controller/CSRFTokenController.php @@ -44,6 +44,13 @@ public function __construct( #[NoTwoFactorRequired] public function index(): JSONResponse { if (!$this->request->passesStrictCookieCheck()) { + // [login-diag] A 403 here means cy.login receives no token, so its + // POST /login then fails the CSRF check -> validate 401. Record it. + \OCP\Server::get(\Psr\Log\LoggerInterface::class)->error( + '[login-diag] CSRFTOKEN 403 strictCookieCheck failed remote=' . $this->request->getRemoteAddress() + . ' cookies=' . implode(',', array_keys($_COOKIE ?? [])), + ['app' => 'login-diag'], + ); return new JSONResponse([], Http::STATUS_FORBIDDEN); } diff --git a/core/Controller/LoginController.php b/core/Controller/LoginController.php index 5c11c4bba83dc..f9f1628e267ef 100644 --- a/core/Controller/LoginController.php +++ b/core/Controller/LoginController.php @@ -366,12 +366,46 @@ public function tryLogin( ); } + // [login-diag] Successful login: record so we can tell a login-logic + // failure apart from a session/cookie propagation failure on the + // subsequent validate request. + $this->logLoginDiag('LOGIN OK', $user); + if ($result->getRedirectUrl() !== null) { return new RedirectResponse($result->getRedirectUrl()); } return $this->generateRedirect($redirect_url); } + /** + * [login-diag] Throwaway diagnostic logger for the e2e login flake. Emits a + * single correlatable line (session id hash, remote addr, throttle delay) + * so we can trace a failed `cy.login` back to its server-side cause. Must + * never break the login flow, hence the broad catch. + */ + private function logLoginDiag(string $what, string $user): void { + try { + $remote = $this->request->getRemoteAddress(); + try { + $session = substr(md5((string)$this->session->getId()), 0, 8); + } catch (\Throwable) { + $session = 'n/a'; + } + \OCP\Server::get(\Psr\Log\LoggerInterface::class)->error( + '[login-diag] ' . $what + . ' user=' . $user + . ' remote=' . $remote + . ' session=' . $session + . ' csrf=' . ($this->request->passesCSRFCheck() ? '1' : '0') + . ' origin=' . $this->request->getHeader('Origin') + . ' delay=' . $this->throttler->getDelay($remote, 'login'), + ['app' => 'login-diag'], + ); + } catch (\Throwable) { + // diagnostics must never affect the login path + } + } + /** * Creates a login failed response. * @@ -389,6 +423,11 @@ private function createLoginFailedResponse( string $loginMessage, bool $throttle = true, ) { + // [login-diag] Every login failure path funnels through here, so this is + // the single point that reveals *why* a login POST did not establish a + // session (invalidOrigin / csrfCheckFailed / invalidpassword / ...). + $this->logLoginDiag('LOGIN FAILED reason=' . $loginMessage . ' throttle=' . ($throttle ? '1' : '0'), (string)$originalUser); + // Read current user and append if possible we need to // return the unmodified user otherwise we will leak the login name $args = $user !== null ? ['user' => $originalUser, 'direct' => 1] : []; diff --git a/cypress/e2e/files_versions/login_flake_repro.cy.ts b/cypress/e2e/files_versions/login_flake_repro.cy.ts new file mode 100644 index 0000000000000..beacad9186d00 --- /dev/null +++ b/cypress/e2e/files_versions/login_flake_repro.cy.ts @@ -0,0 +1,39 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * [login-diag] THROWAWAY reproduction spec for the flaky session-validation 401 + * seen in version_deletion.cy.ts ("Delete versions of shared file with delete + * permission"). It hammers the exact failing path — a freshly created user + * logging in (fresh cy.session setup + validate) — many times against a single + * CPU-throttled container, so we can catch the intermittent + * `GET /apps/files -> 401` and correlate it with the [login-diag] server log. + * + * Run: E2E_SERVER_CPUS=0.2 npx cypress run --e2e \ + * --spec cypress/e2e/files_versions/login_flake_repro.cy.ts + */ + +import { setupTestSharedFileFromUser, uploadThreeVersions } from './filesVersionsUtils.ts' +import { randomString } from '../../support/utils/randomString.ts' + +describe('[login-diag] login flake repro', () => { + const folderName = 'shared_folder' + + for (let i = 0; i < 25; i++) { + it(`fresh-user login+share cycle ${i}`, () => { + // Mirror version_deletion's beforeEach + setupTestSharedFileFromUser: + // two fresh users each doing a fresh-session login, with some DAV + // load in between (uploads + a share), all under CPU starvation. + cy.createRandomUser().then((user) => { + const randomFilePath = `/${folderName}/${randomString(10)}.txt` + cy.mkdir(user, `/${folderName}`) + uploadThreeVersions(user, randomFilePath) + cy.login(user) + cy.visit('/apps/files') + // This inner call logs in a *freshly created recipient* — the + // login that failed on CI. + setupTestSharedFileFromUser(user, folderName, { delete: true }) + }) + }) + } +}) diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index e8e66b2b714de..50f2db847d280 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -283,3 +283,45 @@ Cypress.Commands.add('runOccCommand', (command: string, options?: Partial context) }) }) + +// [login-diag] Throwaway instrumented reimplementation of the library `login` +// command to trace the flaky session-validation 401. It mirrors +// @nextcloud/e2e-test-server's login but logs, for each fresh session: +// - the /csrftoken status + whether a token came back +// - the /login POST status + Location header (the ONLY thing distinguishing a +// 303-to-default-page success from a 303-back-to-login failure) +// - the validate GET /apps/files status +// so the terminal output correlates 1:1 with the [login-diag] server log lines. +Cypress.Commands.overwrite('login', (_originalFn, user: User) => { + const origin = (Cypress.config('baseUrl') ?? '').replace('index.php/', '') + cy.session(user, () => { + cy.request('/csrftoken').then(({ status, body }) => { + const token = body?.token + cy.task('log', `[login-diag] csrftoken user=${user.userId} status=${status} hasToken=${!!token}`) + cy.request({ + method: 'POST', + url: '/login', + body: { + user: user.userId, + password: user.password, + requesttoken: token, + }, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Origin: origin, + }, + followRedirect: false, + failOnStatusCode: false, + }).then((res) => { + cy.task('log', `[login-diag] POST /login user=${user.userId} status=${res.status} location=${res.headers?.location ?? '-'}`) + }) + }) + }, { + validate() { + cy.request({ url: '/apps/files', failOnStatusCode: false }).then((res) => { + cy.task('log', `[login-diag] validate /apps/files user=${user.userId} status=${res.status}`) + expect(res.status).to.eq(200) + }) + }, + }) +}) From 9a8b79d753c86ebe20d06ee28e508bb262c9a1b0 Mon Sep 17 00:00:00 2001 From: David Dreschner Date: Sun, 12 Jul 2026 02:14:40 +0200 Subject: [PATCH 12/16] chore(cypress): Try to analyze failures on login Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: David Dreschner --- core/Controller/CSRFTokenController.php | 17 +++++++++++++++ core/Controller/LoginController.php | 28 +++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/core/Controller/CSRFTokenController.php b/core/Controller/CSRFTokenController.php index fd82ebd98d06f..f041a0fe5663d 100644 --- a/core/Controller/CSRFTokenController.php +++ b/core/Controller/CSRFTokenController.php @@ -56,6 +56,23 @@ public function index(): JSONResponse { $requestToken = $this->tokenManager->getToken(); + // [login-diag] Record the session this token was issued into, so a later + // csrfCheckFailed on /login can be matched against it: if the /login + // session/sessCookie differs from what /csrftoken issued into, the token + // legitimately isn't in /login's session (a session-mismatch race). + try { + $session = \OCP\Server::get(\OCP\ISession::class); + $raw = $this->request->getCookie(session_name()); + \OCP\Server::get(\Psr\Log\LoggerInterface::class)->error( + '[login-diag] CSRFTOKEN issued remote=' . $this->request->getRemoteAddress() + . ' session=' . substr(md5((string)$session->getId()), 0, 8) + . ' sessCookie=' . ($raw !== null ? substr(md5($raw), 0, 8) : 'none'), + ['app' => 'login-diag'], + ); + } catch (\Throwable) { + // diagnostics must never affect the token endpoint + } + return new JSONResponse([ 'token' => $requestToken->getEncryptedValue(), ]); diff --git a/core/Controller/LoginController.php b/core/Controller/LoginController.php index f9f1628e267ef..946a8b85965a3 100644 --- a/core/Controller/LoginController.php +++ b/core/Controller/LoginController.php @@ -391,11 +391,39 @@ private function logLoginDiag(string $what, string $user): void { } catch (\Throwable) { $session = 'n/a'; } + // Break down passesCSRFCheck() so a csrfCheckFailed tells us WHICH + // sub-check failed: the strict same-site cookie check, or the token + // validation (a missing session 'requesttoken' means the /login + // request landed on a different session than /csrftoken issued into). + try { + $strictCookie = $this->request->passesStrictCookieCheck() ? '1' : '0'; + } catch (\Throwable) { + $strictCookie = 'err'; + } + try { + $sessHasReqToken = $this->session->exists('requesttoken') ? '1' : '0'; + } catch (\Throwable) { + $sessHasReqToken = 'err'; + } + // Hash of the session cookie the client actually sent, to compare the + // /csrftoken-issuing session against the /login session. + $sessCookie = 'none'; + try { + $raw = $this->request->getCookie(session_name()); + if ($raw !== null) { + $sessCookie = substr(md5($raw), 0, 8); + } + } catch (\Throwable) { + $sessCookie = 'err'; + } \OCP\Server::get(\Psr\Log\LoggerInterface::class)->error( '[login-diag] ' . $what . ' user=' . $user . ' remote=' . $remote . ' session=' . $session + . ' sessCookie=' . $sessCookie + . ' strictCookie=' . $strictCookie + . ' sessHasReqToken=' . $sessHasReqToken . ' csrf=' . ($this->request->passesCSRFCheck() ? '1' : '0') . ' origin=' . $this->request->getHeader('Origin') . ' delay=' . $this->throttler->getDelay($remote, 'login'), From ee287303a440cf70e20d4fddb53114ccef25648b Mon Sep 17 00:00:00 2001 From: David Dreschner Date: Sun, 12 Jul 2026 14:25:07 +0200 Subject: [PATCH 13/16] chore(cypress): Try to analyze failures Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: David Dreschner --- cypress/e2e/files/FilesUtils.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cypress/e2e/files/FilesUtils.ts b/cypress/e2e/files/FilesUtils.ts index ffa1eedb4aadd..d19b28fa28a40 100644 --- a/cypress/e2e/files/FilesUtils.ts +++ b/cypress/e2e/files/FilesUtils.ts @@ -129,6 +129,14 @@ export function triggerActionForFileId(fileid: number, actionId: string) { * @param actionId */ export function triggerActionForFile(filename: string, actionId: string) { + // [menu-diag] log the searched file vs. what the list actually shows, plus + // the current folder URL, so a "row-actions not found" failure reveals + // whether the view is in the wrong folder or the file is simply missing. + cy.url({ log: false }).then((url) => { + const names = Array.from(Cypress.$('[data-cy-files-list-row-name]')) + .map((e) => (e as HTMLElement).getAttribute('data-cy-files-list-row-name')) + cy.task('log', `[menu-diag] triggerActionForFile target="${filename}" action=${actionId} url=${url} rows=[${names.join(' | ')}]`, { log: false }) + }) getActionButtonForFile(filename) .scrollIntoView() openActionsMenu(() => getActionButtonForFile(filename)) From 4da93ff4577e9378b244767f1b99c5d5c83bc73c Mon Sep 17 00:00:00 2001 From: David Dreschner Date: Sun, 12 Jul 2026 15:02:58 +0200 Subject: [PATCH 14/16] chore(cypress): Try to analyze failures Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: David Dreschner --- cypress/e2e/files/FilesUtils.ts | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/cypress/e2e/files/FilesUtils.ts b/cypress/e2e/files/FilesUtils.ts index d19b28fa28a40..aa7c8f72e4720 100644 --- a/cypress/e2e/files/FilesUtils.ts +++ b/cypress/e2e/files/FilesUtils.ts @@ -129,14 +129,6 @@ export function triggerActionForFileId(fileid: number, actionId: string) { * @param actionId */ export function triggerActionForFile(filename: string, actionId: string) { - // [menu-diag] log the searched file vs. what the list actually shows, plus - // the current folder URL, so a "row-actions not found" failure reveals - // whether the view is in the wrong folder or the file is simply missing. - cy.url({ log: false }).then((url) => { - const names = Array.from(Cypress.$('[data-cy-files-list-row-name]')) - .map((e) => (e as HTMLElement).getAttribute('data-cy-files-list-row-name')) - cy.task('log', `[menu-diag] triggerActionForFile target="${filename}" action=${actionId} url=${url} rows=[${names.join(' | ')}]`, { log: false }) - }) getActionButtonForFile(filename) .scrollIntoView() openActionsMenu(() => getActionButtonForFile(filename)) @@ -238,8 +230,13 @@ export function moveFile(fileName: string, dirPath: string) { .findByRole('button', { name: 'All files' }) .should('be.visible') .click() - // click move - cy.contains('button', 'Move').should('be.visible').click() + // Click move. Match the confirm button EXACTLY as "Move": the picker + // labels it "Move to {folder}" while inside a folder and only "Move" + // once it has navigated to the home root. A loose `contains('Move')` + // would match the stale "Move to {folder}" button before the "All + // files" navigation above has landed, moving the file into the wrong + // folder. The exact match makes cypress wait for the root button. + cy.contains('button', /^Move$/).should('be.visible').click() } else if (dirPath === '.') { // click move cy.contains('button', 'Copy').should('be.visible').click() @@ -277,8 +274,14 @@ export function copyFile(fileName: string, dirPath: string) { .findByRole('button', { name: 'All files' }) .should('be.visible') .click() - // click copy - cy.contains('button', 'Copy').should('be.visible').click() + // Click copy. Match the confirm button EXACTLY as "Copy": the picker + // labels it "Copy to {folder}" while inside a folder and only "Copy" + // once it has navigated to the home root. A loose `contains('Copy')` + // would match the stale "Copy to {folder}" button before the "All + // files" navigation above has landed, copying the file into the wrong + // folder (deduplicated as "… (1)"). The exact match makes cypress + // wait for the root button. + cy.contains('button', /^Copy$/).should('be.visible').click() } else if (dirPath === '.') { // click copy cy.contains('button', 'Copy').should('be.visible').click() From bfa660846b9fb1014dd84337846efe4be6896319 Mon Sep 17 00:00:00 2001 From: David Dreschner Date: Sun, 12 Jul 2026 15:06:25 +0200 Subject: [PATCH 15/16] chore(cypress): Try to analyze failures Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: David Dreschner --- core/Controller/CSRFTokenController.php | 11 ++++++++++- core/Controller/LoginController.php | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/core/Controller/CSRFTokenController.php b/core/Controller/CSRFTokenController.php index f041a0fe5663d..03a95aae09142 100644 --- a/core/Controller/CSRFTokenController.php +++ b/core/Controller/CSRFTokenController.php @@ -63,10 +63,19 @@ public function index(): JSONResponse { try { $session = \OCP\Server::get(\OCP\ISession::class); $raw = $this->request->getCookie(session_name()); + // Hash of the raw token value stored in this session. The matching + // /login line logs the same storedTok; if they differ (or /login has + // none) the two requests hit different sessions -> csrfCheckFailed. + $storedTok = 'n/a'; + $t = $session->get('requesttoken'); + if (is_string($t) && $t !== '') { + $storedTok = substr(md5($t), 0, 8); + } \OCP\Server::get(\Psr\Log\LoggerInterface::class)->error( '[login-diag] CSRFTOKEN issued remote=' . $this->request->getRemoteAddress() . ' session=' . substr(md5((string)$session->getId()), 0, 8) - . ' sessCookie=' . ($raw !== null ? substr(md5($raw), 0, 8) : 'none'), + . ' sessCookie=' . ($raw !== null ? substr(md5($raw), 0, 8) : 'none') + . ' storedTok=' . $storedTok, ['app' => 'login-diag'], ); } catch (\Throwable) { diff --git a/core/Controller/LoginController.php b/core/Controller/LoginController.php index 946a8b85965a3..cdbea8e47cf23 100644 --- a/core/Controller/LoginController.php +++ b/core/Controller/LoginController.php @@ -416,6 +416,29 @@ private function logLoginDiag(string $what, string $user): void { } catch (\Throwable) { $sessCookie = 'err'; } + // Hash of the raw CSRF token value STORED in this session, and of the + // value the client PROVIDED. If the /login session is the same one + // /csrftoken issued into, storedTok here must equal the storedTok that + // CSRFTOKEN logged. A mismatch proves the two requests hit different + // sessions (or the token rotated) — the real csrfCheckFailed cause. + $storedTok = 'n/a'; + try { + $t = $this->session->get('requesttoken'); + if (is_string($t) && $t !== '') { + $storedTok = substr(md5($t), 0, 8); + } + } catch (\Throwable) { + $storedTok = 'err'; + } + $providedTok = 'none'; + try { + $p = $this->request->getParam('requesttoken'); + if (is_string($p) && $p !== '') { + $providedTok = substr(md5($p), 0, 8); + } + } catch (\Throwable) { + $providedTok = 'err'; + } \OCP\Server::get(\Psr\Log\LoggerInterface::class)->error( '[login-diag] ' . $what . ' user=' . $user @@ -424,6 +447,8 @@ private function logLoginDiag(string $what, string $user): void { . ' sessCookie=' . $sessCookie . ' strictCookie=' . $strictCookie . ' sessHasReqToken=' . $sessHasReqToken + . ' storedTok=' . $storedTok + . ' providedTok=' . $providedTok . ' csrf=' . ($this->request->passesCSRFCheck() ? '1' : '0') . ' origin=' . $this->request->getHeader('Origin') . ' delay=' . $this->throttler->getDelay($remote, 'login'), From 26f5879a5a6f2b2d30b0342a06d50a041015b072 Mon Sep 17 00:00:00 2001 From: David Dreschner Date: Sun, 12 Jul 2026 15:17:15 +0200 Subject: [PATCH 16/16] chore(cypress): Try to analyze failures Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: David Dreschner --- cypress/e2e/files/FilesUtils.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cypress/e2e/files/FilesUtils.ts b/cypress/e2e/files/FilesUtils.ts index aa7c8f72e4720..606b2ef51a0af 100644 --- a/cypress/e2e/files/FilesUtils.ts +++ b/cypress/e2e/files/FilesUtils.ts @@ -230,13 +230,15 @@ export function moveFile(fileName: string, dirPath: string) { .findByRole('button', { name: 'All files' }) .should('be.visible') .click() + // [picker-diag] log the exact button texts to see the root label + cy.get('button').then(($b) => cy.task('log', '[picker-diag] move buttons=' + JSON.stringify(Array.from($b).map((x) => (x as HTMLElement).textContent)), { log: false })) // Click move. Match the confirm button EXACTLY as "Move": the picker // labels it "Move to {folder}" while inside a folder and only "Move" // once it has navigated to the home root. A loose `contains('Move')` // would match the stale "Move to {folder}" button before the "All // files" navigation above has landed, moving the file into the wrong // folder. The exact match makes cypress wait for the root button. - cy.contains('button', /^Move$/).should('be.visible').click() + cy.contains('button', /^\s*Move\s*$/).should('be.visible').click() } else if (dirPath === '.') { // click move cy.contains('button', 'Copy').should('be.visible').click() @@ -274,6 +276,8 @@ export function copyFile(fileName: string, dirPath: string) { .findByRole('button', { name: 'All files' }) .should('be.visible') .click() + // [picker-diag] log the exact button texts to see the root label + cy.get('button').then(($b) => cy.task('log', '[picker-diag] copy buttons=' + JSON.stringify(Array.from($b).map((x) => (x as HTMLElement).textContent)), { log: false })) // Click copy. Match the confirm button EXACTLY as "Copy": the picker // labels it "Copy to {folder}" while inside a folder and only "Copy" // once it has navigated to the home root. A loose `contains('Copy')` @@ -281,7 +285,7 @@ export function copyFile(fileName: string, dirPath: string) { // files" navigation above has landed, copying the file into the wrong // folder (deduplicated as "… (1)"). The exact match makes cypress // wait for the root button. - cy.contains('button', /^Copy$/).should('be.visible').click() + cy.contains('button', /^\s*Copy\s*$/).should('be.visible').click() } else if (dirPath === '.') { // click copy cy.contains('button', 'Copy').should('be.visible').click()