Files
SnapOtter/tests/e2e/rbac.spec.ts
T
SnapOtterandGitHub 6917a8b0c7 fix(test): repair integration suite after analytics column/endpoint removal (#340)
* fix(test): repair integration suite after analytics column/endpoint removal

#336 moved analytics to a build-time bake: migration 0005 dropped the
users.analytics_enabled and analytics_consent_* columns and removed the
PUT /api/v1/user/analytics endpoint. Two integration tests were left
referencing the old shape and went red on main (13 failures):

- migrate-from-sqlite.test.ts built 1.x SQLite fixtures whose users table
  declared the analytics columns. The generic SELECT *-based importer then
  tried to INSERT them into the 2.0 target, which no longer has those
  columns, failing with Postgres 42703 and rolling back the whole import
  (cascading to all 12 assertions). 1.x never had analytics columns, so the
  fixtures are corrected to drop them. Also removed the now-dead analytics
  entries from the importer's TS/BOOL conversion sets.

- analytics.test.ts asserted the removed PUT endpoint returns 404 but sent
  the request unauthenticated, so the global auth preHandler answered 401
  first. It now authenticates, reaching Fastify's not-found handler (404).

Also removed the stale /api/v1/user/analytics path from openapi.yaml.

Verified locally: full platform integration bucket 1029 passed / 0 failed;
monorepo typecheck clean.

* test(e2e): drop orphaned analytics-consent dismissal calls

#336 deleted the entire analytics consent system (consent page, consent
module, and PUT /api/v1/user/analytics), but six tests/e2e files still
PUT to that removed endpoint to 'dismiss analytics consent.' The calls
were silent no-ops (Playwright request.put / fetch don't throw on 4xx),
so they passed while hitting a dead route.

There is no consent prompt to dismiss anymore, so remove the calls:
- auth.setup.ts / qa-auth.setup.ts: keep the waitForFunction that syncs on
  login completion, drop the now-unused token capture, the dead PUT, and
  the stale 'consent guard' comments.
- rbac / rbac-full / gui-settings-rbac / gui-settings-expanded specs: the
  re-login blocks existed solely to obtain a token for the PUT (reLoginData
  was used nowhere else and the block was the tail of each helper), so
  remove the whole block. The meaningful create-user/login/change-password
  work is untouched.

Verified: no /api/v1/user/analytics refs remain in tests/e2e; biome clean
(no unused vars).
2026-06-24 13:30:18 +08:00

263 lines
9.4 KiB
TypeScript

import { test as base, expect } from "@playwright/test";
import { authFile } from "../../playwright.config";
import { login, openSettings } from "./helpers";
const API = process.env.API_URL || "http://localhost:13490";
const TEST_USER = "rbactest";
const TEST_PASSWORD = "RbacTest1";
/** Auth header only (GET, DELETE). */
function authOnly(token: string): Record<string, string> {
return { Authorization: `Bearer ${token}` };
}
/** Auth + JSON content-type (POST, PUT). */
function authJson(token: string): Record<string, string> {
return { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
}
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" }),
});
const data = await res.json();
return data.token;
}
/**
* Create the test user with role "user" and clear the mustChangePassword flag
* so the browser login redirects to "/" instead of "/change-password".
*/
async function ensureTestUser(adminToken: string): Promise<void> {
// Create - ignore 409 if already exists
const createRes = await fetch(`${API}/api/auth/register`, {
method: "POST",
headers: authJson(adminToken),
body: JSON.stringify({
username: TEST_USER,
password: TEST_PASSWORD,
role: "user",
}),
});
if (createRes.status !== 201 && createRes.status !== 409) {
throw new Error(`Failed to create test user: ${createRes.status}`);
}
// Login as the test user to get a token
const loginRes = await fetch(`${API}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: TEST_USER, password: TEST_PASSWORD }),
});
if (!loginRes.ok) {
throw new Error(`Failed to login as test user: ${loginRes.status}`);
}
const loginData = await loginRes.json();
// Change password (same value) to clear mustChangePassword flag
const changeRes = await fetch(`${API}/api/auth/change-password`, {
method: "POST",
headers: authJson(loginData.token),
body: JSON.stringify({
currentPassword: TEST_PASSWORD,
newPassword: TEST_PASSWORD,
}),
});
if (!changeRes.ok) {
throw new Error(`Failed to clear mustChangePassword: ${changeRes.status}`);
}
}
/** Delete the test user if it exists. */
async function cleanupTestUser(adminToken: string): Promise<void> {
const listRes = await fetch(`${API}/api/auth/users`, {
headers: authOnly(adminToken),
});
if (!listRes.ok) return;
const { users } = await listRes.json();
const testUser = users.find((u: { username: string }) => u.username === TEST_USER);
if (testUser) {
await fetch(`${API}/api/auth/users/${testUser.id}`, {
method: "DELETE",
headers: authOnly(adminToken),
});
}
}
// ── Admin sees all tabs ─────────────────────────────────────────────
base.describe("RBAC - Admin sees all tabs", () => {
base.use({
storageState: authFile,
});
base.test("admin sees all settings tabs", async ({ page }) => {
await page.goto("/");
await openSettings(page);
await expect(page.getByRole("button", { name: /general/i })).toBeVisible();
await expect(page.getByRole("button", { name: /system settings/i })).toBeVisible();
await expect(page.getByRole("button", { name: /security/i })).toBeVisible();
await expect(page.getByRole("button", { name: /people/i })).toBeVisible();
await expect(page.getByRole("button", { name: /teams/i })).toBeVisible();
await expect(page.getByRole("button", { name: /api keys/i })).toBeVisible();
await expect(page.getByRole("button", { name: /tools/i })).toBeVisible();
await expect(page.getByRole("button", { name: /about/i })).toBeVisible();
});
});
// ── User sees restricted tabs ───────────────────────────────────────
base.describe("RBAC - User sees restricted tabs", () => {
let adminToken: string;
base.beforeAll(async () => {
adminToken = await getAdminToken();
await ensureTestUser(adminToken);
});
base.afterAll(async () => {
await cleanupTestUser(adminToken);
});
base.test("user role only sees permitted settings tabs", async ({ page }) => {
await login(page, TEST_USER, TEST_PASSWORD);
await openSettings(page);
// Should see these tabs
await expect(page.getByRole("button", { name: /general/i })).toBeVisible();
await expect(page.getByRole("button", { name: /security/i })).toBeVisible();
await expect(page.getByRole("button", { name: /api keys/i })).toBeVisible();
await expect(page.getByRole("button", { name: /tools/i })).toBeVisible();
await expect(page.getByRole("button", { name: /about/i })).toBeVisible();
// Should NOT see admin-only tabs
await expect(page.getByRole("button", { name: /system settings/i })).not.toBeVisible();
await expect(page.getByRole("button", { name: /people/i })).not.toBeVisible();
await expect(page.getByRole("button", { name: /teams/i })).not.toBeVisible();
// Should NOT see editor-only tabs (requires settings:write)
await expect(page.getByRole("button", { name: /ai features/i })).not.toBeVisible();
});
base.test("user role gets 403 on admin API endpoints", async ({ page }) => {
await login(page, TEST_USER, TEST_PASSWORD);
// Extract token from localStorage
const token = await page.evaluate(() => localStorage.getItem("snapotter-token"));
expect(token).toBeTruthy();
const bearerToken = token as string;
// GET /api/auth/users requires users:manage
const usersRes = await fetch(`${API}/api/auth/users`, {
headers: authOnly(bearerToken),
});
expect(usersRes.status).toBe(403);
// PUT /api/v1/settings requires settings:write
const settingsRes = await fetch(`${API}/api/v1/settings`, {
method: "PUT",
headers: authJson(bearerToken),
body: JSON.stringify({ testSetting: "hacked" }),
});
expect(settingsRes.status).toBe(403);
});
});
// ── Editor sees collaborative tabs ─────────────────────────────────
base.describe("RBAC - Editor sees collaborative tabs", () => {
let adminToken: string;
base.beforeAll(async () => {
adminToken = await getAdminToken();
// Create editor user
const createRes = await fetch(`${API}/api/auth/register`, {
method: "POST",
headers: authJson(adminToken),
body: JSON.stringify({
username: "editortest",
password: "EditorTest1",
role: "editor",
}),
});
if (createRes.status !== 201 && createRes.status !== 409) {
throw new Error(`Failed to create editor user: ${createRes.status}`);
}
// Clear mustChangePassword
const loginRes = await fetch(`${API}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "editortest", password: "EditorTest1" }),
});
if (!loginRes.ok) throw new Error(`Editor login failed: ${loginRes.status}`);
const loginData = await loginRes.json();
await fetch(`${API}/api/auth/change-password`, {
method: "POST",
headers: authJson(loginData.token),
body: JSON.stringify({
currentPassword: "EditorTest1",
newPassword: "EditorTest1",
}),
});
});
base.afterAll(async () => {
const listRes = await fetch(`${API}/api/auth/users`, {
headers: authOnly(adminToken),
});
if (!listRes.ok) return;
const { users } = await listRes.json();
const editor = users.find((u: { username: string }) => u.username === "editortest");
if (editor) {
await fetch(`${API}/api/auth/users/${editor.id}`, {
method: "DELETE",
headers: authOnly(adminToken),
});
}
});
base.test(
"editor sees general, security, api-keys, tools, about but not admin tabs",
async ({ page }) => {
await login(page, "editortest", "EditorTest1");
await openSettings(page);
// Should see these
await expect(page.getByRole("button", { name: /general/i })).toBeVisible();
await expect(page.getByRole("button", { name: /security/i })).toBeVisible();
await expect(page.getByRole("button", { name: /api keys/i })).toBeVisible();
await expect(page.getByRole("button", { name: /tools/i })).toBeVisible();
await expect(page.getByRole("button", { name: /about/i })).toBeVisible();
// Should NOT see admin tabs
await expect(page.getByRole("button", { name: /system settings/i })).not.toBeVisible();
await expect(page.getByRole("button", { name: /people/i })).not.toBeVisible();
await expect(page.getByRole("button", { name: /teams/i })).not.toBeVisible();
},
);
base.test("editor gets 403 on admin API endpoints", async ({ page }) => {
await login(page, "editortest", "EditorTest1");
const token = await page.evaluate(() => localStorage.getItem("snapotter-token"));
expect(token).toBeTruthy();
const usersRes = await fetch(`${API}/api/auth/users`, {
headers: authOnly(token as string),
});
expect(usersRes.status).toBe(403);
const settingsRes = await fetch(`${API}/api/v1/settings`, {
method: "PUT",
headers: authJson(token as string),
body: JSON.stringify({ testSetting: "hacked" }),
});
expect(settingsRes.status).toBe(403);
});
});