Files
plainleaf/e2e/admin-spaces.test.ts

308 lines
11 KiB
TypeScript

import { type ChildProcess, execFileSync, spawn } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, type Page, test } from "@playwright/test";
import {
ADMIN_PASSWORD,
ADMIN_USER,
getFreePort,
waitForServer,
} from "./fixtures";
let proc: ChildProcess;
let rootDir: string;
let base: string;
const BIN = "./target/debug/silverbullet";
const CWD = join(import.meta.dirname, "..");
test.beforeAll(async () => {
rootDir = await mkdtemp(join(tmpdir(), "sb-admin-spaces-e2e-"));
// Same non-interactive provisioning as e2e/multi-space-admin.test.ts: the
// `setup` subcommand writes users.json (the admin account) + an empty
// spaces.json, which is what boots the server into multi-space mode.
execFileSync(
BIN,
["setup", rootDir, "--admin", `${ADMIN_USER}:${ADMIN_PASSWORD}`],
{
cwd: CWD,
stdio: "pipe",
},
);
const port = await getFreePort();
proc = spawn(BIN, [rootDir, "-p", String(port), "-L", "127.0.0.1"], {
cwd: CWD,
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
SB_RUNTIME_API: "0",
SB_DISABLE_SERVICE_WORKER: "1",
},
});
base = `http://127.0.0.1:${port}`;
await waitForServer(`${base}/.spaces`);
});
test.afterAll(async () => {
proc?.kill();
await rm(rootDir, { recursive: true, force: true });
});
// Log in through the admin UI before each test, so the browser context's
// cookie jar (shared with `page.request` below) carries the admin session
// for both UI navigation and direct API calls.
test.beforeEach(async ({ page }) => {
await page.goto(`${base}/.spaces`);
await page.getByLabel("Username").fill(ADMIN_USER);
await page.getByLabel("Password", { exact: true }).fill(ADMIN_PASSWORD);
await page.getByRole("button", { name: "Log in" }).click();
// The list screens have no heading — the active tab names them instead.
await expect(page.locator(".sb-tab.sb-active")).toHaveText("Spaces");
});
/** Create a space directly via the admin API (full-config POST) and return its id. */
async function createSpaceViaApi(
page: Page,
config: Record<string, unknown>,
): Promise<string> {
const resp = await page.request.post(`${base}/.spaces/api/admin/spaces`, {
data: config,
});
expect(resp.ok(), await resp.text()).toBeTruthy();
const json = await resp.json();
return json.id;
}
/** Fetch a single space's config via the admin API. */
async function fetchSpaceViaApi(page: Page, id: string): Promise<any> {
const resp = await page.request.get(`${base}/.spaces/api/admin/spaces/${id}`);
expect(resp.ok(), await resp.text()).toBeTruthy();
return resp.json();
}
/**
* Call an admin API endpoint (e.g. `api/admin/users`) using the given page's
* session cookie, and return the parsed JSON body.
*/
async function admin(
page: Page,
method: string,
path: string,
body?: unknown,
): Promise<any> {
const resp = await page.request.fetch(`${base}/.spaces/${path}`, {
method,
data: body,
});
expect(resp.ok(), await resp.text()).toBeTruthy();
return resp.json();
}
test("editing a space preserves fields the form does not manage", async ({
page,
}) => {
// Create a space via the API with a non-default themeColor and
// description, which the edit form has no inputs for.
const id = await createSpaceViaApi(page, {
name: "Work",
binding: { prefix: "/work" },
themeColor: "#ff0000",
description: "Custom description",
});
await page.goto(`${base}/.spaces`);
// The name opens the space itself; the admin-only Edit control at the end
// of the row is the durable edit route.
await page.getByRole("link", { name: "Edit" }).click();
await expect(page).toHaveURL(`${base}/.spaces/${encodeURIComponent(id)}`);
await page.getByLabel("Name").fill("Renamed");
await page.getByRole("button", { name: "Save" }).click();
// Saving returns to the space list.
await expect(page).toHaveURL(`${base}/.spaces/`);
// The canonical edit URL still resolves directly, with the saved value.
await page.goto(`${base}/.spaces/${encodeURIComponent(id)}`);
await expect(page.getByLabel("Name")).toHaveValue("Renamed");
const after = await fetchSpaceViaApi(page, id);
expect(after.name).toBe("Renamed");
expect(after.themeColor).toBe("#ff0000");
expect(after.description).toBe("Custom description");
});
test("saving an existing space returns to the list showing the change", async ({
page,
}) => {
const id = await createSpaceViaApi(page, {
name: "Feedback",
binding: { prefix: "/feedback" },
});
await page.goto(`${base}/.spaces/${encodeURIComponent(id)}`);
await page.getByLabel("Name").fill("Feedback Renamed");
await page.getByRole("button", { name: "Save" }).click();
// Landing back on the list is the confirmation: the admin sees the space
// they just edited carrying its new name, rather than an unchanged-looking
// form they have to take on trust.
await expect(page).toHaveURL(`${base}/.spaces/`);
await expect(
page.getByRole("link", { name: "Feedback Renamed" }),
).toBeVisible();
});
test("the shell allow list is editable, and only shown when shell is enabled", async ({
page,
}) => {
const id = await createSpaceViaApi(page, {
name: "Shell",
binding: { prefix: "/shell" },
shell: { enabled: true, whitelist: ["git"] },
});
await page.goto(`${base}/.spaces/${encodeURIComponent(id)}`);
await page.locator("summary", { hasText: "Advanced" }).click();
const allowed = page.getByLabel("Allowed commands");
await expect(allowed).toHaveValue("git");
// The field belongs to the toggle above it: with shell commands off there
// is nothing for an allow list to restrict.
await page.getByLabel("Enable shell commands").uncheck();
await expect(allowed).toBeHidden();
await page.getByLabel("Enable shell commands").check();
await expect(allowed).toHaveValue("git");
await allowed.fill("git pandoc");
await page.getByRole("button", { name: "Save" }).click();
await expect(page).toHaveURL(`${base}/.spaces/`);
const after = await fetchSpaceViaApi(page, id);
expect(after.shell).toEqual({ enabled: true, whitelist: ["git", "pandoc"] });
});
test("switching a host-bound space to prefix without a value is rejected", async ({
page,
}) => {
// A host-bound space: editing it never seeds the (unused) `prefix` field.
// Switching the Binding dropdown to "URL prefix" must not let an empty
// prefix through — that would silently rebind the space to the server root.
const id = await createSpaceViaApi(page, {
name: "Hostname Space",
binding: { host: "hostname-space.example.com" },
});
await page.goto(`${base}/.spaces/${encodeURIComponent(id)}`);
// The hostname affix describes the public URL, not this admin session's:
// always https (TLS is required), whatever port the server this page came
// from happens to be listening on. Nothing trails the hostname.
await expect(page.locator(".sb-url-affix")).toHaveText(["https://"]);
await page.getByLabel("Binding").selectOption("prefix");
await page.getByRole("button", { name: "Save" }).click();
await expect(page.locator(".sb-alert-error")).toContainText(
"prefix is required",
);
// Saving must have been aborted client-side: still on the edit URL.
await expect(page).toHaveURL(`${base}/.spaces/${encodeURIComponent(id)}`);
// The important assertion: the stored binding is unchanged, i.e. the PATCH
// never went through. An error message next to a binding that quietly
// changed anyway would be worse than no guard at all.
const after = await fetchSpaceViaApi(page, id);
expect(after.binding).toEqual({ host: "hostname-space.example.com" });
});
test("user create and detail screens have refreshable URLs", async ({
page,
}) => {
await page.getByRole("link", { name: "Users" }).click();
// The tab is now the screen's label, so it must be marked as the current
// page rather than merely highlighted.
await expect(page.locator("[aria-current=page]")).toHaveText("Users");
await expect(page).toHaveURL(`${base}/.spaces/users`);
await page.getByRole("link", { name: "Create user" }).click();
await expect(page).toHaveURL(`${base}/.spaces/users/new`);
await page.getByLabel("Username").fill("route-user");
await page.getByLabel("Password", { exact: true }).fill("password123");
await page.getByRole("button", { name: "Create user" }).click();
await expect(page).toHaveURL(`${base}/.spaces/users/route-user`);
await expect(page.getByRole("heading", { name: "route-user" })).toBeVisible();
await page.reload();
await expect(page.getByRole("heading", { name: "route-user" })).toBeVisible();
// A direct visit without a session goes through login and returns to the
// requested detail screen rather than dropping back at the list.
await page.context().clearCookies();
await page.goto(`${base}/.spaces/users/route-user`);
await expect(page).toHaveURL(/\/\.spaces\/login\?next=/);
await page.getByLabel("Username").fill(ADMIN_USER);
await page.getByLabel("Password", { exact: true }).fill(ADMIN_PASSWORD);
await page.getByRole("button", { name: "Log in" }).click();
await expect(page).toHaveURL(`${base}/.spaces/users/route-user`);
});
test("the wordmark's app icon loads, including on a nested URL", async ({
page,
}) => {
// A broken <img> still lays out, so assert the bytes actually arrived. The
// nested URL is the real risk: the asset path is relative, and only the
// page's <base href="/.spaces/"> stops it resolving against /.spaces/users/.
await page.goto(`${base}/.spaces/users`);
const icon = page.locator(".sb-wordmark img");
await expect(icon).toBeVisible();
expect(
await icon.evaluate((img: HTMLImageElement) => img.naturalWidth > 0),
).toBe(true);
});
test("a non-admin sees only their spaces and no admin affordances", async ({
page,
}) => {
// The file's beforeEach already established an admin session on `page`; use
// it to create a member user and a space they belong to via the admin API.
await admin(page, "POST", "api/admin/users", {
username: "member",
password: "memberpw123",
});
await admin(page, "POST", "api/admin/spaces", {
name: "Members Only",
folder: join(rootDir, "members-only"),
binding: { prefix: "/members" },
members: { member: {} },
seedIndex: true,
});
// Drop the admin session and log in as the member instead.
await page.context().clearCookies();
await page.goto(`${base}/.spaces/login`);
await page.fill("#username", "member");
await page.fill("#password", "memberpw123");
await page.click("button[type=submit]");
await expect(page.locator(".sb-space-list li")).toHaveCount(1);
await expect(page.locator("text=Members Only")).toBeVisible();
// No Users tab, no create button.
await expect(page.locator(".sb-tabs")).toHaveCount(0);
await expect(page.locator("text=Create space")).toHaveCount(0);
// With no tab bar to name the screen, the heading is what labels it — it is
// dropped only where a tab already says "Spaces".
await expect(page.getByRole("heading", { name: "Spaces" })).toBeVisible();
// Typing an admin URL yields the not-found screen, not the user list.
await page.goto(`${base}/.spaces/users`);
await expect(page.locator("h1")).toHaveText("Not found");
// And the API refuses regardless of what the UI renders. The caller is
// authenticated (just not an admin), so this is 403, not 401.
const resp = await page.request.get(`${base}/.spaces/api/admin/users`);
expect(resp.status()).toBe(403);
});