fix(test): fix playwright to suport OPAQUE+DPOP
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { seedAdmin } from './scenarios/helpers';
|
import { seedAdmin } from './spa/helpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legacy webServer flow only: seed the admin against the single server
|
* Legacy webServer flow only: seed the admin against the single server
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import { defineConfig } from '@playwright/test';
|
|
||||||
import containers from './playwright.containers.config';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Codegen recorder config — reuses the isolated-stack setup from the container
|
|
||||||
* e2e config (worker-scoped DB + app container, Nix-chromium executablePath via
|
|
||||||
* PW_CHROMIUM_PATH, etc.), but points at the per-template recorder files in
|
|
||||||
* scenarios/codegen/ instead of the e2e suite.
|
|
||||||
*
|
|
||||||
* Driven by `just front-codegen`, which sets OXICLOUD_E2E_CONTAINERS=1 (so the
|
|
||||||
* stack boots) and runs one chosen scenarios/codegen/<name>.spec.ts headed.
|
|
||||||
*/
|
|
||||||
export default defineConfig({
|
|
||||||
...containers,
|
|
||||||
testDir: './scenarios/codegen',
|
|
||||||
testIgnore: [],
|
|
||||||
});
|
|
||||||
@@ -55,8 +55,8 @@ export default defineConfig({
|
|||||||
timeout: 600_000,
|
timeout: 600_000,
|
||||||
reuseExistingServer: false,
|
reuseExistingServer: false,
|
||||||
cwd: '../..',
|
cwd: '../..',
|
||||||
stdout: 'inherit',
|
stdout: 'pipe',
|
||||||
stderr: 'inherit',
|
stderr: 'pipe',
|
||||||
env: {
|
env: {
|
||||||
...commonEnv,
|
...commonEnv,
|
||||||
OXICLOUD_SERVER_PORT: '8088',
|
OXICLOUD_SERVER_PORT: '8088',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { test, expect } from './coverage-helpers';
|
import { test, expect } from './coverage-helpers';
|
||||||
import { apiLogin, apiAdminCreateUser } from '../scenarios/helpers';
|
import { apiLogin, apiAdminCreateUser } from './helpers';
|
||||||
|
|
||||||
const PLUGIN_ZIP = path.join(__dirname, '..', 'fixtures', 'plugin-hello.zip');
|
const PLUGIN_ZIP = path.join(__dirname, '..', 'fixtures', 'plugin-hello.zip');
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from './coverage-helpers';
|
import { test, expect } from './coverage-helpers';
|
||||||
import { apiLogin, seedFilesAndFolders } from '../scenarios/helpers';
|
import { apiLogin, seedFilesAndFolders } from './helpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AppShell chrome — global search box (suggestions + submit), notification
|
* AppShell chrome — global search box (suggestions + submit), notification
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from './coverage-helpers';
|
import { test, expect } from './coverage-helpers';
|
||||||
import { apiLogin, apiCreateFolder } from '../scenarios/helpers';
|
import { apiLogin, apiCreateFolder } from './helpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Batch selection bar — select-all then batch favorite / batch delete, each in
|
* Batch selection bar — select-all then batch favorite / batch delete, each in
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from './coverage-helpers';
|
import { test, expect } from './coverage-helpers';
|
||||||
import { apiLogin } from '../scenarios/helpers';
|
import { apiLogin } from './helpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Command palette — opens on Ctrl/Cmd+K, filters on typed input, and closes on
|
* Command palette — opens on Ctrl/Cmd+K, filters on typed input, and closes on
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { Page, expect } from '@playwright/test';
|
import { Page, expect } from '@playwright/test';
|
||||||
import { test as base, TEST_ADMIN } from '../scenarios/helpers';
|
import { test as base, TEST_ADMIN } from './helpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `test` for the SvelteKit SPA coverage suite. Extends the shared `test`
|
* `test` for the SvelteKit SPA coverage suite. Extends the shared `test`
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from './coverage-helpers';
|
import { test, expect } from './coverage-helpers';
|
||||||
import { apiLogin } from '../scenarios/helpers';
|
import { apiLogin } from './helpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Device-pairing route — submit a (bogus) device code to exercise the lookup
|
* Device-pairing route — submit a (bogus) device code to exercise the lookup
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
apiLogin,
|
apiLogin,
|
||||||
apiSetHideDotfiles,
|
apiSetHideDotfiles,
|
||||||
apiTrashFolder,
|
apiTrashFolder,
|
||||||
} from '../scenarios/helpers';
|
} from './helpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dotfile-hide filter — end-to-end coverage of the UI-only, per-user
|
* Dotfile-hide filter — end-to-end coverage of the UI-only, per-user
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from './coverage-helpers';
|
import { test, expect } from './coverage-helpers';
|
||||||
import { apiLogin, apiCreateFolder } from '../scenarios/helpers';
|
import { apiLogin, apiCreateFolder } from './helpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Error-path coverage — drive the catch/error branches that the happy-path
|
* Error-path coverage — drive the catch/error branches that the happy-path
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from './coverage-helpers';
|
import { test, expect } from './coverage-helpers';
|
||||||
import { apiLogin, apiCreateFolder } from '../scenarios/helpers';
|
import { apiLogin, apiCreateFolder } from './helpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Favorites route — favorite a folder from the files context menu, see it in
|
* Favorites route — favorite a folder from the files context menu, see it in
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from './coverage-helpers';
|
import { test, expect } from './coverage-helpers';
|
||||||
import { apiLogin, apiCreateFolder, apiUploadFile, SAMPLE_FILES } from '../scenarios/helpers';
|
import { apiLogin, apiCreateFolder, apiUploadFile, SAMPLE_FILES } from './helpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deeper files-page coverage (the largest source file): list/grid view toggle,
|
* Deeper files-page coverage (the largest source file): list/grid view toggle,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from './coverage-helpers';
|
import { test, expect } from './coverage-helpers';
|
||||||
import { apiLogin, apiCreateFolder, apiUploadFile, SAMPLE_FILES } from '../scenarios/helpers';
|
import { apiLogin, apiCreateFolder, apiUploadFile, SAMPLE_FILES } from './helpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* File-browser CRUD against the SvelteKit `/files` route — the single largest
|
* File-browser CRUD against the SvelteKit `/files` route — the single largest
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { seedAdmin } from '../scenarios/helpers';
|
import { seedAdmin } from './helpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global setup for the SPA coverage suite:
|
* Global setup for the SPA coverage suite:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from './coverage-helpers';
|
import { test, expect } from './coverage-helpers';
|
||||||
import { apiLogin, apiAdminCreateUser } from '../scenarios/helpers';
|
import { apiLogin, apiAdminCreateUser } from './helpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Groups route — create a group, expand it, search members, rename, and delete
|
* Groups route — create a group, expand it, search members, rename, and delete
|
||||||
|
|||||||
@@ -0,0 +1,533 @@
|
|||||||
|
import { test as base, Page, expect } from '@playwright/test';
|
||||||
|
import { startStack, Stack } from '../fixtures/oxicloud-stack';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When `OXICLOUD_E2E_CONTAINERS=1`, each Playwright worker boots its own
|
||||||
|
* isolated DB + app stack via Testcontainers (see `playwright.containers.
|
||||||
|
* config.ts`). Otherwise the legacy single-server webServer flow is used
|
||||||
|
* and this fixture is an inert pass-through.
|
||||||
|
*/
|
||||||
|
const USE_CONTAINERS = process.env.OXICLOUD_E2E_CONTAINERS === '1';
|
||||||
|
|
||||||
|
type WorkerFixtures = {
|
||||||
|
/** The per-worker isolated stack, or `null` in the legacy webServer flow. */
|
||||||
|
stack: Stack | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended `test` fixture with two responsibilities:
|
||||||
|
*
|
||||||
|
* 1. `stack` (worker-scoped) — in container mode, starts a dedicated
|
||||||
|
* DB + app stack per worker, seeds its admin, and tears it down at
|
||||||
|
* worker exit. The app instance is reused across every test the worker
|
||||||
|
* runs, so container startup is paid once per worker, not per test.
|
||||||
|
* 2. `page` — fails any test that produces an unhandled browser-side JS
|
||||||
|
* error (SyntaxError, ReferenceError, uncaught rejection, etc.).
|
||||||
|
*
|
||||||
|
* Import `test` from this module instead of `@playwright/test` so every spec
|
||||||
|
* gets both behaviours automatically without per-file boilerplate.
|
||||||
|
*/
|
||||||
|
export const test = base.extend<object, WorkerFixtures>({
|
||||||
|
stack: [
|
||||||
|
async ({}, use) => {
|
||||||
|
if (!USE_CONTAINERS) {
|
||||||
|
await use(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stack = await startStack();
|
||||||
|
try {
|
||||||
|
await seedAdmin(stack.baseURL);
|
||||||
|
await use(stack);
|
||||||
|
} finally {
|
||||||
|
await stack.stop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Worker-scoped: booting Postgres + the app container (image build on
|
||||||
|
// first run, migrations on boot) can take well over the default 30s
|
||||||
|
// fixture timeout. Match the 180s container startup budget in startStack.
|
||||||
|
{ scope: 'worker', timeout: 200_000 },
|
||||||
|
],
|
||||||
|
|
||||||
|
// Point relative `page.goto('/')` at the per-worker stack when present;
|
||||||
|
// otherwise fall back to the baseURL configured in the project (the
|
||||||
|
// legacy webServer at :8087).
|
||||||
|
baseURL: async ({ stack }, use, testInfo) => {
|
||||||
|
await use(stack ? stack.baseURL : testInfo.project.use.baseURL);
|
||||||
|
},
|
||||||
|
|
||||||
|
page: async ({ page }, use) => {
|
||||||
|
const jsErrors: Error[] = [];
|
||||||
|
page.on('pageerror', (err) => jsErrors.push(err));
|
||||||
|
await use(page);
|
||||||
|
if (jsErrors.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`${jsErrors.length} unhandled JS error(s) on page:\n` +
|
||||||
|
jsErrors.map((e) => ` • ${e.message}`).join('\n')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TEST_ADMIN = {
|
||||||
|
username: 'admin',
|
||||||
|
email: 'testadmin@example.com',
|
||||||
|
password: 'TestPassword1!',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the first-admin account via the public `POST /api/setup` route.
|
||||||
|
* Idempotent: a 409 (admin already exists) is treated as success so the
|
||||||
|
* call is safe to retry and to run once per worker.
|
||||||
|
*
|
||||||
|
* Shared by `global-setup.ts` (legacy flow, single server) and the
|
||||||
|
* worker-scoped `stack` fixture (container flow, one server per worker).
|
||||||
|
*/
|
||||||
|
export async function seedAdmin(baseURL: string, admin = TEST_ADMIN): Promise<void> {
|
||||||
|
const res = await fetch(`${baseURL}/api/setup`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: admin.username,
|
||||||
|
email: admin.email,
|
||||||
|
password: admin.password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok && res.status !== 409) {
|
||||||
|
throw new Error(`Admin setup failed: ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate the page's browser context, ready for subsequent
|
||||||
|
* `page.goto()` calls to load already-signed-in.
|
||||||
|
*
|
||||||
|
* Uses the SPA's real login flow (`page.goto('/login')` → fill form
|
||||||
|
* → submit) rather than a bare `POST /api/auth/login`, so this works
|
||||||
|
* correctly under both auth modes the test env supports:
|
||||||
|
*
|
||||||
|
* * `OXICLOUD_AUTH_OPAQUE_MODE=off` — SPA does legacy login,
|
||||||
|
* server accepts.
|
||||||
|
* * `OXICLOUD_AUTH_OPAQUE_MODE=migrate` — first login legacy-
|
||||||
|
* succeeds + silently mints an OPAQUE envelope (Phase 2 hook);
|
||||||
|
* every subsequent login the SPA detects the envelope via
|
||||||
|
* `/api/auth/opaque/login/lookup` and does the full KE1/KE3
|
||||||
|
* OPAQUE handshake. Legacy `POST /api/auth/login` would 403
|
||||||
|
* with `opaque_migrated_use_opaque` (Phase 4) from the second
|
||||||
|
* login on — that's what the old bare-POST apiLogin used to
|
||||||
|
* hit as soon as OPAQUE went from `off` to `migrate`.
|
||||||
|
* * `OXICLOUD_DPOP_MODE=required` — the SPA computes and sends
|
||||||
|
* `dpop_jkt` in the login body; the session is created bound.
|
||||||
|
* A bare-POST wouldn't include it, so subsequent requests
|
||||||
|
* wouldn't get DPoP-signed. Going through the SPA keeps the
|
||||||
|
* end-to-end flow honest.
|
||||||
|
*
|
||||||
|
* Overhead vs the old direct POST: ~200-500 ms per test to load
|
||||||
|
* `/login`, submit, and wait for the post-login redirect. Runs
|
||||||
|
* once per test (from `beforeEach`), so the total suite tax is
|
||||||
|
* modest and the coverage payoff is real.
|
||||||
|
*/
|
||||||
|
export async function apiLogin(page: Page, admin = TEST_ADMIN): Promise<void> {
|
||||||
|
// Idempotence — many specs' beforeEach + test body both call
|
||||||
|
// apiLogin; the old bare-POST version was a no-op on a live
|
||||||
|
// session and callers rely on that. Navigate to /login FIRST,
|
||||||
|
// then decide: if the SPA's layout guard redirected us away
|
||||||
|
// (URL no longer starts with /login), we're already signed in
|
||||||
|
// as SOMEONE and can identify who via a browser-context probe
|
||||||
|
// (the SW signs the DPoP proof for the bound session — a Node-
|
||||||
|
// side `page.request.get` would 401 under `DPOP_MODE=required`
|
||||||
|
// and falsely conclude "not signed in", sending us back to
|
||||||
|
// /login where the layout guard redirects again → the fill()
|
||||||
|
// below times out with "The folder is empty" showing on the
|
||||||
|
// failure screenshot).
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
if (!new URL(page.url()).pathname.startsWith('/login')) {
|
||||||
|
// SPA redirected away → we're already signed in. Identify who.
|
||||||
|
const probe = await browserFetch(page, { url: '/api/auth/me', method: 'GET' });
|
||||||
|
if (probe.ok) {
|
||||||
|
const body = JSON.parse(probe.body || '{}') as { username?: string };
|
||||||
|
if (body.username === admin.username) return;
|
||||||
|
}
|
||||||
|
// Wrong user (or session raced out from under us) — nuke
|
||||||
|
// cookies + storage and re-goto /login. Clearing cookies alone
|
||||||
|
// isn't enough: the SW keypair lives in IndexedDB and the
|
||||||
|
// login-form soft-reload flag lives in sessionStorage.
|
||||||
|
await page.context().clearCookies();
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
}
|
||||||
|
|
||||||
|
// `networkidle` above waits for the SPA's boot probes
|
||||||
|
// (`getOidcProviders` + `getAuthStatus` in `login/+page.svelte::
|
||||||
|
// onMount`) to settle BEFORE we touch the form. Otherwise the
|
||||||
|
// boot `$effect` fires MID-FILL — when `booting` flips from true
|
||||||
|
// to false, the auto-focus effect steals focus back to the
|
||||||
|
// identifier input and any remaining characters of the password-
|
||||||
|
// fill land in the username field. Symptom: username=
|
||||||
|
// "adminTestPassword1!", password="", submit-button shows
|
||||||
|
// "Send sign-in link" → SPA fires magic-link/send with the
|
||||||
|
// concatenated identifier and login never completes.
|
||||||
|
await page.getByTestId('login-username-input').fill(admin.username);
|
||||||
|
await page.getByTestId('login-password-input').fill(admin.password);
|
||||||
|
await page.getByTestId('login-submit-btn').click();
|
||||||
|
// Post-login the SPA's `goto(redirectTarget)` sends the user
|
||||||
|
// to `/files` (default) or a `?redirect=` target — OR to
|
||||||
|
// `/profile?forcePasswordChange=1` when the backend has stamped
|
||||||
|
// `force_password_change_at_next_login=true` on this account
|
||||||
|
// (usually because a prior admin-reset test flipped it). Match
|
||||||
|
// any post-login destination that ISN'T `/login` itself. The
|
||||||
|
// 15s ceiling covers the OPAQUE-post-migration path: WASM load
|
||||||
|
// + KE1 + KE3 + Argon2id.
|
||||||
|
await page.waitForURL((url) => !url.pathname.startsWith('/login'), {
|
||||||
|
timeout: 15_000,
|
||||||
|
waitUntil: 'commit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a `fetch` in the PAGE context via `page.evaluate` and return
|
||||||
|
* `{ ok, status, body }`. Threading requests through the browser
|
||||||
|
* (rather than Playwright's Node-side `page.request.*`) is load-
|
||||||
|
* bearing under `OXICLOUD_DPOP_MODE=required`:
|
||||||
|
*
|
||||||
|
* * page-context `fetch` gets intercepted by the SPA's Service
|
||||||
|
* Worker (`service-worker.ts`), which mints and attaches a
|
||||||
|
* valid DPoP proof per request using the browser-held keypair;
|
||||||
|
* * `page.request.*` runs in Node — no SW, no proof — and would
|
||||||
|
* 401 on every state-changing call against a bound session.
|
||||||
|
*
|
||||||
|
* CSRF is read inline from `document.cookie` (double-submit token
|
||||||
|
* lives on the non-HttpOnly `oxicloud_csrf` cookie). Body is passed
|
||||||
|
* as an already-serialized string so this helper works uniformly
|
||||||
|
* for JSON, form, and multipart-manually-encoded payloads.
|
||||||
|
*/
|
||||||
|
async function browserFetch(
|
||||||
|
page: Page,
|
||||||
|
init: {
|
||||||
|
url: string;
|
||||||
|
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||||
|
contentType?: string;
|
||||||
|
body?: string;
|
||||||
|
},
|
||||||
|
): Promise<{ ok: boolean; status: number; body: string }> {
|
||||||
|
return page.evaluate(
|
||||||
|
async ({ url, method, contentType, body }) => {
|
||||||
|
const csrf = document.cookie.match(/(?:^|; )oxicloud_csrf=([^;]+)/)?.[1] ?? '';
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (csrf) headers['x-csrf-token'] = csrf;
|
||||||
|
if (contentType) headers['content-type'] = contentType;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
return { ok: res.ok, status: res.status, body: await res.text() };
|
||||||
|
},
|
||||||
|
init,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience: `browserFetch` + JSON body encoding. */
|
||||||
|
async function browserFetchJson<T>(
|
||||||
|
page: Page,
|
||||||
|
method: 'POST' | 'PUT' | 'PATCH',
|
||||||
|
url: string,
|
||||||
|
body: unknown,
|
||||||
|
): Promise<{ ok: boolean; status: number; body: string; json(): T }> {
|
||||||
|
const res = await browserFetch(page, {
|
||||||
|
url,
|
||||||
|
method,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return { ...res, json: () => JSON.parse(res.body) as T };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A folder as returned by the API (subset we use). */
|
||||||
|
export type ApiFolder = { id: string; parent_id: string | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a folder via the API and return it. `parentId` omitted ⇒ the folder
|
||||||
|
* is created in the caller's home (root) folder, and the returned `parent_id`
|
||||||
|
* is that home folder's id (handy as the target for "root" file uploads, which
|
||||||
|
* require an explicit folder id). Requires the page to already be
|
||||||
|
* authenticated (see `apiLogin`).
|
||||||
|
*/
|
||||||
|
export async function apiCreateFolder(
|
||||||
|
page: Page,
|
||||||
|
name: string,
|
||||||
|
parentId?: string,
|
||||||
|
): Promise<ApiFolder> {
|
||||||
|
const res = await browserFetchJson<ApiFolder>(
|
||||||
|
page,
|
||||||
|
'POST',
|
||||||
|
'/api/folders',
|
||||||
|
parentId ? { name, parent_id: parentId } : { name },
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`apiCreateFolder(${name}) failed: ${res.status} ${res.body}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a regular user via the admin API. Requires the page to be authenticated
|
||||||
|
* as an admin (see `apiLogin`). Returns the created username. Handy for tests
|
||||||
|
* that need a second account (sharing, group membership, recipient search).
|
||||||
|
*/
|
||||||
|
export async function apiAdminCreateUser(page: Page, username: string): Promise<string> {
|
||||||
|
const res = await browserFetchJson(page, 'POST', '/api/admin/users', {
|
||||||
|
username,
|
||||||
|
password: 'TestPassword1!',
|
||||||
|
email: `${username}@example.test`,
|
||||||
|
role: 'user',
|
||||||
|
quota_bytes: 1073741824,
|
||||||
|
});
|
||||||
|
if (!res.ok)
|
||||||
|
throw new Error(`apiAdminCreateUser(${username}) failed: ${res.status} ${res.body}`);
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a group via the API (requires an authenticated admin/manager). Returns
|
||||||
|
* the group name. Useful for sharing-with-group and group-membership tests.
|
||||||
|
*/
|
||||||
|
export async function apiCreateGroup(page: Page, name: string): Promise<string> {
|
||||||
|
const res = await browserFetchJson(page, 'POST', '/api/groups', { name, description: null });
|
||||||
|
if (!res.ok) throw new Error(`apiCreateGroup(${name}) failed: ${res.status} ${res.body}`);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move a folder to trash via the API (DELETE /api/folders/{id}). */
|
||||||
|
export async function apiTrashFolder(page: Page, folderId: string): Promise<void> {
|
||||||
|
const res = await browserFetch(page, {
|
||||||
|
url: `/api/folders/${folderId}`,
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`apiTrashFolder(${folderId}) failed: ${res.status} ${res.body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record an access in the user's "recent" list (POST /api/recent/{type}/{id})
|
||||||
|
* so the /recent route has deterministic content. Best-effort: a non-2xx is
|
||||||
|
* tolerated so callers don't fail on a recents quirk.
|
||||||
|
*/
|
||||||
|
export async function apiRecordRecent(
|
||||||
|
page: Page,
|
||||||
|
itemType: 'file' | 'folder',
|
||||||
|
id: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await browserFetch(page, {
|
||||||
|
url: `/api/recent/${itemType}/${id}`,
|
||||||
|
method: 'POST',
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Empty the trash via the API (DELETE /api/trash/empty) for a clean slate. */
|
||||||
|
export async function apiEmptyTrash(page: Page): Promise<void> {
|
||||||
|
const res = await browserFetch(page, { url: '/api/trash/empty', method: 'DELETE' });
|
||||||
|
if (!res.ok) throw new Error(`apiEmptyTrash failed: ${res.status} ${res.body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flip the caller's `ui_preferences.hide_dotfiles` server-side. Used by the
|
||||||
|
* dotfile-filter e2e spec to establish a known state at test start and to
|
||||||
|
* clean up at teardown so sibling tests aren't polluted by a leftover
|
||||||
|
* "hidden" mode (the preference is persistent across sessions because it's
|
||||||
|
* stored on `auth.users.ui_preferences`, not in localStorage).
|
||||||
|
*
|
||||||
|
* PATCHes only `hide_dotfiles`; siblings in the bag (view_mode, future
|
||||||
|
* keys) survive the shallow-merge on the server side.
|
||||||
|
*/
|
||||||
|
export async function apiSetHideDotfiles(page: Page, hide: boolean): Promise<void> {
|
||||||
|
const res = await browserFetchJson(page, 'PATCH', '/api/auth/me/profile', {
|
||||||
|
ui_preferences: { hide_dotfiles: hide },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`apiSetHideDotfiles(${hide}) failed: ${res.status} ${res.body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A file to seed: its name, MIME type, and raw bytes. */
|
||||||
|
export type SeedFile = { name: string; mimeType: string; body: Buffer };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload one file via the API into `folderId`. The target folder is
|
||||||
|
* **required**: the server resolves the file's owner from its parent folder
|
||||||
|
* and rejects an upload with no `folder_id` ("folder_id is required to
|
||||||
|
* determine file owner"). For a "root" file, pass the home folder's id — the
|
||||||
|
* `parent_id` returned by `apiCreateFolder(name)`.
|
||||||
|
*
|
||||||
|
* The `folder_id` field is sent before `file` because the upload handler
|
||||||
|
* parses the multipart stream in order and permission-checks the target folder
|
||||||
|
* before spooling the body.
|
||||||
|
*/
|
||||||
|
export async function apiUploadFile(
|
||||||
|
page: Page,
|
||||||
|
file: SeedFile,
|
||||||
|
folderId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
// Multipart upload rides the browser too so the SW signs it. The
|
||||||
|
// file bytes cross the page.evaluate boundary as a base64 string
|
||||||
|
// (structured clone doesn't handle Node `Buffer` cleanly), then
|
||||||
|
// get decoded into a `Blob` on the page side. `FormData` builds
|
||||||
|
// the multipart body — the browser sets the boundary + content-
|
||||||
|
// type header itself, so we deliberately don't pass a Content-
|
||||||
|
// Type in the fetch init (would collide).
|
||||||
|
const b64 = file.body.toString('base64');
|
||||||
|
const res = await page.evaluate(
|
||||||
|
async ({ b64, name, mimeType, folderId }) => {
|
||||||
|
const csrf = document.cookie.match(/(?:^|; )oxicloud_csrf=([^;]+)/)?.[1] ?? '';
|
||||||
|
const binary = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||||
|
const blob = new Blob([binary], { type: mimeType });
|
||||||
|
const form = new FormData();
|
||||||
|
form.set('folder_id', folderId);
|
||||||
|
form.set('file', blob, name);
|
||||||
|
const r = await fetch('/api/files/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: csrf ? { 'x-csrf-token': csrf } : {},
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
return { ok: r.ok, status: r.status, body: await r.text() };
|
||||||
|
},
|
||||||
|
{ b64, name: file.name, mimeType: file.mimeType, folderId },
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`apiUploadFile(${file.name}) failed: ${res.status} ${res.body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small library of files spanning common types (text, markdown, JSON, CSV,
|
||||||
|
* PNG image, PDF), so a recording starts from a browser that exercises the
|
||||||
|
* different icons / previews / row renderers. Bytes are tiny but valid.
|
||||||
|
*/
|
||||||
|
export const SAMPLE_FILES = {
|
||||||
|
text: (): SeedFile => ({
|
||||||
|
name: 'notes.txt',
|
||||||
|
mimeType: 'text/plain',
|
||||||
|
body: Buffer.from('Hello from the codegen seed.\nLine two.\n'),
|
||||||
|
}),
|
||||||
|
markdown: (): SeedFile => ({
|
||||||
|
name: 'README.md',
|
||||||
|
mimeType: 'text/markdown',
|
||||||
|
body: Buffer.from('# Seeded\n\nA **markdown** file for the file browser.\n'),
|
||||||
|
}),
|
||||||
|
json: (): SeedFile => ({
|
||||||
|
name: 'config.json',
|
||||||
|
mimeType: 'application/json',
|
||||||
|
body: Buffer.from(JSON.stringify({ seeded: true, items: [1, 2, 3] }, null, 2)),
|
||||||
|
}),
|
||||||
|
csv: (): SeedFile => ({
|
||||||
|
name: 'data.csv',
|
||||||
|
mimeType: 'text/csv',
|
||||||
|
body: Buffer.from('id,name,size\n1,alpha,10\n2,beta,20\n'),
|
||||||
|
}),
|
||||||
|
png: (): SeedFile => ({
|
||||||
|
name: 'pixel.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
// 1×1 transparent PNG.
|
||||||
|
body: Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||||
|
'base64',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
pdf: (): SeedFile => ({
|
||||||
|
name: 'sample.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
body: Buffer.from(
|
||||||
|
'%PDF-1.1\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n' +
|
||||||
|
'2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n' +
|
||||||
|
'3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]>>endobj\n' +
|
||||||
|
'trailer<</Root 1 0 R>>\n%%EOF\n',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed a representative tree of folders and files of different types for the
|
||||||
|
* authenticated user, so a codegen recording (or a test) starts from a
|
||||||
|
* populated file browser. Idempotent enough for one run per worker; re-running
|
||||||
|
* creates duplicate names (the backend allows them). Requires `apiLogin` first.
|
||||||
|
*
|
||||||
|
* Layout created in the user's home folder:
|
||||||
|
*
|
||||||
|
* config.json (home/root)
|
||||||
|
* pixel.png (home/root)
|
||||||
|
* Documents/ README.md, notes.txt
|
||||||
|
* Documents/Reports/ data.csv, sample.pdf
|
||||||
|
* Images/ pixel.png
|
||||||
|
*
|
||||||
|
* Returns the created folder ids (plus the resolved `home` id) so callers can
|
||||||
|
* deep-link or assert.
|
||||||
|
*/
|
||||||
|
export async function seedFilesAndFolders(
|
||||||
|
page: Page,
|
||||||
|
): Promise<{ home: string; documents: string; reports: string; images: string }> {
|
||||||
|
const documents = await apiCreateFolder(page, 'Documents');
|
||||||
|
const reports = await apiCreateFolder(page, 'Reports', documents.id);
|
||||||
|
const images = await apiCreateFolder(page, 'Images');
|
||||||
|
|
||||||
|
// Created-at-root folders carry the home folder id as their parent — use it
|
||||||
|
// as the target for the "root" files (uploads require an explicit folder).
|
||||||
|
const home = documents.parent_id;
|
||||||
|
if (!home) {
|
||||||
|
throw new Error('seedFilesAndFolders: could not resolve home folder id from a root folder');
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiUploadFile(page, SAMPLE_FILES.json(), home);
|
||||||
|
await apiUploadFile(page, SAMPLE_FILES.png(), home);
|
||||||
|
await apiUploadFile(page, SAMPLE_FILES.markdown(), documents.id);
|
||||||
|
await apiUploadFile(page, SAMPLE_FILES.text(), documents.id);
|
||||||
|
await apiUploadFile(page, SAMPLE_FILES.csv(), reports.id);
|
||||||
|
await apiUploadFile(page, SAMPLE_FILES.pdf(), reports.id);
|
||||||
|
await apiUploadFile(page, SAMPLE_FILES.png(), images.id);
|
||||||
|
|
||||||
|
return { home, documents: documents.id, reports: reports.id, images: images.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log in as the test admin and wait until the main app is fully initialized.
|
||||||
|
*
|
||||||
|
* We wait for two things after the login redirect:
|
||||||
|
* 1. `#sidebar` — confirms the main HTML has loaded.
|
||||||
|
* 2. `#user-avatar-btn .user-vignette` — confirms that `setupUserMenu()` has
|
||||||
|
* run and mounted the avatar vignette. This is the earliest reliable
|
||||||
|
* signal that the click-handler on the avatar button is attached, so any
|
||||||
|
* subsequent test that opens the user menu will not race against JS startup.
|
||||||
|
*
|
||||||
|
* Without (2), CI (Ubuntu + Xvfb) occasionally clicks the button before the
|
||||||
|
* event listener is registered because the JS runtime is slower than on macOS.
|
||||||
|
*/
|
||||||
|
export async function loginAsAdmin(page: Page) {
|
||||||
|
await goToLoginPage(page);
|
||||||
|
await page.locator('#login-username').fill(TEST_ADMIN.username);
|
||||||
|
await page.locator('#login-password').fill(TEST_ADMIN.password);
|
||||||
|
await page.locator('#login-submit').click();
|
||||||
|
await expect(page.locator('#sidebar')).toBeVisible({ timeout: 15_000 });
|
||||||
|
// Wait for the JS app to initialise: avatar vignette present ⟹ click handler attached.
|
||||||
|
await expect(page.locator('#user-avatar-btn .user-vignette')).toBeAttached({ timeout: 10_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to `/` and land on the login panel, handling the language selector
|
||||||
|
* if it appears (fresh localStorage). The admin account is guaranteed to exist
|
||||||
|
* because globalSetup created it before any test ran.
|
||||||
|
*/
|
||||||
|
export async function goToLoginPage(page: Page) {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Both panels start with .hidden — wait for JS to reveal one.
|
||||||
|
// Use expect() (5 s default) rather than waitForSelector() (30 s) so a JS
|
||||||
|
// crash fails fast instead of hanging for the full test timeout.
|
||||||
|
await expect(
|
||||||
|
page.locator('#language-panel:not(.hidden), #login-panel:not(.hidden)').first()
|
||||||
|
).toBeAttached();
|
||||||
|
|
||||||
|
if (await page.locator('#language-panel').isVisible()) {
|
||||||
|
await page.locator('#language-continue').click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.locator('#login-panel')).toBeVisible();
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from './coverage-helpers';
|
import { test, expect } from './coverage-helpers';
|
||||||
import { apiLogin, apiCreateFolder } from '../scenarios/helpers';
|
import { apiLogin, apiCreateFolder } from './helpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MoveDialog coverage — move a folder into another via the context menu.
|
* MoveDialog coverage — move a folder into another via the context menu.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { test, expect } from './coverage-helpers';
|
import { test, expect } from './coverage-helpers';
|
||||||
import { apiLogin, apiCreateFolder, apiUploadFile } from '../scenarios/helpers';
|
import { apiLogin, apiCreateFolder, apiUploadFile } from './helpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Music route — playlist lifecycle, the add-tracks dialog, and the audio player
|
* Music route — playlist lifecycle, the add-tracks dialog, and the audio player
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from './coverage-helpers';
|
import { test, expect } from './coverage-helpers';
|
||||||
import { apiLogin, seedFilesAndFolders } from '../scenarios/helpers';
|
import { apiLogin, seedFilesAndFolders } from './helpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads every primary route while authenticated. Each route is its own test
|
* Loads every primary route while authenticated. Each route is its own test
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from './coverage-helpers';
|
import { test, expect } from './coverage-helpers';
|
||||||
import { apiLogin, apiCreateFolder, apiUploadFile, SAMPLE_FILES } from '../scenarios/helpers';
|
import { apiLogin, apiCreateFolder, apiUploadFile, SAMPLE_FILES } from './helpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Photos route — populate the grid with uploaded images, open the lightbox and
|
* Photos route — populate the grid with uploaded images, open the lightbox and
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from './coverage-helpers';
|
import { test, expect } from './coverage-helpers';
|
||||||
import { apiLogin } from '../scenarios/helpers';
|
import { apiLogin } from './helpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Profile route — edit the profile, open the avatar panel, and generate an app
|
* Profile route — edit the profile, open the avatar panel, and generate an app
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from './coverage-helpers';
|
import { test, expect } from './coverage-helpers';
|
||||||
import { apiLogin, apiCreateFolder, apiRecordRecent } from '../scenarios/helpers';
|
import { apiLogin, apiCreateFolder, apiRecordRecent } from './helpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recent route — populate it via the recents API (the SPA doesn't auto-record
|
* Recent route — populate it via the recents API (the SPA doesn't auto-record
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from './coverage-helpers';
|
import { test, expect } from './coverage-helpers';
|
||||||
import { apiLogin, apiCreateFolder, apiUploadFile } from '../scenarios/helpers';
|
import { apiLogin, apiCreateFolder, apiUploadFile } from './helpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search route — seed a uniquely-named file, query for it, then exercise the
|
* Search route — seed a uniquely-named file, query for it, then exercise the
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from './coverage-helpers';
|
import { test, expect } from './coverage-helpers';
|
||||||
import { apiLogin, apiCreateFolder, apiAdminCreateUser, apiCreateGroup } from '../scenarios/helpers';
|
import { apiLogin, apiCreateFolder, apiAdminCreateUser, apiCreateGroup } from './helpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User-to-user sharing — create a second user, add them as a member through the
|
* User-to-user sharing — create a second user, add them as a member through the
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from './coverage-helpers';
|
import { test, expect } from './coverage-helpers';
|
||||||
import { apiLogin, apiCreateFolder } from '../scenarios/helpers';
|
import { apiLogin, apiCreateFolder } from './helpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared route — create a public link via the share dialog, then view it in
|
* Shared route — create a public link via the share dialog, then view it in
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from './coverage-helpers';
|
import { test, expect } from './coverage-helpers';
|
||||||
import { apiLogin, apiCreateFolder } from '../scenarios/helpers';
|
import { apiLogin, apiCreateFolder } from './helpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ShareDialog coverage — opened from the files context menu. Exercises the
|
* ShareDialog coverage — opened from the files context menu. Exercises the
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from './coverage-helpers';
|
import { test, expect } from './coverage-helpers';
|
||||||
import { apiLogin, apiCreateFolder, apiTrashFolder } from '../scenarios/helpers';
|
import { apiLogin, apiCreateFolder, apiTrashFolder } from './helpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trash flow — restore and permanently delete items from /trash. The folder is
|
* Trash flow — restore and permanently delete items from /trash. The folder is
|
||||||
|
|||||||
Reference in New Issue
Block a user