mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: remove app name and logo customization feature
Users can no longer customize the app name or logo. The branding API endpoints, permission, frontend UI, env vars (APP_NAME, MAX_LOGO_SIZE_KB), and all related tests are removed. Includes a migration to clean up branding data from existing databases.
This commit is contained in:
@@ -122,15 +122,6 @@ test.describe("GUI Settings - General Tab", () => {
|
||||
});
|
||||
|
||||
test.describe("GUI Settings - System Settings Tab", () => {
|
||||
test("shows App Name input", async ({ loggedInPage: page }) => {
|
||||
await openSettings(page);
|
||||
await page.getByRole("button", { name: /system settings/i }).click();
|
||||
|
||||
await expect(page.getByText("App Name")).toBeVisible();
|
||||
const appNameInput = page.locator("input[type='text']").first();
|
||||
await expect(appNameInput).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows File Upload Limit input", async ({ loggedInPage: page }) => {
|
||||
await openSettings(page);
|
||||
await page.getByRole("button", { name: /system settings/i }).click();
|
||||
@@ -167,7 +158,7 @@ test.describe("GUI Settings - System Settings Tab", () => {
|
||||
await page.getByRole("button", { name: /system settings/i }).click();
|
||||
|
||||
// Wait for section to load
|
||||
await expect(page.getByText("App Name")).toBeVisible();
|
||||
await expect(page.getByText("File Upload Limit (MB)")).toBeVisible();
|
||||
|
||||
// Click save
|
||||
await page.getByRole("button", { name: /save settings/i }).click();
|
||||
@@ -372,45 +363,3 @@ test.describe("GUI Settings - Product Analytics Tab (deep)", () => {
|
||||
expect(toggleVisible || disabledVisible).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("GUI Settings - Save and Persistence", () => {
|
||||
test("System Settings save persists after dialog close and reopen", async ({
|
||||
loggedInPage: page,
|
||||
}) => {
|
||||
// Navigate to System Settings tab
|
||||
await openSettings(page);
|
||||
await page.getByRole("button", { name: /system settings/i }).click();
|
||||
await expect(page.getByText("App Name")).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Wait for the input to load
|
||||
const appNameInput = page.locator("input[type='text']").first();
|
||||
await expect(appNameInput).toBeVisible();
|
||||
const originalName = await appNameInput.inputValue();
|
||||
const testName = originalName === "SnapOtter" ? "TestApp" : "SnapOtter";
|
||||
|
||||
// Change the App Name
|
||||
await appNameInput.fill(testName);
|
||||
|
||||
// Save
|
||||
await page.getByRole("button", { name: /save settings/i }).click();
|
||||
await expect(page.getByText("Settings saved.")).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Close the dialog
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.locator("h2").filter({ hasText: "Settings" })).not.toBeVisible();
|
||||
|
||||
// Reopen the dialog and go to System Settings
|
||||
await openSettings(page);
|
||||
await page.getByRole("button", { name: /system settings/i }).click();
|
||||
await expect(page.getByText("App Name")).toBeVisible();
|
||||
|
||||
// Verify the setting persisted
|
||||
const persistedName = await page.locator("input[type='text']").first().inputValue();
|
||||
expect(persistedName).toBe(testName);
|
||||
|
||||
// Restore original name
|
||||
await page.locator("input[type='text']").first().fill(originalName);
|
||||
await page.getByRole("button", { name: /save settings/i }).click();
|
||||
await expect(page.getByText("Settings saved.")).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -331,7 +331,7 @@ base.describe("RBAC Full — Custom Role User", () => {
|
||||
const writeRes = await fetch(`${API}/api/v1/settings`, {
|
||||
method: "PUT",
|
||||
headers: authJson(bearerToken),
|
||||
body: JSON.stringify({ appName: "hacked" }),
|
||||
body: JSON.stringify({ testSetting: "hacked" }),
|
||||
});
|
||||
expect(writeRes.status).toBe(403);
|
||||
|
||||
|
||||
@@ -174,7 +174,7 @@ base.describe("RBAC - User sees restricted tabs", () => {
|
||||
const settingsRes = await fetch(`${API}/api/v1/settings`, {
|
||||
method: "PUT",
|
||||
headers: authJson(bearerToken),
|
||||
body: JSON.stringify({ appName: "hacked" }),
|
||||
body: JSON.stringify({ testSetting: "hacked" }),
|
||||
});
|
||||
expect(settingsRes.status).toBe(403);
|
||||
});
|
||||
@@ -280,7 +280,7 @@ base.describe("RBAC - Editor sees collaborative tabs", () => {
|
||||
const settingsRes = await fetch(`${API}/api/v1/settings`, {
|
||||
method: "PUT",
|
||||
headers: authJson(token as string),
|
||||
body: JSON.stringify({ appName: "hacked" }),
|
||||
body: JSON.stringify({ testSetting: "hacked" }),
|
||||
});
|
||||
expect(settingsRes.status).toBe(403);
|
||||
});
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("API key permission scoping", () => {
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${apiKey}` },
|
||||
payload: { appName: "hacked" },
|
||||
payload: { testSetting: "hacked" },
|
||||
});
|
||||
expect(writeRes.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
@@ -1452,7 +1452,7 @@ describe("Settings", () => {
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
payload: { app_name: "<script>alert('xss')</script>" },
|
||||
payload: { test_setting: "<script>alert('xss')</script>" },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
const body = JSON.parse(res.body);
|
||||
@@ -1500,7 +1500,7 @@ describe("Settings", () => {
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
payload: { app_name: "My App (v2.0) - Production" },
|
||||
payload: { test_setting: "My App (v2.0) - Production" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
|
||||
@@ -1,533 +0,0 @@
|
||||
/**
|
||||
* Integration tests for the Logo Branding API.
|
||||
*
|
||||
* POST /api/v1/settings/logo — Upload logo (admin only)
|
||||
* GET /api/v1/settings/logo — Serve custom logo as PNG (public)
|
||||
* DELETE /api/v1/settings/logo — Remove custom logo (admin only)
|
||||
*/
|
||||
|
||||
import sharp from "sharp";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
let testApp: TestApp;
|
||||
let app: TestApp["app"];
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
app = testApp.app;
|
||||
adminToken = await loginAsAdmin(app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
// Helper: create a small test PNG
|
||||
async function makeTestPng(width = 10, height = 10): Promise<Buffer> {
|
||||
return sharp({
|
||||
create: { width, height, channels: 4, background: { r: 255, g: 0, b: 0, alpha: 1 } },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
// Helper: create a test JPEG
|
||||
async function makeTestJpeg(width = 10, height = 10): Promise<Buffer> {
|
||||
return sharp({
|
||||
create: { width, height, channels: 3, background: { r: 0, g: 255, b: 0 } },
|
||||
})
|
||||
.jpeg()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
// Helper: create a simple SVG buffer
|
||||
function makeTestSvg(): Buffer {
|
||||
return Buffer.from(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect fill="blue" width="64" height="64"/></svg>',
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// POST /api/v1/settings/logo
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe("POST /api/v1/settings/logo", () => {
|
||||
it("uploads a PNG logo and stores it", async () => {
|
||||
const png = await makeTestPng();
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "logo.png", contentType: "image/png", content: png },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("uploads a JPEG and converts to PNG", async () => {
|
||||
const jpeg = await makeTestJpeg();
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "logo.jpg", contentType: "image/jpeg", content: jpeg },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
// Verify it was converted — GET should return PNG
|
||||
const getRes = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(getRes.headers["content-type"]).toBe("image/png");
|
||||
});
|
||||
|
||||
it("uploads an SVG and converts to PNG", async () => {
|
||||
const svg = makeTestSvg();
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "logo.svg", contentType: "image/svg+xml", content: svg },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects files over 500KB", async () => {
|
||||
// Create an image that's just over 500KB but under the multipart limit (10MB)
|
||||
// A 500x500 uncompressed PNG with 4 channels is ~1MB
|
||||
const largePng = await sharp({
|
||||
create: {
|
||||
width: 500,
|
||||
height: 500,
|
||||
channels: 4,
|
||||
background: { r: 128, g: 64, b: 32, alpha: 1 },
|
||||
},
|
||||
})
|
||||
.png({ compressionLevel: 0 })
|
||||
.toBuffer();
|
||||
|
||||
// Ensure it's actually over 500KB
|
||||
expect(largePng.length).toBeGreaterThan(500 * 1024);
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "big.png", contentType: "image/png", content: largePng },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).error).toMatch(/500KB/i);
|
||||
});
|
||||
|
||||
it("rejects non-image files", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "data.txt",
|
||||
contentType: "text/plain",
|
||||
content: Buffer.from("not an image"),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).error).toMatch(/image/i);
|
||||
});
|
||||
|
||||
it("requires admin role", async () => {
|
||||
// Register a non-admin user
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/register",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
payload: { username: "branduser", password: "Testpass1", role: "user" },
|
||||
});
|
||||
|
||||
// Login as non-admin
|
||||
const loginRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/login",
|
||||
payload: { username: "branduser", password: "Testpass1" },
|
||||
});
|
||||
|
||||
// Clear mustChangePassword
|
||||
const { token } = JSON.parse(loginRes.body);
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/change-password",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { currentPassword: "Testpass1", newPassword: "Newpass123" },
|
||||
});
|
||||
|
||||
// Login again with new password
|
||||
const loginRes2 = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/login",
|
||||
payload: { username: "branduser", password: "Newpass123" },
|
||||
});
|
||||
const userToken = JSON.parse(loginRes2.body).token;
|
||||
|
||||
const png = await makeTestPng();
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "logo.png", contentType: "image/png", content: png },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${userToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("resizes large images to max 128x128", async () => {
|
||||
const png = await makeTestPng(256, 256);
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "big-logo.png", contentType: "image/png", content: png },
|
||||
]);
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
|
||||
// Fetch and check dimensions
|
||||
const getRes = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
const metadata = await sharp(getRes.rawPayload).metadata();
|
||||
expect(metadata.width).toBeLessThanOrEqual(128);
|
||||
expect(metadata.height).toBeLessThanOrEqual(128);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Settings state verification (customLogo flag)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe("customLogo setting reflects actual state", () => {
|
||||
it("sets customLogo to true after successful upload", async () => {
|
||||
const png = await makeTestPng();
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "logo.png", contentType: "image/png", content: png },
|
||||
]);
|
||||
|
||||
const uploadRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
expect(uploadRes.statusCode).toBe(200);
|
||||
|
||||
const settingsRes = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
const { settings } = JSON.parse(settingsRes.body);
|
||||
expect(settings.customLogo).toBe("true");
|
||||
});
|
||||
|
||||
it("does not set customLogo to true when upload is rejected (oversized)", async () => {
|
||||
// First ensure no logo exists
|
||||
await app.inject({
|
||||
method: "DELETE",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
const largePng = await sharp({
|
||||
create: { width: 500, height: 500, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } },
|
||||
})
|
||||
.png({ compressionLevel: 0 })
|
||||
.toBuffer();
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "big.png", contentType: "image/png", content: largePng },
|
||||
]);
|
||||
|
||||
const uploadRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
expect(uploadRes.statusCode).toBe(400);
|
||||
|
||||
const settingsRes = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
const { settings } = JSON.parse(settingsRes.body);
|
||||
expect(settings.customLogo).not.toBe("true");
|
||||
});
|
||||
|
||||
it("sets customLogo to false after deletion", async () => {
|
||||
// Upload first
|
||||
const png = await makeTestPng();
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "logo.png", contentType: "image/png", content: png },
|
||||
]);
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
|
||||
// Delete
|
||||
await app.inject({
|
||||
method: "DELETE",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
const settingsRes = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
const { settings } = JSON.parse(settingsRes.body);
|
||||
expect(settings.customLogo).toBe("false");
|
||||
});
|
||||
|
||||
it("returns 400 with clear error when no file is attached", async () => {
|
||||
const { body, contentType } = createMultipartPayload([]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).error).toMatch(/no file/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/v1/settings/logo
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe("GET /api/v1/settings/logo", () => {
|
||||
it("returns 404 when no custom logo is set", async () => {
|
||||
// First delete any existing logo
|
||||
await app.inject({
|
||||
method: "DELETE",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings/logo",
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("returns the logo with image/png content-type after upload", async () => {
|
||||
// Upload a logo first
|
||||
const png = await makeTestPng();
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "logo.png", contentType: "image/png", content: png },
|
||||
]);
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings/logo",
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers["content-type"]).toBe("image/png");
|
||||
// Verify it's valid PNG data
|
||||
const metadata = await sharp(res.rawPayload).metadata();
|
||||
expect(metadata.format).toBe("png");
|
||||
});
|
||||
|
||||
it("works without authentication (public path)", async () => {
|
||||
// Upload a logo first (as admin)
|
||||
const png = await makeTestPng();
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "logo.png", contentType: "image/png", content: png },
|
||||
]);
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
|
||||
// GET without any auth header
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings/logo",
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers["content-type"]).toBe("image/png");
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// DELETE /api/v1/settings/logo
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe("DELETE /api/v1/settings/logo", () => {
|
||||
it("removes the custom logo", async () => {
|
||||
// Upload first
|
||||
const png = await makeTestPng();
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "logo.png", contentType: "image/png", content: png },
|
||||
]);
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
|
||||
// Delete
|
||||
const delRes = await app.inject({
|
||||
method: "DELETE",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
expect(delRes.statusCode).toBe(200);
|
||||
expect(JSON.parse(delRes.body)).toEqual({ ok: true });
|
||||
|
||||
// Verify logo is gone
|
||||
const getRes = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings/logo",
|
||||
});
|
||||
|
||||
expect(getRes.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("requires admin role", async () => {
|
||||
// Login as non-admin (created in earlier test)
|
||||
const loginRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/login",
|
||||
payload: { username: "branduser", password: "Newpass123" },
|
||||
});
|
||||
const userToken = JSON.parse(loginRes.body).token;
|
||||
|
||||
const res = await app.inject({
|
||||
method: "DELETE",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: { authorization: `Bearer ${userToken}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("returns ok even if no logo exists (idempotent)", async () => {
|
||||
// Ensure no logo exists
|
||||
await app.inject({
|
||||
method: "DELETE",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
// Delete again
|
||||
const res = await app.inject({
|
||||
method: "DELETE",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body)).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
@@ -243,15 +243,6 @@ const routes: RouteTest[] = [
|
||||
unauth: 401,
|
||||
label: "system:health",
|
||||
},
|
||||
{
|
||||
method: "DELETE",
|
||||
url: "/api/v1/settings/logo",
|
||||
admin: 200,
|
||||
editor: 403,
|
||||
user: 403,
|
||||
unauth: 401,
|
||||
label: "branding:manage (delete logo)",
|
||||
},
|
||||
];
|
||||
|
||||
describe("RBAC route permission matrix (full)", () => {
|
||||
@@ -320,7 +311,6 @@ describe("Cross-role isolation", () => {
|
||||
expect(body.user.permissions).not.toContain("settings:write");
|
||||
expect(body.user.permissions).not.toContain("users:manage");
|
||||
expect(body.user.permissions).not.toContain("teams:manage");
|
||||
expect(body.user.permissions).not.toContain("branding:manage");
|
||||
expect(body.user.permissions).not.toContain("features:manage");
|
||||
expect(body.user.permissions).not.toContain("system:health");
|
||||
expect(body.user.permissions).not.toContain("audit:read");
|
||||
|
||||
@@ -36,7 +36,6 @@ import { analyticsRoutes } from "../../apps/api/src/routes/analytics.js";
|
||||
import { apiKeyRoutes } from "../../apps/api/src/routes/api-keys.js";
|
||||
import { auditLogRoutes } from "../../apps/api/src/routes/audit-log.js";
|
||||
import { registerBatchRoutes } from "../../apps/api/src/routes/batch.js";
|
||||
import { brandingRoutes } from "../../apps/api/src/routes/branding.js";
|
||||
import { docsRoutes } from "../../apps/api/src/routes/docs.js";
|
||||
import { fileRoutes } from "../../apps/api/src/routes/files.js";
|
||||
import { registerPipelineRoutes } from "../../apps/api/src/routes/pipeline.js";
|
||||
@@ -109,9 +108,6 @@ export async function buildTestApp(): Promise<TestApp> {
|
||||
// Settings routes
|
||||
await settingsRoutes(app);
|
||||
|
||||
// Branding routes
|
||||
await brandingRoutes(app);
|
||||
|
||||
// Teams routes
|
||||
await teamsRoutes(app);
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ describe("hasEffectivePermission", () => {
|
||||
expect(hasEffectivePermission(editor, "users:manage")).toBe(false);
|
||||
expect(hasEffectivePermission(editor, "settings:write")).toBe(false);
|
||||
expect(hasEffectivePermission(editor, "teams:manage")).toBe(false);
|
||||
expect(hasEffectivePermission(editor, "branding:manage")).toBe(false);
|
||||
expect(hasEffectivePermission(editor, "features:manage")).toBe(false);
|
||||
expect(hasEffectivePermission(editor, "system:health")).toBe(false);
|
||||
expect(hasEffectivePermission(editor, "audit:read")).toBe(false);
|
||||
@@ -185,8 +184,8 @@ describe("hasEffectivePermission", () => {
|
||||
|
||||
describe("getPermissions", () => {
|
||||
describe("exact counts for built-in roles", () => {
|
||||
it("admin has exactly 15 permissions", () => {
|
||||
expect(getPermissions("admin")).toHaveLength(15);
|
||||
it("admin has exactly 14 permissions", () => {
|
||||
expect(getPermissions("admin")).toHaveLength(14);
|
||||
});
|
||||
|
||||
it("editor has exactly 7 permissions", () => {
|
||||
|
||||
@@ -19,9 +19,9 @@ import { getPermissions, hasPermission } from "../../../apps/api/src/permissions
|
||||
|
||||
describe("permissions", () => {
|
||||
describe("getPermissions", () => {
|
||||
it("returns all 15 permissions for admin", () => {
|
||||
it("returns all 14 permissions for admin", () => {
|
||||
const perms = getPermissions("admin");
|
||||
expect(perms).toHaveLength(15);
|
||||
expect(perms).toHaveLength(14);
|
||||
expect(perms).toContain("tools:use");
|
||||
expect(perms).toContain("files:own");
|
||||
expect(perms).toContain("files:all");
|
||||
@@ -33,7 +33,6 @@ describe("permissions", () => {
|
||||
expect(perms).toContain("settings:write");
|
||||
expect(perms).toContain("users:manage");
|
||||
expect(perms).toContain("teams:manage");
|
||||
expect(perms).toContain("branding:manage");
|
||||
expect(perms).toContain("features:manage");
|
||||
expect(perms).toContain("system:health");
|
||||
expect(perms).toContain("audit:read");
|
||||
@@ -58,7 +57,6 @@ describe("permissions", () => {
|
||||
expect(perms).not.toContain("settings:write");
|
||||
expect(perms).not.toContain("users:manage");
|
||||
expect(perms).not.toContain("teams:manage");
|
||||
expect(perms).not.toContain("branding:manage");
|
||||
});
|
||||
|
||||
it("returns empty array for unknown role", () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { getPermissions, hasPermission } from "../../../apps/api/src/permissions.js";
|
||||
|
||||
describe("role permissions", () => {
|
||||
it("admin has all 15 permissions", () => {
|
||||
it("admin has all 14 permissions", () => {
|
||||
const perms = getPermissions("admin");
|
||||
expect(perms).toContain("tools:use");
|
||||
expect(perms).toContain("files:all");
|
||||
@@ -10,7 +10,7 @@ describe("role permissions", () => {
|
||||
expect(perms).toContain("features:manage");
|
||||
expect(perms).toContain("system:health");
|
||||
expect(perms).toContain("audit:read");
|
||||
expect(perms.length).toBe(15);
|
||||
expect(perms.length).toBe(14);
|
||||
});
|
||||
|
||||
it("editor has collaborative but not admin permissions", () => {
|
||||
|
||||
@@ -752,7 +752,6 @@ describe("loadEnv", () => {
|
||||
"WORKSPACE_PATH",
|
||||
"DEFAULT_THEME",
|
||||
"DEFAULT_LOCALE",
|
||||
"APP_NAME",
|
||||
];
|
||||
for (const key of keysToClean) {
|
||||
delete process.env[key];
|
||||
@@ -794,7 +793,6 @@ describe("loadEnv", () => {
|
||||
expect(typeof env.WORKSPACE_PATH).toBe("string");
|
||||
expect(["light", "dark"]).toContain(env.DEFAULT_THEME);
|
||||
expect(typeof env.DEFAULT_LOCALE).toBe("string");
|
||||
expect(typeof env.APP_NAME).toBe("string");
|
||||
});
|
||||
|
||||
it("parses custom PORT as a number via coercion", async () => {
|
||||
@@ -862,12 +860,10 @@ describe("loadEnv", () => {
|
||||
});
|
||||
|
||||
it("accepts string values for string fields", async () => {
|
||||
process.env.APP_NAME = "My Custom App";
|
||||
process.env.DB_PATH = "/var/data/mydb.sqlite";
|
||||
process.env.DEFAULT_LOCALE = "fr";
|
||||
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
||||
const env = loadEnv();
|
||||
expect(env.APP_NAME).toBe("My Custom App");
|
||||
expect(env.DB_PATH).toBe("/var/data/mydb.sqlite");
|
||||
expect(env.DEFAULT_LOCALE).toBe("fr");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user