diff --git a/docs/contracts/user-data-pagination.md b/docs/contracts/user-data-pagination.md new file mode 100644 index 00000000..e3dccf1f --- /dev/null +++ b/docs/contracts/user-data-pagination.md @@ -0,0 +1,107 @@ +# User-data list contract and remaining beta gate + +Reference: vpsAdmin API `486350466e8fb6f966add1cde3fa2bc12b4d6b62`, +Clankerdev base `fbb02a065bc65f50f66f5bbcedfc35099a74bfa0`, issues #241 and #189. +This is a fresh implementation; closed PR #242 is not reopened or merged. + +## API evidence + +`api/lib/vpsadmin/api/resources/vps_user_data.rb`, `VpsUserData::Index`, +declares only `user` and `format` in addition to inherited pagination inputs. +Member authorization restricts the owner to the authenticated user; admins can +select an exact owner. The resource spec requires returned IDs to be greater +than `from_id`. The action calls `with_pagination(query)` without an explicit +`ORDER BY`. Upstream PR #43 changes user payments, not this endpoint. + +## Frontend behavior + +- Never send the unsupported `q` parameter. Search labels case-insensitively; + a number or `#number` matches an exact ID. Do not search template content. +- Retain owner and format on every request. Include viewer identity and role + in the query cache and pagination scope. +- Read batches of 100 using the ascending ID predicate. Validate strictly + increasing positive safe-integer IDs, including invisible lookahead rows. + Reject repeated, descending, invalid and oversized pages instead of sorting + or presenting a partial successful result. +- Collect the requested page plus one matching row. Display only the page, + continue after its greatest visible ID and derive Next from the extra match. +- Scan at most 1,000 raw rows per page request, then permit only a one-row + existence probe. An empty probe proves the exact boundary; a nonempty probe + raises a translated search-limit error. Retry is explicit, not automatic. +- Carry the query abort signal through every request and check it between + batches. Advanced filters apply on confirmation, not on every keystroke. +- Fresh pages rebuild forward cursor edges; successful empty cursor pages + return to the preceding page. Filter changes clear the cursor. Browser + Back/Forward and reload preserve a valid URL cursor. +- URL-restoring lists derive the current cursor from the committed router + URL during render. A history transition must not query the preceding page + while waiting for a layout effect. Empty-page recovery runs once per result + while the router commits the navigation. + +## What this does not establish + +The frontend cannot prove that an API response omitted no lower IDs. For +example, a first batch `[10, 20]` can pass order validation even if ID 5 exists +but was omitted by an unordered SQL LIMIT. The next predicate `id > 20` cannot +recover it. This PR therefore does not resolve upstream #189 or claim an +atomic snapshot across concurrent writes. + +The minimal backend proposal is an explicit `ORDER BY id ASC` matching the +existing predicate, with integration tests over multiple limited pages, +nonmonotonic timestamps, owner/format scope and exact terminal boundaries. +The user subsequently authorized this specific backend work. It is now open +as [vpsAdmin PR44](https://github.com/vpsfreecz/vpsadmin/pull/44), head +`320af0e152ed223bf0365e0f1cf4b38cf00d7b1d`, with explicit `ORDER BY id ASC` and +resource-test coverage. This frontend must be validated together with that +exact API in the existing isolated cluster before promotion. No shared API +deployment or database migration is included. The shared API deployment gate stays open. See the isolated verification below. + +## Verification checklist + +- [x] Unit coverage for ascending/gapped IDs, lookahead, timestamps unrelated + to ID order, later-batch label/ID matches, scope propagation, malformed pages, + 1,000/1,001 boundaries, mid-scan HTTP failure and cancellation. +- [x] Fixture Playwright: member/admin, cs/en, desktop and 390 px mobile; + exact terminal page, URL refresh/history, rebuilt forward cursor, advanced + filter labels and request count, cap/error/retry, last-row deletion. +- [x] Existing create/edit/deploy/delete and editor/lookup accessibility tests. +- [x] Real isolated API/UI three-page smoke and owner permissions; see exact pins below. +- [ ] Additional live adversarial timestamp fixtures and broader #189 cursor resources. +- [ ] Explicit upstream ordering guarantee (#189). + +Fixture tests record every POST/PUT/PATCH/DELETE on every origin. Read tests +allow only the exact same-origin `PUT /api/v7.0/webui_user_settings` emitted by +the fixture app shell. The deletion test additionally expects exactly one +mocked `DELETE /api/v7.0/vps_user_data/26`. These are not live/VM tests or KB +captures. + +The structural audit also fails on the unchanged base revision: 62 files over +500 lines versus the stored budget of 53, plus existing assertions/type-cast +regressions in other files. Reproduced from a clean `git archive HEAD src +scripts`; this change does not alter the baseline or bypass that audit. + + +## Isolated VM evidence — 24 September 2026 + +Integrated UI `3ac1be67d9964e1a0d9052b1a43070d704688912` includes this PR at +`c01f09ff`. It runs against API44 `320af0e152ed223bf0365e0f1cf4b38cf00d7b1d` +in the existing dedicated `clanker-beta-20260924` cluster. The runtime +provenance and served build commit are checked before every test. The KB +runner is versioned at `5f616e9` in the local `codex/clankerdev-kb` branch. + +Four configured browser variants (Czech/English, desktop/mobile) each use real +member and administrator OAuth logins and the actual API. Each variant creates +51 script templates, one excluded cloud-config template and one foreign-owner +script. Both views return exactly 25, 25 and 1 matching rows, in ascending ID +order despite reversed labels. Next is disabled at the end; reload, Previous +and browser Back preserve the expected rows. Observed API requests retain +format and administrator owner scope and never contain unsupported `q`. +Foreign-object reads and malformed cursors are rejected by the actual API. +All 212 disposable templates are deleted and each deletion is verified. + +This is live VM evidence, separate from the fixture suite. It covers label +order, basic end-of-list and real owner boundaries. It does not exercise +nonmonotonic database timestamps, the search-cap/outage cases against a live +server, or the other #189 resources. Those claims still rely on the explicitly +identified resource/unit/fixture tests or remain pending. No template was +deployed to a VPS, and no shared API or frontend was updated. diff --git a/e2e/specs/app/profile_user_data.spec.ts b/e2e/specs/app/profile_user_data.spec.ts index b40aff07..7a51dadd 100644 --- a/e2e/specs/app/profile_user_data.spec.ts +++ b/e2e/specs/app/profile_user_data.spec.ts @@ -34,20 +34,18 @@ test.describe('Profile: user data templates', () => { }, handlers: { 'GET vps_user_data': async ({ params }) => { - const q = (params['vps_user_data[q]'] ?? '').toString().trim().toLowerCase(); + expect(params['vps_user_data[q]']).toBeUndefined(); const format = (params['vps_user_data[format]'] ?? '').toString().trim(); - let out = [...templates].sort((a, b) => b.id - a.id); - - if (q) { - out = out.filter((x) => x.label.toLowerCase().includes(q) || `#${x.id}`.includes(q)); - } + let out = [...templates].sort((a, b) => a.id - b.id); if (format) { out = out.filter((x) => x.format === format); } - return out; + const from = Number(params['vps_user_data[from_id]'] ?? 0); + const limit = Number(params['vps_user_data[limit]'] ?? 100); + return out.filter((row) => row.id > from).slice(0, limit); }, 'POST vps_user_data': async ({ reqJson }) => { diff --git a/e2e/specs/app/user_data_pagination.spec.ts b/e2e/specs/app/user_data_pagination.spec.ts new file mode 100644 index 00000000..d1d9b640 --- /dev/null +++ b/e2e/specs/app/user_data_pagination.spec.ts @@ -0,0 +1,176 @@ +import { expect, test, type Page } from '@playwright/test'; +import { bootstrapVpsAdminWindow, installHaveApiMock, setUiSettingsLocalStorage } from '../../fixtures'; +import type { VpsUserData } from '../../../src/lib/api/vpsUserData'; + +const template = (id: number, label = 'nginx'): VpsUserData => ({ + id, label, user: { id: 7 }, format: 'script', content: '#!/bin/sh', + // Time order deliberately differs from ID order. + updated_at: id % 2 ? '2026-09-23T12:00:00Z' : '2020-01-01T12:00:00Z', +}); + +async function setup(page: Page, admin: boolean, language: 'en' | 'cs', getRows: () => VpsUserData[]) { + const requests: URL[] = []; + const writes: string[] = []; + // Observe all origins and methods, including the app shell. + page.on('request', (req) => { + if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method())) writes.push(`${req.method()} ${req.url()}`); + }); + await bootstrapVpsAdminWindow(page); + await setUiSettingsLocalStorage(page, { language }); + const mock = await installHaveApiMock(page, { + authorize: { user: { id: admin ? 1 : 7, login: 'tester', level: admin ? 99 : 1 } }, + handlers: { + 'GET users/7': () => ({ id: 7, login: 'owner', level: 1 }), + 'GET vpses': () => ({ vpses: [], _meta: { total_count: 0 } }), + 'GET vps_user_data': ({ url, params }) => { + requests.push(url); + expect(params['vps_user_data[q]']).toBeUndefined(); + expect(params['vps_user_data[user]']).toBe(admin ? '7' : undefined); + const format = params['vps_user_data[format]']; + const from = Number(params['vps_user_data[from_id]'] ?? 0); + const limit = Number(params['vps_user_data[limit]']); + return getRows().filter((row) => row.user?.id === 7 && (!format || row.format === format) && row.id > from) + .sort((a, b) => a.id - b.id).slice(0, limit); + }, + }, + }); + return { + requests, writes, mock, + path: admin ? '/admin/users/7/user-data' : '/app/profile/user-data', + prefix: admin ? 'admin.user.user_data' : 'profile.user_data', + }; +} + +async function visibleIds(page: Page, prefix: string) { + return page.locator(`tr[data-testid^="${prefix}.row."]`).evaluateAll((rows) => rows.map((row) => + Number(row.getAttribute('data-testid')!.split('.').at(-1)))); +} + +function expectWrites(page: Page, writes: string[], expected: string[] = []) { + // The fixture app shell syncs local preferences on each reload. Allow only + // that exact method/path on this test origin, never a resource mutation. + const settingsWrite = `PUT ${new URL('/api/v7.0/webui_user_settings', page.url()).href}`; + expect(writes.filter((request) => request !== settingsWrite)).toEqual(expected); +} + +for (const admin of [false, true]) { + for (const language of ['en', 'cs'] as const) { + test(`@pr-smoke @pr-smoke-mobile ${admin ? 'admin' : 'member'} ${language}: scoped search, pages, URL history and advanced filters`, async ({ page }, info) => { + if (info.project.name === 'mobile-chrome') await page.setViewportSize({ width: 390, height: 844 }); + let rows = Array.from({ length: 150 }, (_, i) => template((i + 1) * 3, i < 100 ? 'unrelated' : 'nginx')); + rows.push({ ...template(9999), user: { id: 8 } }, { ...template(9998), format: 'cloudinit_config' }); + const { requests, writes, path, prefix } = await setup(page, admin, language, () => rows); + await page.goto(`${path}?q=nginx&format=script&limit=25`); + const expected = rows.filter((row) => row.label === 'nginx' && row.user?.id === 7 && row.format === 'script'); + const next = page.getByTestId(`${prefix}.pagination.next`); + const prev = page.getByTestId(`${prefix}.pagination.prev`); + await expect.poll(() => visibleIds(page, prefix)).toEqual(expected.slice(0, 25).map((row) => row.id)); + await expect(next).toBeEnabled(); + expect(requests.length).toBe(2); + await next.click(); + await expect.poll(() => visibleIds(page, prefix)).toEqual(expected.slice(25).map((row) => row.id)); + await expect(next).toBeDisabled(); + await expect(page).toHaveURL(/from_id=375/); + await page.reload(); + await expect(page.getByTestId(`${prefix}.row.378`)).toBeVisible(); + await prev.click(); + await expect(page.getByTestId(`${prefix}.row.303`)).toBeVisible(); + await page.goBack(); + await expect(page.getByTestId(`${prefix}.row.378`)).toBeVisible(); + await page.goForward(); + await expect(page.getByTestId(`${prefix}.row.303`)).toBeVisible(); + + // A refreshed preceding page must replace its old forward cursor. + rows = rows.filter((row) => ![303, 306, 309].includes(row.id)); + await page.reload(); + await expect(page.getByTestId(`${prefix}.row.312`)).toBeVisible(); + await next.click(); + await expect(page).toHaveURL(/from_id=384/); + await expect(page.getByTestId(`${prefix}.row.387`)).toBeVisible(); + + await page.getByTestId(`${prefix}.filters.advanced`).click(); + const search = page.getByTestId(`${prefix}.filters.q.advanced`); + const format = page.getByTestId(`${prefix}.filters.format`); + await expect(search).toHaveAccessibleName(language === 'cs' ? 'Vyhledat' : 'Search'); + await expect(format).toHaveAccessibleName(/.+/); + const count = requests.length; + await search.fill('a query that should not make requests while typing'); + await search.pressSequentially(' more text'); + expect(requests).toHaveLength(count); + await search.fill('#450'); + await page.getByTestId(`${prefix}.filters.apply`).click(); + await expect.poll(() => visibleIds(page, prefix)).toEqual([450]); + await expect(page).not.toHaveURL(/from_id=/); + await expect(next).toBeDisabled(); + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + for (const url of requests) expect(url.searchParams.get('vps_user_data[format]')).toBe('script'); + expectWrites(page, writes); + }); + } +} + +test('search cap, inconsistent order and HTTP errors are explicit and retryable', async ({ page }) => { + let rows = Array.from({ length: 1001 }, (_, i) => template(i + 1, 'unrelated')); + const { path, prefix, mock, writes } = await setup(page, false, 'en', () => rows); + await page.goto(`${path}?q=absent`); + await expect(page.getByTestId(`${prefix}.error`)).toContainText('1,000'); + await expect(page.getByTestId(`${prefix}.empty`)).toHaveCount(0); + rows = rows.slice(0, 1000); + await page.getByTestId(`${prefix}.retry`).click(); + await expect(page.getByTestId(`${prefix}.empty`)).toBeVisible(); + + mock.addHandler('GET vps_user_data', () => [template(10), template(2)]); + await page.reload(); + await expect(page.getByTestId(`${prefix}.error`)).toContainText('inconsistent'); + mock.addHandler('GET vps_user_data', () => ({ status: 500, contentType: 'application/json', body: JSON.stringify({ status: false, message: 'Temporarily unavailable' }) })); + await page.getByTestId(`${prefix}.retry`).click(); + await expect(page.getByTestId(`${prefix}.error`)).toContainText('Temporarily unavailable'); + mock.addHandler('GET vps_user_data', () => []); + await page.getByTestId(`${prefix}.retry`).click(); + await expect(page.getByTestId(`${prefix}.empty`)).toBeVisible(); + expectWrites(page, writes); +}); + +test('deleting the only row on the last page returns to a usable preceding page', async ({ page }) => { + let rows = Array.from({ length: 26 }, (_, i) => template(i + 1)); + const { path, prefix, mock, writes } = await setup(page, false, 'en', () => rows); + mock.addHandler('DELETE vps_user_data/26', () => { rows = rows.filter((row) => row.id !== 26); return null; }); + await page.goto(`${path}?limit=25`); + await page.getByTestId(`${prefix}.pagination.next`).click(); + await page.getByTestId(`${prefix}.row.26.delete`).click(); + await page.getByTestId(`${prefix}.delete.confirm.confirm`).click(); + await expect.poll(() => visibleIds(page, prefix)).toEqual(rows.slice(0, 25).map((row) => row.id)); + await expect(page.getByTestId(`${prefix}.pagination.next`)).toBeDisabled(); + await expect(page).not.toHaveURL(/from_id=/); + expectWrites(page, writes, [`DELETE ${new URL('/api/v7.0/vps_user_data/26', page.url()).href}`]); +}); + +test('applying a different format aborts the obsolete scan', async ({ page }) => { + const { path, prefix, mock, requests, writes } = await setup(page, false, 'en', () => []); + let release: () => void = () => {}; + const heldResponse = new Promise((resolve) => { release = resolve; }); + mock.addHandler('GET vps_user_data', async ({ url, params }) => { + requests.push(url); + if (params['vps_user_data[format]'] === 'script') { + await heldResponse; + return Array.from({ length: 100 }, (_, i) => template(i + 1, 'old scope')); + } + return [{ ...template(2001, 'new scope'), format: 'cloudinit_config' }]; + }); + const initial = page.waitForRequest((req) => req.url().includes('/vps_user_data?')); + await page.goto(`${path}?q=absent&format=script`); + const obsolete = await initial; + await page.getByTestId(`${prefix}.filters.advanced`).click(); + await page.getByTestId(`${prefix}.filters.q.advanced`).fill('new scope'); + await page.getByTestId(`${prefix}.filters.format`).selectOption('cloudinit_config'); + const failed = page.waitForEvent('requestfailed', (req) => req === obsolete); + try { + await page.getByTestId(`${prefix}.filters.apply`).click(); + expect((await failed).failure()?.errorText).toContain('ABORTED'); + } finally { + release(); + } + await expect(page.getByTestId(`${prefix}.row.2001`)).toBeVisible(); + expect(requests.filter((url) => url.searchParams.get('vps_user_data[format]') === 'script')).toHaveLength(1); + expectWrites(page, writes); +}); diff --git a/src/components/user/UserDataTemplatesFilters.tsx b/src/components/user/UserDataTemplatesFilters.tsx index 8793a682..a0c84874 100644 --- a/src/components/user/UserDataTemplatesFilters.tsx +++ b/src/components/user/UserDataTemplatesFilters.tsx @@ -42,6 +42,7 @@ export function UserDataTemplatesFilters(props: { const smartInputRef = useRef(null); const [helpOpen, setHelpOpen] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false); + const [draft, setDraft] = useState({}); function focusSmartInput() { window.requestAnimationFrame(() => smartInputRef.current?.focus()); @@ -106,7 +107,7 @@ export function UserDataTemplatesFilters(props: { return ( <> -
+
-
- @@ -180,8 +184,9 @@ export function UserDataTemplatesFilters(props: {
{t('common.search')}
props.onSetFilters({ q: e.target.value, format: props.formatFilter })} + value={draft.q ?? ''} + ariaLabel={t('common.search')} + onChange={(e) => setDraft((current) => ({ ...current, q: e.target.value }))} placeholder={t('user_data.filters.search.placeholder')} autoComplete="off" testId={`${props.prefix}.filters.q.advanced`} @@ -193,8 +198,9 @@ export function UserDataTemplatesFilters(props: {
{t('user_data.filters.format')}