Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions docs/contracts/user-data-pagination.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 5 additions & 7 deletions e2e/specs/app/profile_user_data.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
176 changes: 176 additions & 0 deletions e2e/specs/app/user_data_pagination.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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);
});
Loading
Loading