mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
A release-readiness QA pass over the whole product. The commits split into defects a user would hit and gates that were reporting green while measuring nothing. ## Fixes that change behaviour Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so request.ip came from a client-set header and a forged X-Forwarded-For got past the login limiter. The default is now a private-network trust list. A transient Postgres outage stranded in-flight jobs, leaving finished output on disk with no row pointing at it. A reconciler now resolves those rows and adopts the bytes rather than dropping the work. A Redis connection that moved to a new address wedged every read-blocked consumer, so completions stopped signalling while health still answered 200. Socket timeouts plus subscriber pings recover it. Installing more than one AI bundle left the shared venv multi-versioned and silently broke three tools. The installer now reconciles distributions to one version each. Converting an image to JXL at quality 1 through 4 returned a 500, because libjxl 0.7 rejects the distance those values compute. The quality is floored at what the encoder honours. A missing ffmpeg was also reported to the user as a corrupt upload; it now says the engine is unavailable. RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at 0.22.2, and the release scan was split so it can fail on an unfixed critical instead of hiding it behind ignore-unfixed. ## Gates that could not fail Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs build; coverage discarded its whole report on any failing test; the lint gate skipped root tests, scripts, and two workspaces; and several generated matrices counted a host missing ffmpeg as a passing tool. Each now measures what it claims. Full evidence and the outstanding release items are tracked locally and are not part of this branch.
1556 lines
58 KiB
TypeScript
1556 lines
58 KiB
TypeScript
import { test as base, expect } from "@playwright/test";
|
|
import { authFile } from "../../playwright.config";
|
|
import { login, openSettings, test } from "./helpers";
|
|
|
|
const API = process.env.API_URL || "http://localhost:13490";
|
|
|
|
/** Auth + JSON content-type (POST, PUT). */
|
|
function authJson(token: string): Record<string, string> {
|
|
return { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
|
|
}
|
|
|
|
/** Auth header only (GET, DELETE). */
|
|
function authOnly(token: string): Record<string, string> {
|
|
return { Authorization: `Bearer ${token}` };
|
|
}
|
|
|
|
async function getAdminToken(): Promise<string> {
|
|
const res = await fetch(`${API}/api/auth/login`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ username: "admin", password: "admin" }),
|
|
});
|
|
// Fail here, not three calls later. Without this an admin login that was
|
|
// refused returns undefined and every later request sends "Bearer undefined",
|
|
// which surfaces as a 401 on whatever the test happened to do next.
|
|
if (!res.ok) {
|
|
throw new Error(
|
|
`Admin login failed with ${res.status}; the admin password may not have been restored by an earlier spec`,
|
|
);
|
|
}
|
|
const data = (await res.json()) as { token?: string };
|
|
if (!data.token) throw new Error("Admin login returned no token");
|
|
return data.token;
|
|
}
|
|
|
|
/** Delete all non-admin test users by prefix. */
|
|
async function cleanupUsersByPrefix(adminToken: string, prefix: string): Promise<void> {
|
|
const listRes = await fetch(`${API}/api/auth/users`, {
|
|
headers: authOnly(adminToken),
|
|
});
|
|
if (!listRes.ok) return;
|
|
const { users } = await listRes.json();
|
|
for (const u of users) {
|
|
if (u.username.startsWith(prefix)) {
|
|
await fetch(`${API}/api/auth/users/${u.id}`, {
|
|
method: "DELETE",
|
|
headers: authOnly(adminToken),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Delete test teams by prefix. */
|
|
async function cleanupTeamsByPrefix(adminToken: string, prefix: string): Promise<void> {
|
|
const listRes = await fetch(`${API}/api/v1/teams`, {
|
|
headers: authOnly(adminToken),
|
|
});
|
|
if (!listRes.ok) return;
|
|
const { teams } = await listRes.json();
|
|
for (const t of teams) {
|
|
if (t.name.startsWith(prefix)) {
|
|
await fetch(`${API}/api/v1/teams/${t.id}`, {
|
|
method: "DELETE",
|
|
headers: authOnly(adminToken),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Delete custom roles by prefix. */
|
|
async function cleanupRolesByPrefix(adminToken: string, prefix: string): Promise<void> {
|
|
const listRes = await fetch(`${API}/api/v1/roles`, {
|
|
headers: authOnly(adminToken),
|
|
});
|
|
if (!listRes.ok) return;
|
|
const { roles } = await listRes.json();
|
|
for (const r of roles) {
|
|
if (r.name.startsWith(prefix)) {
|
|
await fetch(`${API}/api/v1/roles/${r.id}`, {
|
|
method: "DELETE",
|
|
headers: authOnly(adminToken),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a user with a given role and clear mustChangePassword
|
|
* so the browser login redirects to "/" instead of "/change-password".
|
|
*/
|
|
async function createReadyUser(
|
|
adminToken: string,
|
|
username: string,
|
|
password: string,
|
|
role: string,
|
|
): Promise<void> {
|
|
const createRes = await fetch(`${API}/api/auth/register`, {
|
|
method: "POST",
|
|
headers: authJson(adminToken),
|
|
body: JSON.stringify({ username, password, role }),
|
|
});
|
|
if (createRes.status !== 201 && createRes.status !== 409) {
|
|
throw new Error(`Failed to create user ${username}: ${createRes.status}`);
|
|
}
|
|
|
|
// Login to get token, then change password to clear mustChangePassword
|
|
const loginRes = await fetch(`${API}/api/auth/login`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ username, password }),
|
|
});
|
|
if (!loginRes.ok) throw new Error(`Login failed for ${username}: ${loginRes.status}`);
|
|
const loginData = await loginRes.json();
|
|
|
|
await fetch(`${API}/api/auth/change-password`, {
|
|
method: "POST",
|
|
headers: authJson(loginData.token),
|
|
body: JSON.stringify({ currentPassword: password, newPassword: password }),
|
|
});
|
|
}
|
|
|
|
/** Delete a user by username if it exists. */
|
|
async function deleteUser(adminToken: string, username: string): Promise<void> {
|
|
const listRes = await fetch(`${API}/api/auth/users`, {
|
|
headers: authOnly(adminToken),
|
|
});
|
|
if (!listRes.ok) return;
|
|
const { users } = await listRes.json();
|
|
const found = users.find((u: { username: string }) => u.username === username);
|
|
if (found) {
|
|
await fetch(`${API}/api/auth/users/${found.id}`, {
|
|
method: "DELETE",
|
|
headers: authOnly(adminToken),
|
|
});
|
|
}
|
|
}
|
|
|
|
const UID = Date.now().toString(36);
|
|
|
|
// ===========================================================================
|
|
// SETTINGS DIALOG -- Dialog Interactions (expanded)
|
|
// ===========================================================================
|
|
|
|
test.describe("Settings Dialog - Dialog state management", () => {
|
|
test("dialog content scrolls independently of the page", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
|
|
// The dialog's content pane has overflow-y-auto. Scope to the dialog: the
|
|
// page's <main> also carries .flex-1.overflow-y-auto.
|
|
const contentPane = page.getByRole("dialog").locator(".flex-1.overflow-y-auto");
|
|
await expect(contentPane.first()).toBeVisible();
|
|
|
|
// Navigate to a section with lots of content (Tools)
|
|
await page.getByRole("button", { name: /tools/i }).click();
|
|
await expect(page.locator("h3").filter({ hasText: "Tools" }).first()).toBeVisible();
|
|
});
|
|
|
|
test("rapidly switching tabs does not cause rendering errors", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
|
|
// Quickly click through several tabs
|
|
const tabs = ["Security", "About", "General", "Tools", "API Keys", "About"];
|
|
for (const tab of tabs) {
|
|
await page.getByRole("button", { name: new RegExp(tab, "i") }).click();
|
|
}
|
|
|
|
// Final tab should render correctly
|
|
await expect(page.locator("h3").filter({ hasText: "About" })).toBeVisible();
|
|
});
|
|
|
|
test("settings dialog renders with role=dialog and aria-modal", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await openSettings(page);
|
|
|
|
const dialog = page.getByRole("dialog");
|
|
await expect(dialog).toBeVisible();
|
|
await expect(dialog).toHaveAttribute("aria-modal", "true");
|
|
});
|
|
|
|
test("backdrop click does not interfere with dialog controls", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
|
|
// Click a button inside the dialog -- should not close it
|
|
await page.getByRole("button", { name: /about/i }).click();
|
|
await expect(page.locator("h3").filter({ hasText: "About" })).toBeVisible();
|
|
|
|
// Dialog should still be open
|
|
await expect(page.locator("h2").filter({ hasText: "Settings" })).toBeVisible();
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// GENERAL TAB -- expanded tests
|
|
// ===========================================================================
|
|
|
|
test.describe("Settings General Tab - User info details", () => {
|
|
test("displays role in capitalized format below username", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
|
|
// The role text is displayed with capitalize class
|
|
const roleEl = page.locator("p.capitalize").first();
|
|
await expect(roleEl).toBeVisible();
|
|
const roleText = await roleEl.textContent();
|
|
expect(roleText?.toLowerCase()).toContain("admin");
|
|
});
|
|
|
|
test("General tab has user preferences description", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
|
|
// Description text should be present
|
|
await expect(page.getByText("User preferences and display settings.")).toBeVisible();
|
|
});
|
|
|
|
test("Save Settings button is disabled while saving", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
|
|
const saveBtn = page.getByRole("button", { name: /save settings/i });
|
|
await expect(saveBtn).toBeVisible();
|
|
await expect(saveBtn).toBeEnabled();
|
|
|
|
// Click save and check the button shows loading state
|
|
await saveBtn.click();
|
|
|
|
// Eventually the save completes and message appears
|
|
await expect(page.getByText("Settings saved.")).toBeVisible({ timeout: 5_000 });
|
|
});
|
|
|
|
test("General tab shows Language (locale) dropdown", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
|
|
// Exact match: "Language" also appears inside the row description
|
|
// ("Language for the interface"), which would trip strict mode.
|
|
await expect(page.getByText("Language", { exact: true })).toBeVisible();
|
|
// The locale select has the user's current locale as its value
|
|
const localeSelect = page.locator("select").filter({ has: page.locator("option[value='en']") });
|
|
await expect(localeSelect).toBeVisible();
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// SYSTEM SETTINGS TAB -- expanded validation
|
|
// ===========================================================================
|
|
|
|
test.describe("Settings System Settings Tab - Additional coverage", () => {
|
|
test("shows description 'Server-side configuration and limits.'", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /system settings/i }).click();
|
|
|
|
await expect(page.getByText("Server-side configuration and limits.")).toBeVisible();
|
|
});
|
|
|
|
test("File Upload Limit has min=1 attribute", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /system settings/i }).click();
|
|
await expect(page.getByText("File Upload Limit (MB)")).toBeVisible();
|
|
|
|
const uploadInput = page.locator("input[type='number']").first();
|
|
await expect(uploadInput).toHaveAttribute("min", "1");
|
|
});
|
|
|
|
test("Login Attempt Limit has min=1 and max=100 attributes", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /system settings/i }).click();
|
|
await expect(page.getByText("Login Attempt Limit")).toBeVisible();
|
|
|
|
const loginInput = page.locator("input[type='number']").nth(1);
|
|
await expect(loginInput).toHaveAttribute("min", "1");
|
|
await expect(loginInput).toHaveAttribute("max", "100");
|
|
});
|
|
|
|
test("System theme dropdown has light, dark, and system options", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /system settings/i }).click();
|
|
|
|
const themeSelect = page
|
|
.locator("select")
|
|
.filter({ has: page.locator("option[value='dark']") });
|
|
|
|
// The System section loads its settings asynchronously; wait for the select
|
|
// to render before reading its options.
|
|
await expect(themeSelect).toBeVisible({ timeout: 10_000 });
|
|
const options = await themeSelect.locator("option").allTextContents();
|
|
expect(options).toContain("Light");
|
|
expect(options).toContain("Dark");
|
|
expect(options).toContain("System");
|
|
});
|
|
|
|
test("Language dropdown contains English option", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /system settings/i }).click();
|
|
|
|
const langSelect = page.locator("select").filter({ has: page.locator("option[value='en']") });
|
|
await expect(langSelect).toBeVisible();
|
|
|
|
const enOption = langSelect.locator("option[value='en']");
|
|
await expect(enOption).toBeAttached();
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// SECURITY TAB -- expanded password validation flows
|
|
// ===========================================================================
|
|
|
|
test.describe("Settings Security Tab - Extended password flows", () => {
|
|
test("empty form submission is prevented by browser required validation", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /security/i }).click();
|
|
|
|
// All password inputs should have the required attribute
|
|
const currentPw = page.getByPlaceholder("Current Password");
|
|
const newPw = page.getByPlaceholder("New Password").first();
|
|
const confirmPw = page.getByPlaceholder("Confirm New Password");
|
|
|
|
await expect(currentPw).toHaveAttribute("required", "");
|
|
await expect(newPw).toHaveAttribute("required", "");
|
|
await expect(confirmPw).toHaveAttribute("required", "");
|
|
});
|
|
|
|
test("multiple password visibility toggles work independently", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /security/i }).click();
|
|
|
|
const currentPwInput = page.getByPlaceholder("Current Password");
|
|
const newPwInput = page.getByPlaceholder("New Password").first();
|
|
|
|
// Both start as password type
|
|
await expect(currentPwInput).toHaveAttribute("type", "password");
|
|
await expect(newPwInput).toHaveAttribute("type", "password");
|
|
|
|
// Toggle the first one
|
|
const toggleButtons = page.locator("form button[type='button']");
|
|
await toggleButtons.first().click();
|
|
|
|
// Only the first should change
|
|
await expect(currentPwInput).toHaveAttribute("type", "text");
|
|
await expect(newPwInput).toHaveAttribute("type", "password");
|
|
});
|
|
|
|
test("security section shows Change Password sub-heading", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /security/i }).click();
|
|
|
|
// The form has a sub-heading for the change password section
|
|
await expect(page.getByText("Change Password").first()).toBeVisible();
|
|
});
|
|
|
|
test("changing a password to the same value succeeds", async ({ loggedInPage: page }) => {
|
|
// Exercised on a throwaway user: the default admin password ("admin", 5
|
|
// chars) cannot satisfy the 8-char policy, so a same-value change can never
|
|
// be submitted for it.
|
|
const uid = Date.now().toString(36);
|
|
const username = `samepw-${uid}`;
|
|
const password = "Samepass123";
|
|
const adminToken = await page.evaluate(() => localStorage.getItem("snapotter-token"));
|
|
|
|
const reg = await page.request.post("/api/auth/register", {
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
data: { username, password, role: "user" },
|
|
});
|
|
expect(reg.ok()).toBeTruthy();
|
|
|
|
try {
|
|
const userLogin = await page.request.post("/api/auth/login", {
|
|
data: { username, password },
|
|
});
|
|
const { token } = await userLogin.json();
|
|
const change = await page.request.post("/api/auth/change-password", {
|
|
headers: { authorization: `Bearer ${token}` },
|
|
data: { currentPassword: password, newPassword: password },
|
|
});
|
|
expect(change.ok()).toBeTruthy();
|
|
} finally {
|
|
const list = await page.request.get("/api/auth/users", {
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
if (list.ok()) {
|
|
const { users } = await list.json();
|
|
const u = users.find((x: { username: string; id: string }) => x.username === username);
|
|
if (u) {
|
|
await page.request.delete(`/api/auth/users/${u.id}`, {
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// ABOUT TAB -- expanded tests
|
|
// ===========================================================================
|
|
|
|
test.describe("Settings About Tab - Extended", () => {
|
|
test("shows AGPLv3 license information", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /about/i }).click();
|
|
|
|
// Exact match: the license description paragraph also contains "AGPLv3",
|
|
// so a substring match would resolve to two elements (strict mode).
|
|
await expect(page.getByText("AGPLv3", { exact: true })).toBeVisible();
|
|
});
|
|
|
|
test("shows SnapOtter logo element", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /about/i }).click();
|
|
|
|
// The OtterLogo component renders an <img> (logo.png), not an inline SVG.
|
|
const logo = page.locator("img.text-primary").first();
|
|
await expect(logo).toBeVisible();
|
|
});
|
|
|
|
test("GitHub link points to snapotter-hq repository", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /about/i }).click();
|
|
|
|
const githubLink = page.getByRole("link", { name: /github repository/i });
|
|
await expect(githubLink).toHaveAttribute("href", "https://github.com/snapotter-hq/snapotter");
|
|
});
|
|
|
|
test("documentation link points to docs.snapotter.com", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /about/i }).click();
|
|
|
|
const docsLink = page.getByRole("link", { name: /documentation/i });
|
|
await expect(docsLink).toHaveAttribute("href", "https://docs.snapotter.com/");
|
|
});
|
|
|
|
test("API Reference link points to /api/docs", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /about/i }).click();
|
|
|
|
const apiLink = page.getByRole("link", { name: /api reference/i });
|
|
await expect(apiLink).toHaveAttribute("href", "/api/docs");
|
|
});
|
|
|
|
test("external links open in new tab", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /about/i }).click();
|
|
|
|
const githubLink = page.getByRole("link", { name: /github repository/i });
|
|
await expect(githubLink).toHaveAttribute("target", "_blank");
|
|
await expect(githubLink).toHaveAttribute("rel", /noopener/);
|
|
});
|
|
|
|
test("shows License label and description", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /about/i }).click();
|
|
|
|
await expect(page.getByText("License:").first()).toBeVisible();
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// PEOPLE TAB -- team assignment during user creation
|
|
// ===========================================================================
|
|
|
|
test.describe("Settings People Tab - Team assignment", () => {
|
|
test("team dropdown in add form contains Default team", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /people/i }).click();
|
|
await page.waitForTimeout(500);
|
|
|
|
await page.getByRole("button", { name: /add members/i }).click();
|
|
await expect(page.getByPlaceholder("Username")).toBeVisible();
|
|
|
|
// The team dropdown is the second select in the form
|
|
const teamSelect = page.locator("form select").nth(1);
|
|
await expect(teamSelect).toBeVisible();
|
|
|
|
// Default team should be an option
|
|
await expect(teamSelect.locator("option")).toContainText(["Default"]);
|
|
|
|
// Cancel the form
|
|
await page.getByRole("button", { name: /cancel/i }).click();
|
|
});
|
|
|
|
test("Generate Password button populates the password field", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /people/i }).click();
|
|
await page.waitForTimeout(500);
|
|
|
|
await page.getByRole("button", { name: /add members/i }).click();
|
|
await expect(page.getByPlaceholder("Username")).toBeVisible();
|
|
|
|
// Click the Generate Password button
|
|
const generateBtn = page.getByRole("button", { name: /generate/i });
|
|
await expect(generateBtn).toBeVisible();
|
|
await generateBtn.click();
|
|
|
|
// The password field should now have a value (16 chars generated password).
|
|
// After generating, the password input flips to type="text", so a generic
|
|
// form input[type='text'] selector would also match the (empty) username
|
|
// field. Target the password input by its id instead.
|
|
const pwInput = page.locator("#new-user-password");
|
|
const value = await pwInput.inputValue();
|
|
expect(value.length).toBeGreaterThanOrEqual(8);
|
|
|
|
// Cancel the form
|
|
await page.getByRole("button", { name: /cancel/i }).click();
|
|
});
|
|
|
|
test("creating user with specific team assigns them correctly", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
const teamName = `exteam-${UID}`;
|
|
const username = `extuser-${UID}`;
|
|
const adminToken = await getAdminToken();
|
|
|
|
try {
|
|
// Create a team via API first
|
|
await fetch(`${API}/api/v1/teams`, {
|
|
method: "POST",
|
|
headers: authJson(adminToken),
|
|
body: JSON.stringify({ name: teamName }),
|
|
});
|
|
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /people/i }).click();
|
|
await page.waitForTimeout(500);
|
|
|
|
await page.getByRole("button", { name: /add members/i }).click();
|
|
await page.getByPlaceholder("Username").fill(username);
|
|
await page.getByPlaceholder("Password").fill("TestPass123!");
|
|
|
|
// Select the created team
|
|
const teamSelect = page.locator("form select").nth(1);
|
|
await teamSelect.selectOption(teamName);
|
|
|
|
await page.getByRole("button", { name: /create/i }).click();
|
|
|
|
// User should appear in the table with the team name
|
|
await expect(page.getByText(username)).toBeVisible({ timeout: 5_000 });
|
|
await expect(page.getByText(teamName).first()).toBeVisible();
|
|
} finally {
|
|
await cleanupUsersByPrefix(adminToken, "extuser-");
|
|
await cleanupTeamsByPrefix(adminToken, "exteam-");
|
|
}
|
|
});
|
|
|
|
test("Add Members form shows 'New Member' heading", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /people/i }).click();
|
|
await page.waitForTimeout(500);
|
|
|
|
await page.getByRole("button", { name: /add members/i }).click();
|
|
|
|
await expect(page.getByText("New Member")).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: /cancel/i }).click();
|
|
});
|
|
|
|
test("canceling add form resets it", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /people/i }).click();
|
|
await page.waitForTimeout(500);
|
|
|
|
await page.getByRole("button", { name: /add members/i }).click();
|
|
await page.getByPlaceholder("Username").fill("shouldnotexist");
|
|
|
|
// Cancel
|
|
await page.getByRole("button", { name: /cancel/i }).click();
|
|
|
|
// Form should be hidden
|
|
await expect(page.getByPlaceholder("Username")).not.toBeVisible();
|
|
|
|
// Re-open -- the username field should be empty
|
|
await page.getByRole("button", { name: /add members/i }).click();
|
|
const usernameValue = await page.getByPlaceholder("Username").inputValue();
|
|
expect(usernameValue).toBe("");
|
|
|
|
await page.getByRole("button", { name: /cancel/i }).click();
|
|
});
|
|
|
|
test("user table row has avatar with first letter initial", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /people/i }).click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// The admin row should have an avatar with "A" (first letter of admin)
|
|
const avatar = page.locator(".w-8.h-8.rounded-full").first();
|
|
await expect(avatar).toBeVisible();
|
|
await expect(avatar).toContainText("A");
|
|
});
|
|
|
|
test("people tab heading says 'People'", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /people/i }).click();
|
|
|
|
await expect(page.locator("h3").filter({ hasText: "People" })).toBeVisible();
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// TEAMS TAB -- expanded team management tests
|
|
// ===========================================================================
|
|
|
|
test.describe("Settings Teams Tab - Extended", () => {
|
|
test("teams tab heading and description", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /teams/i }).click();
|
|
|
|
await expect(page.locator("h3").filter({ hasText: "Teams" })).toBeVisible();
|
|
await expect(page.getByText(/manage.*team/i).first()).toBeVisible();
|
|
});
|
|
|
|
test("Create New Team button toggles the form", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /teams/i }).click();
|
|
|
|
// Click to open form
|
|
await page.getByRole("button", { name: /create new team/i }).click();
|
|
await expect(page.getByPlaceholder("Team name")).toBeVisible();
|
|
|
|
// Click again to close form
|
|
await page.getByRole("button", { name: /create new team/i }).click();
|
|
await expect(page.getByPlaceholder("Team name")).not.toBeVisible();
|
|
});
|
|
|
|
test("team three-dot menu shows Rename and Delete options", async ({ loggedInPage: page }) => {
|
|
const teamName = `menuteam-${UID}`;
|
|
const adminToken = await getAdminToken();
|
|
try {
|
|
// A fresh instance has no teams, so create one to act on.
|
|
await fetch(`${API}/api/v1/teams`, {
|
|
method: "POST",
|
|
headers: authJson(adminToken),
|
|
body: JSON.stringify({ name: teamName }),
|
|
});
|
|
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /teams/i }).click();
|
|
await expect(page.getByText(teamName)).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Open the three-dot menu for the team
|
|
const moreButtons = page.locator("button:has(svg.lucide-ellipsis-vertical)");
|
|
await moreButtons.last().click();
|
|
|
|
await expect(page.locator("[role='menu']").getByText("Rename")).toBeVisible();
|
|
await expect(page.locator("[role='menu']").getByText("Delete")).toBeVisible();
|
|
} finally {
|
|
await cleanupTeamsByPrefix(adminToken, "menuteam-");
|
|
}
|
|
});
|
|
|
|
test("team rename via Enter key works", async ({ loggedInPage: page }) => {
|
|
const teamName = `enterteam-${UID}`;
|
|
const renamedName = `enterrenamed-${UID}`;
|
|
const adminToken = await getAdminToken();
|
|
|
|
try {
|
|
// Create a team via API
|
|
await fetch(`${API}/api/v1/teams`, {
|
|
method: "POST",
|
|
headers: authJson(adminToken),
|
|
body: JSON.stringify({ name: teamName }),
|
|
});
|
|
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /teams/i }).click();
|
|
await page.waitForTimeout(500);
|
|
|
|
await expect(page.getByText(teamName)).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Open menu and rename
|
|
const moreButtons = page.locator("button:has(svg.lucide-ellipsis-vertical)");
|
|
await moreButtons.last().click();
|
|
await page.locator("[role='menu']").getByText("Rename").click();
|
|
|
|
// Inline edit input should appear
|
|
const renameInput = page.locator(
|
|
"input.px-2.py-1.rounded.border.border-border.bg-background",
|
|
);
|
|
await expect(renameInput).toBeVisible({ timeout: 3_000 });
|
|
|
|
await renameInput.fill(renamedName);
|
|
await renameInput.press("Enter");
|
|
|
|
// Updated name should appear
|
|
await expect(page.getByText(renamedName)).toBeVisible({ timeout: 5_000 });
|
|
} finally {
|
|
await cleanupTeamsByPrefix(adminToken, "enterteam-");
|
|
await cleanupTeamsByPrefix(adminToken, "enterrenamed-");
|
|
}
|
|
});
|
|
|
|
test("team rename cancel via Escape key works", async ({ loggedInPage: page }) => {
|
|
const teamName = `escteam-${UID}`;
|
|
const adminToken = await getAdminToken();
|
|
try {
|
|
await fetch(`${API}/api/v1/teams`, {
|
|
method: "POST",
|
|
headers: authJson(adminToken),
|
|
body: JSON.stringify({ name: teamName }),
|
|
});
|
|
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /teams/i }).click();
|
|
await page.waitForTimeout(500);
|
|
await expect(page.getByText(teamName)).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Open the team menu and click Rename
|
|
const moreButtons = page.locator("button:has(svg.lucide-ellipsis-vertical)");
|
|
await moreButtons.last().click();
|
|
await page.locator("[role='menu']").getByText("Rename").click();
|
|
|
|
// Inline edit input should appear
|
|
const renameInput = page.locator(
|
|
"input.px-2.py-1.rounded.border.border-border.bg-background",
|
|
);
|
|
await expect(renameInput).toBeVisible({ timeout: 3_000 });
|
|
|
|
// Press Escape to cancel
|
|
await renameInput.press("Escape");
|
|
|
|
// Input should disappear and the team name should be unchanged
|
|
await expect(renameInput).not.toBeVisible();
|
|
await expect(page.getByText(teamName)).toBeVisible();
|
|
} finally {
|
|
await cleanupTeamsByPrefix(adminToken, "escteam-");
|
|
}
|
|
});
|
|
|
|
test("creating multiple teams shows correct member counts", async ({ loggedInPage: page }) => {
|
|
const teamName1 = `multi1-${UID}`;
|
|
const teamName2 = `multi2-${UID}`;
|
|
const adminToken = await getAdminToken();
|
|
|
|
try {
|
|
await fetch(`${API}/api/v1/teams`, {
|
|
method: "POST",
|
|
headers: authJson(adminToken),
|
|
body: JSON.stringify({ name: teamName1 }),
|
|
});
|
|
await fetch(`${API}/api/v1/teams`, {
|
|
method: "POST",
|
|
headers: authJson(adminToken),
|
|
body: JSON.stringify({ name: teamName2 }),
|
|
});
|
|
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /teams/i }).click();
|
|
await page.waitForTimeout(500);
|
|
|
|
await expect(page.getByText(teamName1)).toBeVisible({ timeout: 5_000 });
|
|
await expect(page.getByText(teamName2)).toBeVisible({ timeout: 5_000 });
|
|
} finally {
|
|
await cleanupTeamsByPrefix(adminToken, "multi1-");
|
|
await cleanupTeamsByPrefix(adminToken, "multi2-");
|
|
}
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// ROLES TAB -- expanded custom role tests
|
|
// ===========================================================================
|
|
|
|
test.describe("Settings Roles Tab - Extended custom role management", () => {
|
|
test("creating a role without a name is prevented", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /^roles$/i }).click();
|
|
|
|
await page.getByRole("button", { name: /create custom role/i }).click();
|
|
|
|
// Leave name empty and try to submit
|
|
const nameInput = page.getByPlaceholder("Role name");
|
|
await expect(nameInput).toHaveAttribute("required", "");
|
|
|
|
// Cancel
|
|
await page.getByRole("button", { name: /cancel/i }).click();
|
|
});
|
|
|
|
test("creating a custom role with duplicate name shows error", async ({ loggedInPage: page }) => {
|
|
const roleName = `exdup${UID}`;
|
|
const adminToken = await getAdminToken();
|
|
|
|
try {
|
|
// Create role via API first
|
|
await fetch(`${API}/api/v1/roles`, {
|
|
method: "POST",
|
|
headers: authJson(adminToken),
|
|
body: JSON.stringify({
|
|
name: roleName,
|
|
description: "Dup test",
|
|
permissions: ["tools:use"],
|
|
}),
|
|
});
|
|
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /^roles$/i }).click();
|
|
await expect(page.getByText(roleName)).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Try to create same role via GUI
|
|
await page.getByRole("button", { name: /create custom role/i }).click();
|
|
await page.getByPlaceholder("Role name").fill(roleName);
|
|
await page.getByRole("button", { name: /^create$/i }).click();
|
|
|
|
// Should show duplicate error
|
|
await expect(page.getByText(/already exists|duplicate/i).first()).toBeVisible({
|
|
timeout: 5_000,
|
|
});
|
|
} finally {
|
|
await cleanupRolesByPrefix(adminToken, roleName);
|
|
}
|
|
});
|
|
|
|
test("admin built-in role shows correct permissions", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /^roles$/i }).click();
|
|
|
|
// Admin role should have all permissions displayed
|
|
// Look for key permissions that admin role must have
|
|
await expect(
|
|
page.locator(".font-mono").filter({ hasText: "users:manage" }).first(),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.locator(".font-mono").filter({ hasText: "settings:write" }).first(),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test("Cancel button on edit role form closes it", async ({ loggedInPage: page }) => {
|
|
const roleName = `excancel${UID}`;
|
|
const adminToken = await getAdminToken();
|
|
|
|
try {
|
|
await fetch(`${API}/api/v1/roles`, {
|
|
method: "POST",
|
|
headers: authJson(adminToken),
|
|
body: JSON.stringify({
|
|
name: roleName,
|
|
description: "Cancel test",
|
|
permissions: ["tools:use"],
|
|
}),
|
|
});
|
|
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /^roles$/i }).click();
|
|
await expect(page.getByText(roleName)).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Open edit
|
|
await page.locator("button[title='Edit role']").first().click();
|
|
await expect(page.getByText(/edit role/i)).toBeVisible();
|
|
|
|
// Cancel
|
|
await page.getByRole("button", { name: /cancel/i }).click();
|
|
|
|
// Edit form should be hidden
|
|
await expect(page.getByText(/edit role/i)).not.toBeVisible();
|
|
} finally {
|
|
await cleanupRolesByPrefix(adminToken, roleName);
|
|
}
|
|
});
|
|
|
|
test("editing a custom role description updates it", async ({ loggedInPage: page }) => {
|
|
const roleName = `exdesc${UID}`;
|
|
const adminToken = await getAdminToken();
|
|
|
|
try {
|
|
await fetch(`${API}/api/v1/roles`, {
|
|
method: "POST",
|
|
headers: authJson(adminToken),
|
|
body: JSON.stringify({
|
|
name: roleName,
|
|
description: "Original desc",
|
|
permissions: ["tools:use"],
|
|
}),
|
|
});
|
|
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /^roles$/i }).click();
|
|
await expect(page.getByText(roleName)).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Edit the role
|
|
await page.locator("button[title='Edit role']").first().click();
|
|
await expect(page.getByText(/edit role/i)).toBeVisible();
|
|
|
|
// Change description
|
|
const descInput = page.getByPlaceholder("Description (optional)");
|
|
await descInput.fill("Updated desc");
|
|
|
|
// Save
|
|
await page.getByRole("button", { name: /^save$/i }).click();
|
|
await expect(page.getByText("Role updated")).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Updated description should appear on the card
|
|
await expect(page.getByText("Updated desc")).toBeVisible();
|
|
} finally {
|
|
await cleanupRolesByPrefix(adminToken, roleName);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// AUDIT LOG TAB -- expanded tests
|
|
// ===========================================================================
|
|
|
|
test.describe("Settings Audit Log Tab - Extended", () => {
|
|
test("audit log pagination controls appear when entries exceed page limit", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /audit log/i }).click();
|
|
|
|
// Wait for table to load
|
|
await expect(page.locator("h3").filter({ hasText: "Audit Log" })).toBeVisible();
|
|
|
|
// Pagination controls may or may not appear depending on entry count
|
|
// Just verify the component loads without error
|
|
await page.waitForTimeout(1_000);
|
|
});
|
|
|
|
test("filter dropdown includes USER_UPDATED and PASSWORD_RESET actions", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /audit log/i }).click();
|
|
|
|
const filterSelect = page.locator("select").first();
|
|
await expect(filterSelect).toBeVisible();
|
|
|
|
for (const action of ["USER_UPDATED", "PASSWORD_RESET", "API_KEY_DELETED", "ROLE_CREATED"]) {
|
|
await expect(filterSelect.locator(`option[value='${action}']`)).toBeAttached();
|
|
}
|
|
});
|
|
|
|
test("USER_CREATED entry appears after creating a user", async ({ loggedInPage: page }) => {
|
|
const username = `auditcreate-${UID}`;
|
|
const adminToken = await getAdminToken();
|
|
|
|
try {
|
|
// Create a user via API to generate a USER_CREATED audit entry
|
|
await fetch(`${API}/api/auth/register`, {
|
|
method: "POST",
|
|
headers: authJson(adminToken),
|
|
body: JSON.stringify({ username, password: "AuditPass123!", role: "user" }),
|
|
});
|
|
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /audit log/i }).click();
|
|
await expect(page.locator("table thead")).toBeVisible({ timeout: 10_000 });
|
|
|
|
const filterSelect = page.locator("select").first();
|
|
await filterSelect.selectOption("USER_CREATED");
|
|
|
|
await expect(page.locator("table tbody tr").first()).toBeVisible({ timeout: 10_000 });
|
|
const tableText = await page.locator("table tbody").textContent();
|
|
expect(tableText).toContain("USER_CREATED");
|
|
} finally {
|
|
await cleanupUsersByPrefix(adminToken, "auditcreate-");
|
|
}
|
|
});
|
|
|
|
test("API_KEY_CREATED entry appears after generating an API key", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await openSettings(page);
|
|
|
|
// Generate an API key to trigger the audit entry
|
|
await page.getByRole("button", { name: /api keys/i }).click();
|
|
const keyName = `auditkey-${Date.now()}`;
|
|
await page.getByPlaceholder("Key name (optional)").fill(keyName);
|
|
await page.getByRole("button", { name: /generate api key/i }).click();
|
|
await expect(page.locator("code.font-mono")).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Clean up the key
|
|
page.on("dialog", (d) => d.accept());
|
|
await page.locator("button[title='Delete key']").first().click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Check audit log for API_KEY_CREATED
|
|
await page.getByRole("button", { name: /audit log/i }).click();
|
|
await expect(page.locator("table thead")).toBeVisible({ timeout: 10_000 });
|
|
|
|
const filterSelect = page.locator("select").first();
|
|
await filterSelect.selectOption("API_KEY_CREATED");
|
|
|
|
await expect(page.locator("table tbody tr").first()).toBeVisible({ timeout: 10_000 });
|
|
const tableText = await page.locator("table tbody").textContent();
|
|
expect(tableText).toContain("API_KEY_CREATED");
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// API KEYS TAB -- expanded tests
|
|
// ===========================================================================
|
|
|
|
test.describe("Settings API Keys Tab - Extended", () => {
|
|
test("API keys tab shows heading and description", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /api keys/i }).click();
|
|
|
|
await expect(page.locator("h3").filter({ hasText: "API Keys" })).toBeVisible();
|
|
await expect(page.getByText("Manage API keys for programmatic access")).toBeVisible();
|
|
});
|
|
|
|
test("generating key with expiration date shows expiration in the list", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /api keys/i }).click();
|
|
|
|
const keyName = `expTest-${Date.now()}`;
|
|
await page.getByPlaceholder("Key name (optional)").fill(keyName);
|
|
|
|
// Set an expiration date
|
|
const dateInput = page.locator("input[type='datetime-local']");
|
|
await dateInput.fill("2030-12-31T23:59");
|
|
|
|
// Generate
|
|
await page.getByRole("button", { name: /generate api key/i }).click();
|
|
await expect(page.locator("code.font-mono")).toBeVisible({ timeout: 5_000 });
|
|
|
|
// The key entry should show an expiration indicator
|
|
await expect(page.getByText(/Expires/).first()).toBeVisible();
|
|
|
|
// Clean up
|
|
page.on("dialog", (d) => d.accept());
|
|
await page.locator("button[title='Delete key']").first().click();
|
|
await page.waitForTimeout(500);
|
|
});
|
|
|
|
test("multiple API keys can be created and listed", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /api keys/i }).click();
|
|
|
|
const key1Name = `multi1-${Date.now()}`;
|
|
const key2Name = `multi2-${Date.now()}`;
|
|
|
|
// Create first key
|
|
await page.getByPlaceholder("Key name (optional)").fill(key1Name);
|
|
await page.getByRole("button", { name: /generate api key/i }).click();
|
|
await expect(page.locator("code.font-mono")).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Create second key
|
|
await page.getByPlaceholder("Key name (optional)").fill(key2Name);
|
|
await page.getByRole("button", { name: /generate api key/i }).click();
|
|
await page.waitForTimeout(1_000);
|
|
|
|
// Both should appear in the Existing Keys list
|
|
await expect(page.getByText(key1Name)).toBeVisible();
|
|
await expect(page.getByText(key2Name)).toBeVisible();
|
|
|
|
// Clean up both
|
|
page.on("dialog", (d) => d.accept());
|
|
const deleteButtons = page.locator("button[title='Delete key']");
|
|
const count = await deleteButtons.count();
|
|
for (let i = count - 1; i >= 0; i--) {
|
|
const btn = deleteButtons.nth(i);
|
|
const isVis = await btn.isVisible().catch(() => false);
|
|
if (isVis) {
|
|
await btn.click();
|
|
await page.waitForTimeout(500);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("scoped permission checkbox labels include permission names", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /api keys/i }).click();
|
|
|
|
await page.getByText("Restrict permissions (optional)").click();
|
|
await expect(page.getByText("Remove permission scoping")).toBeVisible();
|
|
|
|
// Key permissions should be listed as checkbox labels
|
|
for (const perm of ["tools:use", "files:own"]) {
|
|
await expect(page.getByText(perm).first()).toBeVisible();
|
|
}
|
|
|
|
// Collapse
|
|
await page.getByText("Remove permission scoping").click();
|
|
await expect(page.getByText("Restrict permissions (optional)")).toBeVisible();
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// AI FEATURES TAB -- expanded tests
|
|
// ===========================================================================
|
|
|
|
test.describe("Settings AI Features Tab - Extended", () => {
|
|
test("AI Features tab shows correct heading", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /ai features/i }).click();
|
|
|
|
await expect(page.locator("h3").filter({ hasText: "AI Features" })).toBeVisible();
|
|
});
|
|
|
|
test("AI Features tab shows Install All button", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /ai features/i }).click();
|
|
|
|
await expect(page.getByRole("button", { name: /install all/i })).toBeVisible();
|
|
});
|
|
|
|
test("each AI bundle card shows a name", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /ai features/i }).click();
|
|
|
|
// Wait for bundles to load
|
|
const bundleCards = page.locator(".rounded-lg.border.border-border.p-4");
|
|
await expect(bundleCards.first()).toBeVisible({ timeout: 5_000 });
|
|
|
|
// First bundle card should have text content (bundle name)
|
|
const firstCardText = await bundleCards.first().textContent();
|
|
expect(firstCardText?.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// TOOLS TAB -- expanded tests
|
|
// ===========================================================================
|
|
|
|
test.describe("Settings Tools Tab - Extended", () => {
|
|
test("tools section has a search input", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /tools/i }).click();
|
|
await expect(page.getByText(/\d+ tools? disabled/)).toBeVisible({ timeout: 5_000 });
|
|
|
|
const dialogContent = page.locator(".flex-1.overflow-y-auto");
|
|
const searchInput = dialogContent.getByPlaceholder("Search tools...");
|
|
await expect(searchInput).toBeVisible();
|
|
});
|
|
|
|
test("search for a specific tool shows only matching results", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /tools/i }).click();
|
|
await expect(page.getByText(/\d+ tools? disabled/)).toBeVisible({ timeout: 5_000 });
|
|
|
|
const dialogContent = page.locator(".flex-1.overflow-y-auto");
|
|
const searchInput = dialogContent.getByPlaceholder("Search tools...");
|
|
|
|
// Search for "Compress"
|
|
await searchInput.fill("Compress");
|
|
|
|
// Compress tool should be visible
|
|
await expect(dialogContent.getByText("Compress").first()).toBeVisible();
|
|
|
|
// Clear search
|
|
await searchInput.fill("");
|
|
});
|
|
|
|
test("saving tool settings shows restart banner", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /tools/i }).click();
|
|
await expect(page.getByText(/\d+ tools? disabled/)).toBeVisible({ timeout: 5_000 });
|
|
|
|
await page.getByRole("button", { name: /save tool settings/i }).click();
|
|
|
|
await expect(page.getByText("Restart required for changes to take effect.")).toBeVisible({
|
|
timeout: 5_000,
|
|
});
|
|
});
|
|
|
|
test("tool descriptions are visible in the tool list", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /tools/i }).click();
|
|
await expect(page.getByText(/\d+ tools? disabled/)).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Each tool row has a description in text-xs text-muted-foreground
|
|
const descriptions = page.locator(".flex-1.overflow-y-auto .text-xs.text-muted-foreground");
|
|
const count = await descriptions.count();
|
|
expect(count).toBeGreaterThan(5);
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// RBAC VERIFICATION -- expanded cross-role API endpoint checks
|
|
// ===========================================================================
|
|
|
|
base.describe("RBAC API - Editor expanded endpoint checks", () => {
|
|
const EDITOR_EXP = `rbaceditorexp-${UID}`;
|
|
const EDITOR_EXP_PASS = "EditorExpPass1";
|
|
let adminToken: string;
|
|
|
|
base.beforeAll(async () => {
|
|
adminToken = await getAdminToken();
|
|
await createReadyUser(adminToken, EDITOR_EXP, EDITOR_EXP_PASS, "editor");
|
|
});
|
|
|
|
base.afterAll(async () => {
|
|
await deleteUser(adminToken, EDITOR_EXP);
|
|
});
|
|
|
|
base.test("editor gets 403 on DELETE user endpoint", async ({ page }) => {
|
|
await login(page, EDITOR_EXP, EDITOR_EXP_PASS);
|
|
|
|
const token = await page.evaluate(() => localStorage.getItem("snapotter-token"));
|
|
expect(token).toBeTruthy();
|
|
|
|
// Try to delete a user (even a fake ID)
|
|
const res = await fetch(`${API}/api/auth/users/99999`, {
|
|
method: "DELETE",
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
base.test("editor gets 403 on PUT settings endpoint", async ({ page }) => {
|
|
await login(page, EDITOR_EXP, EDITOR_EXP_PASS);
|
|
|
|
const token = await page.evaluate(() => localStorage.getItem("snapotter-token"));
|
|
expect(token).toBeTruthy();
|
|
|
|
const res = await fetch(`${API}/api/v1/settings`, {
|
|
method: "PUT",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ fileUploadLimitMb: "999" }),
|
|
});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
base.test("editor gets 403 on POST roles endpoint", async ({ page }) => {
|
|
await login(page, EDITOR_EXP, EDITOR_EXP_PASS);
|
|
|
|
const token = await page.evaluate(() => localStorage.getItem("snapotter-token"));
|
|
expect(token).toBeTruthy();
|
|
|
|
const res = await fetch(`${API}/api/v1/roles`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ name: "hacked-role", permissions: ["users:manage"] }),
|
|
});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
base.test("editor can access own session endpoint", async ({ page }) => {
|
|
await login(page, EDITOR_EXP, EDITOR_EXP_PASS);
|
|
|
|
const token = await page.evaluate(() => localStorage.getItem("snapotter-token"));
|
|
expect(token).toBeTruthy();
|
|
|
|
const res = await fetch(`${API}/api/auth/session`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
|
|
const data = await res.json();
|
|
expect(data.user.username).toBe(EDITOR_EXP);
|
|
expect(data.user.role).toBe("editor");
|
|
});
|
|
});
|
|
|
|
base.describe("RBAC API - User expanded endpoint checks", () => {
|
|
const USER_EXP = `rbacuserexp-${UID}`;
|
|
const USER_EXP_PASS = "UserExpPass1";
|
|
let adminToken: string;
|
|
|
|
base.beforeAll(async () => {
|
|
adminToken = await getAdminToken();
|
|
await createReadyUser(adminToken, USER_EXP, USER_EXP_PASS, "user");
|
|
});
|
|
|
|
base.afterAll(async () => {
|
|
await deleteUser(adminToken, USER_EXP);
|
|
});
|
|
|
|
base.test("user gets 403 on DELETE user endpoint", async ({ page }) => {
|
|
await login(page, USER_EXP, USER_EXP_PASS);
|
|
|
|
const token = await page.evaluate(() => localStorage.getItem("snapotter-token"));
|
|
expect(token).toBeTruthy();
|
|
|
|
const res = await fetch(`${API}/api/auth/users/99999`, {
|
|
method: "DELETE",
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
base.test("user gets 403 on POST teams endpoint", async ({ page }) => {
|
|
await login(page, USER_EXP, USER_EXP_PASS);
|
|
|
|
const token = await page.evaluate(() => localStorage.getItem("snapotter-token"));
|
|
expect(token).toBeTruthy();
|
|
|
|
const res = await fetch(`${API}/api/v1/teams`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ name: "hacked-team" }),
|
|
});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
base.test("user gets 403 on POST roles endpoint", async ({ page }) => {
|
|
await login(page, USER_EXP, USER_EXP_PASS);
|
|
|
|
const token = await page.evaluate(() => localStorage.getItem("snapotter-token"));
|
|
expect(token).toBeTruthy();
|
|
|
|
const res = await fetch(`${API}/api/v1/roles`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ name: "hacked-role", permissions: ["users:manage"] }),
|
|
});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
base.test("user gets 403 on PUT settings endpoint", async ({ page }) => {
|
|
await login(page, USER_EXP, USER_EXP_PASS);
|
|
|
|
const token = await page.evaluate(() => localStorage.getItem("snapotter-token"));
|
|
expect(token).toBeTruthy();
|
|
|
|
const res = await fetch(`${API}/api/v1/settings`, {
|
|
method: "PUT",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ fileUploadLimitMb: "999" }),
|
|
});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
base.test("user can change own password via API", async ({ page }) => {
|
|
await login(page, USER_EXP, USER_EXP_PASS);
|
|
|
|
const token = await page.evaluate(() => localStorage.getItem("snapotter-token"));
|
|
expect(token).toBeTruthy();
|
|
|
|
// Change password to same value
|
|
const res = await fetch(`${API}/api/auth/change-password`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
currentPassword: USER_EXP_PASS,
|
|
newPassword: USER_EXP_PASS,
|
|
}),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
base.test("user can generate own API key", async ({ page }) => {
|
|
await login(page, USER_EXP, USER_EXP_PASS);
|
|
|
|
const token = await page.evaluate(() => localStorage.getItem("snapotter-token"));
|
|
expect(token).toBeTruthy();
|
|
|
|
const res = await fetch(`${API}/api/v1/api-keys`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ name: "user-test-key" }),
|
|
});
|
|
expect(res.status).toBe(201);
|
|
|
|
// Clean up: list and delete the key
|
|
const listRes = await fetch(`${API}/api/v1/api-keys`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
const listData = await listRes.json();
|
|
for (const k of listData.apiKeys) {
|
|
if (k.name === "user-test-key") {
|
|
await fetch(`${API}/api/v1/api-keys/${k.id}`, {
|
|
method: "DELETE",
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// RBAC -- Admin GUI-level tests for admin-only tabs
|
|
// ===========================================================================
|
|
|
|
base.describe("RBAC GUI - Admin admin-only tab deep access", () => {
|
|
// The run-scoped auth file, not the pre-isolation .playwright path. A stale
|
|
// path leaves the block signed out, so every case here drove the login page.
|
|
base.use({ storageState: authFile });
|
|
|
|
base.test("admin can open Roles tab and see Create Custom Role button", async ({ page }) => {
|
|
await page.goto("/");
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /^roles$/i }).click();
|
|
|
|
await expect(page.locator("h3").filter({ hasText: "Roles" })).toBeVisible();
|
|
await expect(page.getByRole("button", { name: /create custom role/i })).toBeVisible();
|
|
});
|
|
|
|
base.test("admin can open Audit Log and filter by specific action", async ({ page }) => {
|
|
await page.goto("/");
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /audit log/i }).click();
|
|
|
|
await expect(page.locator("h3").filter({ hasText: "Audit Log" })).toBeVisible();
|
|
|
|
const filterSelect = page.locator("select").first();
|
|
await filterSelect.selectOption("LOGIN_SUCCESS");
|
|
|
|
// Wait for filtered results
|
|
await page.waitForTimeout(1_000);
|
|
});
|
|
|
|
base.test("admin can save system settings without error", async ({ page }) => {
|
|
await page.goto("/");
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /system settings/i }).click();
|
|
await expect(page.getByText("File Upload Limit (MB)")).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: /save settings/i }).click();
|
|
await expect(page.getByText("Settings saved.")).toBeVisible({ timeout: 5_000 });
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// RBAC -- Editor and User GUI-level tab content verification
|
|
// ===========================================================================
|
|
|
|
base.describe("RBAC GUI - Editor tab content access", () => {
|
|
const EDITOR_GUI = `rbacedgui-${UID}`;
|
|
const EDITOR_GUI_PASS = "EdGuiPass1";
|
|
let adminToken: string;
|
|
|
|
base.beforeAll(async () => {
|
|
adminToken = await getAdminToken();
|
|
await createReadyUser(adminToken, EDITOR_GUI, EDITOR_GUI_PASS, "editor");
|
|
});
|
|
|
|
base.afterAll(async () => {
|
|
await deleteUser(adminToken, EDITOR_GUI);
|
|
});
|
|
|
|
base.test("editor can change password from Security tab", async ({ page }) => {
|
|
await login(page, EDITOR_GUI, EDITOR_GUI_PASS);
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /security/i }).click();
|
|
|
|
await page.getByPlaceholder("Current Password").fill(EDITOR_GUI_PASS);
|
|
await page.getByPlaceholder("New Password").first().fill(EDITOR_GUI_PASS);
|
|
await page.getByPlaceholder("Confirm New Password").fill(EDITOR_GUI_PASS);
|
|
await page.getByRole("button", { name: /change password/i }).click();
|
|
|
|
await expect(page.getByText("Password changed successfully")).toBeVisible({ timeout: 5_000 });
|
|
});
|
|
|
|
base.test("editor can generate and delete API key from GUI", async ({ page }) => {
|
|
await login(page, EDITOR_GUI, EDITOR_GUI_PASS);
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /api keys/i }).click();
|
|
|
|
const keyName = `editorKey-${Date.now()}`;
|
|
await page.getByPlaceholder("Key name (optional)").fill(keyName);
|
|
await page.getByRole("button", { name: /generate api key/i }).click();
|
|
|
|
await expect(page.locator("code.font-mono")).toBeVisible({ timeout: 5_000 });
|
|
await expect(page.getByText(keyName)).toBeVisible();
|
|
|
|
// Delete the key
|
|
page.on("dialog", (d) => d.accept());
|
|
await page.locator("button[title='Delete key']").first().click();
|
|
await expect(page.getByText(keyName)).not.toBeVisible({ timeout: 5_000 });
|
|
});
|
|
|
|
base.test("editor can view About tab with version info", async ({ page }) => {
|
|
await login(page, EDITOR_GUI, EDITOR_GUI_PASS);
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /about/i }).click();
|
|
|
|
await expect(page.locator("h3").filter({ hasText: "About" })).toBeVisible();
|
|
await expect(page.getByText("Version:")).toBeVisible();
|
|
await expect(page.getByText("SnapOtter").first()).toBeVisible();
|
|
});
|
|
});
|
|
|
|
base.describe("RBAC GUI - User tab content access", () => {
|
|
const USER_GUI = `rbacusrgui-${UID}`;
|
|
const USER_GUI_PASS = "UsrGuiPass1";
|
|
let adminToken: string;
|
|
|
|
base.beforeAll(async () => {
|
|
adminToken = await getAdminToken();
|
|
await createReadyUser(adminToken, USER_GUI, USER_GUI_PASS, "user");
|
|
});
|
|
|
|
base.afterAll(async () => {
|
|
await deleteUser(adminToken, USER_GUI);
|
|
});
|
|
|
|
base.test("user can save General tab preferences", async ({ page }) => {
|
|
await login(page, USER_GUI, USER_GUI_PASS);
|
|
await openSettings(page);
|
|
|
|
await expect(page.getByText("Default Tool View")).toBeVisible();
|
|
await page.getByRole("button", { name: /save settings/i }).click();
|
|
await expect(page.getByText("Settings saved.")).toBeVisible({ timeout: 5_000 });
|
|
});
|
|
|
|
base.test("user can change own password from Security tab", async ({ page }) => {
|
|
await login(page, USER_GUI, USER_GUI_PASS);
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /security/i }).click();
|
|
|
|
await page.getByPlaceholder("Current Password").fill(USER_GUI_PASS);
|
|
await page.getByPlaceholder("New Password").first().fill(USER_GUI_PASS);
|
|
await page.getByPlaceholder("Confirm New Password").fill(USER_GUI_PASS);
|
|
await page.getByRole("button", { name: /change password/i }).click();
|
|
|
|
await expect(page.getByText("Password changed successfully")).toBeVisible({ timeout: 5_000 });
|
|
});
|
|
|
|
base.test("user cannot access the Tools tab (requires settings:write)", async ({ page }) => {
|
|
await login(page, USER_GUI, USER_GUI_PASS);
|
|
await openSettings(page);
|
|
await expect(page.getByRole("button", { name: /general/i })).toBeVisible();
|
|
|
|
// The Tools tab writes the admin-only /v1/settings endpoint, so it is gated
|
|
// behind settings:write and hidden from the user role.
|
|
await expect(page.getByRole("button", { name: /tools/i })).not.toBeVisible();
|
|
});
|
|
|
|
base.test("user can generate API key from GUI", async ({ page }) => {
|
|
await login(page, USER_GUI, USER_GUI_PASS);
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /api keys/i }).click();
|
|
|
|
const keyName = `userKey-${Date.now()}`;
|
|
await page.getByPlaceholder("Key name (optional)").fill(keyName);
|
|
await page.getByRole("button", { name: /generate api key/i }).click();
|
|
|
|
await expect(page.locator("code.font-mono")).toBeVisible({ timeout: 5_000 });
|
|
await expect(page.getByText("Store this key securely")).toBeVisible();
|
|
|
|
// Clean up
|
|
page.on("dialog", (d) => d.accept());
|
|
await page.locator("button[title='Delete key']").first().click();
|
|
await page.waitForTimeout(500);
|
|
});
|
|
});
|