Files

143 lines
8.4 KiB
JavaScript

// Actual disposable Nextcloud pagination; never an installed-provider probe.
const assert = require('node:assert/strict')
const fs = require('node:fs/promises')
const { randomUUID } = require('node:crypto')
const COUNT = 251 // Production Nextcloud PAGE_ITEMS is 250; do not shrink it for tests.
const nameOf = index => `page-${String(index).padStart(4, '0')}.txt`
const bytesOf = name => `Owned pagination fixture: ${name}\n`
const pathOf = folder => `/remote.php/dav/files/alice/${encodeURIComponent(folder)}/`
async function createNew(api, method, path, data, timeout = 5000, onFailure = () => {}) {
const response = await api.fetch(path, { method, data, timeout, maxRedirects: 0, maxRetries: 0,
headers: { 'Content-Type': 'text/plain', 'If-None-Match': '*' } })
try { assert.equal(response.status(), 201, `${method} must create owned data, never overwrite`) }
catch (error) { onFailure(error); throw error }
finally { await response.dispose() }
}
// Exported for fake-transport control tests only. Real entry is guarded below.
async function seedPagination(api, folder) {
assert.match(folder, /^archy-pagination-[a-f0-9-]{36}$/)
const deadline = Date.now() + 30000
const remaining = () => {
const value = deadline - Date.now()
assert.ok(value > 0, 'Pagination seed deadline exceeded')
return Math.min(value, 5000)
}
await createNew(api, 'MKCOL', pathOf(folder), '', remaining())
let next = 0, failure = null
await Promise.all(Array.from({ length: 4 }, async () => {
while (!failure && next < COUNT) {
const name = nameOf(next++)
try { await createNew(api, 'PUT', pathOf(folder) + name, bytesOf(name), remaining(), error => { failure ??= error }) }
catch (error) { failure ??= error }
}
}))
if (failure) throw failure
return Array.from({ length: COUNT }, (_, index) => nameOf(index))
}
async function exerciseNextcloudPagination({ page, request, expect, connectionId, rpc }) {
assert.equal(process.env.ARCHY_TEST_ISOLATED, '1')
assert.equal(process.env.ARCHY_TEST_CLOUD_UPSTREAM, 'nextcloud29')
const canary = process.env.ARCHY_TEST_HOST_CANARY || ''
assert.match(canary, /^\/tmp\/archy-cloud-test\.[^/]+\/host-only-canary$/)
await assert.rejects(fs.access(canary), { code: 'ENOENT' })
// Separate request context: never forward the backend's session cookies.
const api = await request.newContext({ baseURL: 'http://127.0.0.1', timeout: 5000,
extraHTTPHeaders: { Authorization: 'Basic ' + Buffer.from('alice:Synthetic-Cloud-A-Only-2026!').toString('base64') } })
const folder = `archy-pagination-${randomUUID()}`
const section = page.getByRole('region', { name: 'Connected libraries', exact: true })
const downloads = section.getByRole('button', { name: /^Download page-\d{4}\.txt$/ })
const names = () => downloads.evaluateAll(nodes => nodes.map(node => node.getAttribute('aria-label').slice('Download '.length)))
const responseFor = number => page.waitForResponse(response => {
if (new URL(response.url()).pathname !== '/rpc/v1' || response.request().method() !== 'POST') return false
const body = response.request().postDataJSON()
return body?.method === 'cloud.sources.browse' && body.params?.connection_id === connectionId
&& body.params.page === number && JSON.stringify(body.params.folder) === JSON.stringify([folder])
})
const resultOf = async response => {
assert.equal(response.status(), 200)
const body = await response.json()
assert.ok(!body.error && body.result, 'Actual browse must succeed')
return body.result
}
try {
const expected = await seedPagination(api, folder)
await section.getByRole('button', { name: 'Refresh', exact: true }).click()
await expect(section.getByRole('button', { name: folder, exact: true })).toBeVisible()
const firstResponse = responseFor(1)
await section.getByRole('button', { name: folder, exact: true }).click()
const first = await resultOf(await firstResponse)
assert.equal(first.items.length, 250)
assert.equal(first.next_page, 2)
assert.match(first.listing_revision, /^[a-f0-9]{64}$/)
await expect(downloads).toHaveCount(250)
assert.deepEqual((await names()).sort(), first.items.map(item => item.name).sort())
const secondResponse = responseFor(2)
await section.getByRole('button', { name: 'Next', exact: true }).click()
const wire = await secondResponse
assert.equal(wire.request().postDataJSON().params.listing_revision, first.listing_revision)
const second = await resultOf(wire)
assert.equal(second.items.length, 1)
assert.equal(second.next_page, null)
assert.equal(second.listing_revision, first.listing_revision)
const all = [...first.items, ...second.items]
assert.equal(new Set(all.map(item => item.upstream_id)).size, COUNT)
assert.deepEqual(all.map(item => item.name).sort(), expected.sort())
await expect(downloads).toHaveCount(1)
await expect(section.getByText('Page 2', { exact: true })).toBeVisible()
await expect(section.getByRole('button', { name: 'Next', exact: true })).toBeDisabled()
// Upstream identity ordering is NOT filename ordering. Use the actual tail.
const tail = second.items[0].name
const pending = page.waitForEvent('download')
await section.getByRole('button', { name: `Download ${tail}`, exact: true }).click()
const download = await pending
assert.equal(download.suggestedFilename(), tail)
assert.equal(await fs.readFile(await download.path(), 'utf8'), bytesOf(tail))
const back = responseFor(1)
await section.getByRole('button', { name: 'Previous', exact: true }).click()
assert.equal((await resultOf(await back)).listing_revision, first.listing_revision)
await expect(downloads).toHaveCount(250)
await createNew(api, 'PUT', pathOf(folder) + 'changed.txt', 'Owned listing mutation\n')
const stale = responseFor(2)
await section.getByRole('button', { name: 'Next', exact: true }).click()
const staleResponse = await stale
assert.equal(staleResponse.request().postDataJSON().params.listing_revision, first.listing_revision)
assert.equal(staleResponse.status(), 200)
const denied = await staleResponse.json()
assert.ok(denied.error && !denied.result, 'A changed listing must not return a partial page')
assert.equal(denied.error.code, -1)
assert.equal(denied.error.message, 'Cloud source is unavailable')
await expect(section.getByRole('alert')).toContainText('Cloud source is unavailable')
await expect(downloads).toHaveCount(0)
const refreshed = responseFor(1)
await section.getByRole('button', { name: 'Refresh from first page', exact: true }).click()
const fresh = await resultOf(await refreshed)
assert.notEqual(fresh.listing_revision, first.listing_revision)
assert.equal(fresh.items.length, 250)
assert.equal(fresh.next_page, 2)
const freshNames = fresh.items.map(item => item.name).filter(name => /^page-\d{4}\.txt$/.test(name))
await expect(downloads).toHaveCount(freshNames.length)
assert.deepEqual((await names()).sort(), freshNames.sort())
// Production deliberately exposes only the outer error context. Prove the
// revision cause with old/fresh/old requests, not an arbitrary generic error.
const params = { connection_id: connectionId, folder: [folder], page: 2 }
const rejectOld = () => assert.rejects(rpc('cloud.sources.browse', { ...params,
listing_revision: first.listing_revision }), { message: 'Cloud source is unavailable' })
await rejectOld()
const currentTail = await rpc('cloud.sources.browse', { ...params, listing_revision: fresh.listing_revision })
assert.equal(currentTail.listing_revision, fresh.listing_revision)
assert.equal(currentTail.items.length, 2)
assert.equal(currentTail.next_page, null)
const changedItems = [...fresh.items, ...currentTail.items]
assert.equal(new Set(changedItems.map(item => item.upstream_id)).size, COUNT + 1)
assert.deepEqual(changedItems.map(item => item.name).sort(), [...expected, 'changed.txt'].sort())
await rejectOld()
await section.getByRole('button', { name: 'All files', exact: true }).click()
await expect(section.getByRole('button', { name: 'Download contract.txt', exact: true })).toBeVisible()
console.log('PASS actual Nextcloud pagination: 250+1 unique items, later-page exact bytes, stable Previous, stale revision refusal and first-page refresh')
} finally { await api.dispose() }
}
module.exports = { seedPagination, exerciseNextcloudPagination, COUNT }