e3823ce470
Add an end-to-end and unit test suite for the SvelteKit frontend:
- Playwright e2e specs (tests/e2e/spa) with a throwaway container stack,
codegen scenarios, and an Istanbul-based coverage report pipeline.
- Vitest unit tests across API endpoints, components, stores and composables.
- `data-testid` hooks on interactive elements (AppShell, FileViewer,
ShareDialog, search, photos, files breadcrumbs, login/Nextcloud flows,
public share pages) so the e2e suite can target them deterministically.
- Serve the SPA app-shell CSP from a <meta> policy (svelte.config.js) plus a
middleware that skips the CSP header on HTML; move the Nextcloud Login Flow
v2 grant page to the SvelteKit /nextcloud/login route.
- `just front-codegen` recipe and start-server-spa.sh harness.
Make the test environment robust and consistent:
- Install a deterministic in-memory localStorage/sessionStorage in the Vitest
setup so storage behaves identically across Node versions (Node 26 ships a
native Web Storage global that otherwise shadows jsdom's).
- Pin devenv to Node 26 + PostgreSQL 18 and pin every CI job to Node 26.3.0
so the dev shell and CI run the same toolchain versions.
Repair the API/WebDAV (hurl) suite, which had drifted from the backend:
- Migrate the removed `/api/folders/{id}/listing` endpoint to `/resources`
(cursor-paginated `{items:[{resource_type,resource}]}` shape) across the
batch-copy, grants, nested-group, and WebDAV NC tests + the dav_helpers
wipe routine.
- Stop photos_etag from uploading the dedup-tracked fixture so the dedup
blob-lifecycle test can own its content-addressed blob exclusively.
- dedup_create now asserts the idempotent same-content re-upload (201 +
existing file id) instead of the stale 409 expectation.
Generated coverage reports, nyc output and the e2e server runtime data dir
are gitignored rather than committed.
308 lines
14 KiB
TypeScript
308 lines
14 KiB
TypeScript
import * as path from 'path';
|
|
import { test, expect } from './coverage-helpers';
|
|
import { apiLogin, apiAdminCreateUser } from '../scenarios/helpers';
|
|
|
|
const PLUGIN_ZIP = path.join(__dirname, '..', 'fixtures', 'plugin-hello.zip');
|
|
|
|
/**
|
|
* Admin panel — walk every tab and open a couple of forms. The admin route is
|
|
* one of the largest source files; exercising each tab pulls in a lot of it
|
|
* plus the admin endpoint module.
|
|
*/
|
|
test.beforeEach(async ({ page }) => {
|
|
await apiLogin(page);
|
|
});
|
|
|
|
test('walk every admin tab', async ({ page }) => {
|
|
await page.goto('/admin');
|
|
await expect(page.getByTestId('admin-dashboard-tab')).toBeVisible({ timeout: 15_000 });
|
|
// Dashboard is the default tab.
|
|
await expect(page.getByTestId('admin-dashboard-registration-checkbox')).toBeVisible();
|
|
|
|
await page.getByTestId('admin-users-tab').click();
|
|
await expect(page.getByTestId('admin-users-create-btn')).toBeVisible();
|
|
|
|
await page.getByTestId('admin-oidc-tab').click();
|
|
await expect(page.getByTestId('admin-oidc-form')).toBeVisible();
|
|
|
|
await page.getByTestId('admin-storage-tab').click();
|
|
await expect(page.getByTestId('admin-storage-form')).toBeVisible();
|
|
|
|
await page.getByTestId('admin-smtp-tab').click();
|
|
await expect(page.getByTestId('admin-smtp-send-btn')).toBeVisible();
|
|
|
|
await page.getByTestId('admin-plugins-tab').click();
|
|
// Plugins panel content is conditional; assert the tab became active.
|
|
await expect(page.getByTestId('admin-plugins-tab')).toHaveAttribute('aria-selected', 'true');
|
|
});
|
|
|
|
test('open the create-user form', async ({ page }) => {
|
|
await page.goto('/admin');
|
|
await page.getByTestId('admin-users-tab').click();
|
|
await page.getByTestId('admin-users-create-btn').click();
|
|
await expect(page.getByTestId('admin-create-user-form')).toBeVisible({ timeout: 15_000 });
|
|
});
|
|
|
|
test('storage tab: change backend select', async ({ page }) => {
|
|
await page.goto('/admin');
|
|
await page.getByTestId('admin-storage-tab').click();
|
|
await expect(page.getByTestId('admin-storage-form')).toBeVisible({ timeout: 15_000 });
|
|
await page.getByTestId('admin-storage-backend-select').selectOption({ index: 1 }).catch(() => {});
|
|
});
|
|
|
|
test('storage tab: save the local backend settings', async ({ page }) => {
|
|
await page.goto('/admin');
|
|
await page.getByTestId('admin-storage-tab').click();
|
|
await expect(page.getByTestId('admin-storage-form')).toBeVisible({ timeout: 15_000 });
|
|
// Keep the (safe) local backend and save — exercises the save handler without
|
|
// reconfiguring storage to a remote backend.
|
|
await page.getByTestId('admin-storage-backend-select').selectOption('local').catch(() => {});
|
|
await page.getByTestId('admin-storage-save-btn').click().catch(() => {});
|
|
await expect(page.getByTestId('admin-storage-form')).toBeVisible();
|
|
});
|
|
|
|
test('oidc tab: toggle enabled and fill issuer', async ({ page }) => {
|
|
await page.goto('/admin');
|
|
await page.getByTestId('admin-oidc-tab').click();
|
|
await expect(page.getByTestId('admin-oidc-form')).toBeVisible({ timeout: 15_000 });
|
|
await page.getByTestId('admin-oidc-enabled-checkbox').check().catch(() => {});
|
|
await page
|
|
.getByTestId('admin-oidc-issuer-input')
|
|
.fill('https://example.test/issuer', { timeout: 2_000 })
|
|
.catch(() => {});
|
|
});
|
|
|
|
function uniqUser(): string {
|
|
return `u${Date.now()}${Math.floor(Math.random() * 1e6)}`;
|
|
}
|
|
|
|
/**
|
|
* Create a user via the admin form, dismiss the (modal-backdrop) create dialog,
|
|
* and return a locator for that user's table row.
|
|
*/
|
|
async function createUserRow(
|
|
page: import('@playwright/test').Page,
|
|
uname: string,
|
|
): Promise<ReturnType<import('@playwright/test').Page['locator']>> {
|
|
await page.goto('/admin');
|
|
await page.getByTestId('admin-users-tab').click();
|
|
await page.getByTestId('admin-users-create-btn').click();
|
|
await expect(page.getByTestId('admin-create-user-form')).toBeVisible({ timeout: 15_000 });
|
|
await page.getByTestId('admin-create-user-username-input').fill(uname);
|
|
await page.getByTestId('admin-create-user-email-input').fill(`${uname}@example.test`);
|
|
await page.getByTestId('admin-create-user-password-input').fill('TestPassword1!');
|
|
await page.getByTestId('admin-create-user-submit-btn').click();
|
|
|
|
const row = page.locator('tr').filter({ hasText: uname });
|
|
await expect(row).toBeVisible({ timeout: 15_000 });
|
|
|
|
// The create flow can leave a modal open whose backdrop blocks row actions.
|
|
await page.keyboard.press('Escape');
|
|
const cancel = page.getByTestId('admin-create-user-cancel-btn');
|
|
if (await cancel.isVisible().catch(() => false)) await cancel.click();
|
|
await expect(page.locator('.modal__backdrop')).toHaveCount(0, { timeout: 10_000 });
|
|
return row;
|
|
}
|
|
|
|
test('create and delete a user', async ({ page }) => {
|
|
const uname = uniqUser();
|
|
const row = await createUserRow(page, uname);
|
|
|
|
// Delete — admin uses its own confirm modal (admin-confirm-ok-btn).
|
|
await row.locator('[data-testid^="admin-user-delete-"]').first().click();
|
|
await page.getByTestId('admin-confirm-ok-btn').click();
|
|
await expect(page.locator('tr').filter({ hasText: uname })).toHaveCount(0, { timeout: 15_000 });
|
|
});
|
|
|
|
test('toggle a user role and active state', async ({ page }) => {
|
|
const uname = uniqUser();
|
|
const row = await createUserRow(page, uname);
|
|
|
|
// Each of these confirms through the admin confirm modal.
|
|
await row.locator('[data-testid^="admin-user-toggle-role-"]').first().click();
|
|
await page.getByTestId('admin-confirm-ok-btn').click();
|
|
await row.locator('[data-testid^="admin-user-toggle-active-"]').first().click(); // deactivate
|
|
await page.getByTestId('admin-confirm-ok-btn').click();
|
|
await row.locator('[data-testid^="admin-user-toggle-active-"]').first().click(); // reactivate
|
|
await page.getByTestId('admin-confirm-ok-btn').click();
|
|
|
|
await expect(row).toBeVisible();
|
|
});
|
|
|
|
test('reset a user password', async ({ page }) => {
|
|
const uname = uniqUser();
|
|
const row = await createUserRow(page, uname);
|
|
|
|
await row.locator('[data-testid^="admin-user-reset-password-"]').first().click();
|
|
await expect(page.getByTestId('admin-reset-password-form')).toBeVisible({ timeout: 15_000 });
|
|
await page.getByTestId('admin-reset-password-input').fill('NewPassword1!');
|
|
await page.getByTestId('admin-reset-password-submit-btn').click();
|
|
await expect(page.getByTestId('admin-reset-password-form')).toHaveCount(0, { timeout: 15_000 });
|
|
});
|
|
|
|
test('save a user quota and deactivate the user', async ({ page }) => {
|
|
const uname = uniqUser();
|
|
const row = await createUserRow(page, uname);
|
|
|
|
await row.locator('[data-testid^="admin-user-quota-"]').first().click();
|
|
await expect(page.getByTestId('admin-quota-form')).toBeVisible({ timeout: 15_000 });
|
|
await page.getByTestId('admin-quota-save-btn').click();
|
|
await expect(page.getByTestId('admin-quota-form')).toHaveCount(0, { timeout: 15_000 });
|
|
|
|
// Deactivate (admin's own confirm modal).
|
|
await row.locator('[data-testid^="admin-user-toggle-active-"]').first().click();
|
|
await page.getByTestId('admin-confirm-ok-btn').click();
|
|
});
|
|
|
|
test('save oidc settings', async ({ page }) => {
|
|
await page.goto('/admin');
|
|
await page.getByTestId('admin-oidc-tab').click();
|
|
await expect(page.getByTestId('admin-oidc-form')).toBeVisible({ timeout: 15_000 });
|
|
await page.getByTestId('admin-oidc-issuer-input').fill('https://example.test/issuer').catch(() => {});
|
|
await page.getByTestId('admin-oidc-client-id-input').fill('client-123').catch(() => {});
|
|
await page.getByTestId('admin-oidc-save-btn').click().catch(() => {});
|
|
await expect(page.getByTestId('admin-oidc-form')).toBeVisible();
|
|
});
|
|
|
|
test('install, toggle, and delete a plugin', async ({ page }) => {
|
|
await page.goto('/admin');
|
|
await page.getByTestId('admin-plugins-tab').click();
|
|
await expect(page.getByTestId('admin-plugins-tab')).toHaveAttribute('aria-selected', 'true');
|
|
|
|
// Install the example hello plugin (plugins are enabled in the coverage env).
|
|
await page.getByTestId('admin-plugins-install-input').setInputFiles(PLUGIN_ZIP);
|
|
|
|
// The installed plugin shows up with toggle/delete controls.
|
|
const toggle = page.locator('[data-testid^="admin-plugin-toggle-"]').first();
|
|
await expect(toggle).toBeVisible({ timeout: 20_000 });
|
|
await toggle.click(); // disable
|
|
await page.waitForTimeout(400);
|
|
await toggle.click(); // re-enable
|
|
await page.waitForTimeout(400);
|
|
|
|
// Delete it (admin confirm modal).
|
|
await page.locator('[data-testid^="admin-plugin-delete-"]').first().click();
|
|
const ok = page.getByTestId('admin-confirm-ok-btn');
|
|
if (await ok.isVisible().catch(() => false)) await ok.click();
|
|
await expect(page.locator('[data-testid^="admin-plugin-toggle-"]')).toHaveCount(0, {
|
|
timeout: 15_000,
|
|
});
|
|
});
|
|
|
|
test('view plugin logs and details', async ({ page }) => {
|
|
await page.goto('/admin');
|
|
await page.getByTestId('admin-plugins-tab').click();
|
|
await page.getByTestId('admin-plugins-install-input').setInputFiles(PLUGIN_ZIP);
|
|
await expect(page.locator('[data-testid^="admin-plugin-details-"]').first()).toBeVisible({
|
|
timeout: 20_000,
|
|
});
|
|
|
|
// Open the logs/details view and exercise its controls, then close.
|
|
await page.locator('[data-testid^="admin-plugin-details-"]').first().click();
|
|
await page.getByTestId('admin-plugin-logs-level-select').selectOption({ index: 1 }).catch(() => {});
|
|
await page.getByTestId('admin-plugin-logs-search-input').fill('hello').catch(() => {});
|
|
await page.getByTestId('admin-plugin-logs-search-btn').click({ timeout: 2_000 }).catch(() => {});
|
|
// Note: the live-tail checkbox opens an SSE stream that prevents the page from
|
|
// settling, so it's left untested here.
|
|
await page.getByTestId('admin-plugin-logs-close-btn').click({ timeout: 3_000 }).catch(() => {});
|
|
|
|
// Clean up: delete the plugin.
|
|
await page.locator('[data-testid^="admin-plugin-delete-"]').first().click();
|
|
const ok = page.getByTestId('admin-confirm-ok-btn');
|
|
if (await ok.isVisible().catch(() => false)) await ok.click();
|
|
});
|
|
|
|
test('plugins tab: save retention settings', async ({ page }) => {
|
|
await page.goto('/admin');
|
|
await page.getByTestId('admin-plugins-tab').click();
|
|
await expect(page.getByTestId('admin-plugins-tab')).toHaveAttribute('aria-selected', 'true');
|
|
// The retention form is conditional; fill + save it when present.
|
|
const retention = page.getByTestId('admin-plugin-retention-form');
|
|
if (await retention.isVisible().catch(() => false)) {
|
|
await page.getByTestId('admin-plugin-retention-days-input').fill('30').catch(() => {});
|
|
await page.getByTestId('admin-plugin-retention-save-btn').click().catch(() => {});
|
|
}
|
|
});
|
|
|
|
test('toggle the dashboard registration setting', async ({ page }) => {
|
|
await page.goto('/admin');
|
|
await expect(page.getByTestId('admin-dashboard-registration-checkbox')).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
await page.getByTestId('admin-dashboard-registration-checkbox').click();
|
|
});
|
|
|
|
test('send a test email from the smtp tab', async ({ page }) => {
|
|
await page.goto('/admin');
|
|
await page.getByTestId('admin-smtp-tab').click();
|
|
await expect(page.getByTestId('admin-smtp-to-input')).toBeVisible({ timeout: 15_000 });
|
|
await page.getByTestId('admin-smtp-to-input').fill('test@example.test');
|
|
await page.getByTestId('admin-smtp-send-btn').click();
|
|
await expect(page.getByTestId('admin-smtp-send-btn')).toBeVisible();
|
|
});
|
|
|
|
test('storage tab: fill the S3 backend fields', async ({ page }) => {
|
|
await page.goto('/admin');
|
|
await page.getByTestId('admin-storage-tab').click();
|
|
await expect(page.getByTestId('admin-storage-form')).toBeVisible({ timeout: 15_000 });
|
|
|
|
// Switch to S3 to reveal + fill the conditional fields (no save — that would
|
|
// reconfigure storage to an unreachable backend).
|
|
await page.getByTestId('admin-storage-backend-select').selectOption('s3').catch(() => {});
|
|
await page.getByTestId('admin-storage-endpoint-input').fill('https://s3.example.test', { timeout: 2_000 }).catch(() => {});
|
|
await page.getByTestId('admin-storage-bucket-input').fill('e2e-bucket', { timeout: 2_000 }).catch(() => {});
|
|
await page.getByTestId('admin-storage-region-input').fill('us-east-1', { timeout: 2_000 }).catch(() => {});
|
|
await page.getByTestId('admin-storage-access-key-input').fill('AKIA', { timeout: 2_000 }).catch(() => {});
|
|
await page.getByTestId('admin-storage-secret-key-input').fill('secret', { timeout: 2_000 }).catch(() => {});
|
|
await page.getByTestId('admin-storage-path-style-checkbox').click({ timeout: 2_000 }).catch(() => {});
|
|
// Switch back to the safe local backend.
|
|
await page.getByTestId('admin-storage-backend-select').selectOption('local').catch(() => {});
|
|
});
|
|
|
|
test('oidc tab: run discovery against a bogus issuer', async ({ page }) => {
|
|
await page.goto('/admin');
|
|
await page.getByTestId('admin-oidc-tab').click();
|
|
await expect(page.getByTestId('admin-oidc-form')).toBeVisible({ timeout: 15_000 });
|
|
await page.getByTestId('admin-oidc-issuer-input').fill('https://idp.example.test').catch(() => {});
|
|
// Discovery fails (no real IdP) — exercises the discover + error path.
|
|
await page.getByTestId('admin-oidc-discover-btn').click().catch(() => {});
|
|
await page.waitForTimeout(800);
|
|
await expect(page.getByTestId('admin-oidc-form')).toBeVisible();
|
|
});
|
|
|
|
test('users tab: paginate the user list', async ({ page }) => {
|
|
// The list paginates at PAGE_SIZE (25); create enough to get a second page.
|
|
await apiLogin(page);
|
|
for (let i = 0; i < 26; i++) {
|
|
await apiAdminCreateUser(page, `pageu${Date.now()}${i}`);
|
|
}
|
|
await page.goto('/admin');
|
|
await page.getByTestId('admin-users-tab').click();
|
|
await expect(page.getByTestId('admin-users-pager-next-btn')).toBeVisible({ timeout: 15_000 });
|
|
await page.getByTestId('admin-users-pager-next-btn').click();
|
|
await page.waitForTimeout(400);
|
|
await page.getByTestId('admin-users-pager-prev-btn').click().catch(() => {});
|
|
});
|
|
|
|
test('plugins tab: install then save retention settings', async ({ page }) => {
|
|
await page.goto('/admin');
|
|
await page.getByTestId('admin-plugins-tab').click();
|
|
await page.getByTestId('admin-plugins-install-input').setInputFiles(PLUGIN_ZIP);
|
|
await expect(page.locator('[data-testid^="admin-plugin-delete-"]').first()).toBeVisible({
|
|
timeout: 20_000,
|
|
});
|
|
|
|
// Retention form is shown once a plugin exists.
|
|
const retention = page.getByTestId('admin-plugin-retention-form');
|
|
if (await retention.isVisible().catch(() => false)) {
|
|
await page.getByTestId('admin-plugin-retention-days-input').fill('30').catch(() => {});
|
|
await page.getByTestId('admin-plugin-retention-max-input').fill('100').catch(() => {});
|
|
await page.getByTestId('admin-plugin-retention-save-btn').click().catch(() => {});
|
|
}
|
|
|
|
// Clean up.
|
|
await page.locator('[data-testid^="admin-plugin-delete-"]').first().click();
|
|
const ok = page.getByTestId('admin-confirm-ok-btn');
|
|
if (await ok.isVisible().catch(() => false)) await ok.click();
|
|
});
|