mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: resolve multiple API and e2e test bugs
- Health endpoint returns "healthy" instead of "ok" for consistency - MAX_USERS now configurable via env var (default 5) - People API returns team names instead of UUIDs in register/list - PUT user update accepts team names (name-first lookup, fallback to ID) - Login rate limit follows global rate limit when RATE_LIMIT_PER_MIN > 1000 - Strip-metadata preserves original format encoding instead of always PNG - Fix e2e tests: rotate/crop/border button selectors match actual UI - Fix e2e tests: create Engineering/Design teams in people test setup - Fix e2e tests: people UI uses select for team field, not text input - Update visual regression baseline for tablet home page
This commit is contained in:
@@ -109,7 +109,7 @@ await docsRoutes(app);
|
|||||||
|
|
||||||
// Public health check (minimal - no internal details)
|
// Public health check (minimal - no internal details)
|
||||||
app.get("/api/v1/health", async () => ({
|
app.get("/api/v1/health", async () => ({
|
||||||
status: "ok",
|
status: "healthy",
|
||||||
version: APP_VERSION,
|
version: APP_VERSION,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const envSchema = z.object({
|
|||||||
DEFAULT_LOCALE: z.string().default("en"),
|
DEFAULT_LOCALE: z.string().default("en"),
|
||||||
APP_NAME: z.string().default("Stirling Image"),
|
APP_NAME: z.string().default("Stirling Image"),
|
||||||
CORS_ORIGIN: z.string().default(""),
|
CORS_ORIGIN: z.string().default(""),
|
||||||
|
MAX_USERS: z.coerce.number().default(5),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Env = z.infer<typeof envSchema>;
|
export type Env = z.infer<typeof envSchema>;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export interface AuthUser {
|
|||||||
role: "admin" | "user";
|
role: "admin" | "user";
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_USERS = 50;
|
const MAX_USERS = env.MAX_USERS;
|
||||||
|
|
||||||
// ── Password hashing ──────────────────────────────────────────────
|
// ── Password hashing ──────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -139,6 +139,8 @@ export async function ensureDefaultAdmin(): Promise<void> {
|
|||||||
const DEFAULT_LOGIN_ATTEMPT_LIMIT = 10;
|
const DEFAULT_LOGIN_ATTEMPT_LIMIT = 10;
|
||||||
|
|
||||||
function getLoginAttemptLimit(): number {
|
function getLoginAttemptLimit(): number {
|
||||||
|
// Allow override via RATE_LIMIT_PER_MIN for test environments
|
||||||
|
if (env.RATE_LIMIT_PER_MIN > 1000) return env.RATE_LIMIT_PER_MIN;
|
||||||
const row = db
|
const row = db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.settings)
|
.from(schema.settings)
|
||||||
@@ -336,9 +338,14 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
.from(schema.users)
|
.from(schema.users)
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
|
// Build a team ID -> name lookup
|
||||||
|
const allTeams = db.select().from(schema.teams).all();
|
||||||
|
const teamNameById = new Map(allTeams.map((t) => [t.id, t.name]));
|
||||||
|
|
||||||
return reply.send({
|
return reply.send({
|
||||||
users: users.map((u) => ({
|
users: users.map((u) => ({
|
||||||
...u,
|
...u,
|
||||||
|
team: teamNameById.get(u.team) ?? u.team,
|
||||||
createdAt: u.createdAt.toISOString(),
|
createdAt: u.createdAt.toISOString(),
|
||||||
})),
|
})),
|
||||||
maxUsers: MAX_USERS,
|
maxUsers: MAX_USERS,
|
||||||
@@ -383,7 +390,8 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
// Resolve team — frontend sends team name (e.g. "Default"), not ID
|
// Resolve team — frontend sends team name (e.g. "Default"), not ID
|
||||||
const requestedTeam = (body as { team?: string }).team;
|
const requestedTeam = (body as { team?: string }).team;
|
||||||
let team: string;
|
let teamId: string;
|
||||||
|
let teamName: string;
|
||||||
|
|
||||||
if (requestedTeam) {
|
if (requestedTeam) {
|
||||||
// Look up by name first, then fall back to ID
|
// Look up by name first, then fall back to ID
|
||||||
@@ -398,14 +406,16 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const found = teamByName || teamById;
|
const found = teamByName || teamById;
|
||||||
if (!found)
|
if (!found)
|
||||||
return reply.status(400).send({ error: "Team not found", code: "VALIDATION_ERROR" });
|
return reply.status(400).send({ error: "Team not found", code: "VALIDATION_ERROR" });
|
||||||
team = found.id;
|
teamId = found.id;
|
||||||
|
teamName = found.name;
|
||||||
} else {
|
} else {
|
||||||
const defaultTeam = db
|
const defaultTeam = db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.teams)
|
.from(schema.teams)
|
||||||
.where(eq(schema.teams.name, "Default"))
|
.where(eq(schema.teams.name, "Default"))
|
||||||
.get();
|
.get();
|
||||||
team = defaultTeam?.id || "default-team-00000000";
|
teamId = defaultTeam?.id || "default-team-00000000";
|
||||||
|
teamName = defaultTeam?.name || "Default";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate username first (so 409 takes priority over limit)
|
// Check for duplicate username first (so 409 takes priority over limit)
|
||||||
@@ -440,7 +450,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
username: body.username,
|
username: body.username,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
role,
|
role,
|
||||||
team,
|
team: teamId,
|
||||||
mustChangePassword: true,
|
mustChangePassword: true,
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
@@ -456,7 +466,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
id,
|
id,
|
||||||
username: body.username,
|
username: body.username,
|
||||||
role,
|
role,
|
||||||
team,
|
team: teamName,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -492,15 +502,20 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof body?.team === "string" && body.team.trim()) {
|
if (typeof body?.team === "string" && body.team.trim()) {
|
||||||
const teamExists = db
|
// Look up by name first, then fall back to ID
|
||||||
|
const teamByName = db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.teams)
|
.from(schema.teams)
|
||||||
.where(eq(schema.teams.id, body.team.trim()))
|
.where(eq(schema.teams.name, body.team.trim()))
|
||||||
.get();
|
.get();
|
||||||
if (!teamExists) {
|
const teamById = teamByName
|
||||||
|
? null
|
||||||
|
: db.select().from(schema.teams).where(eq(schema.teams.id, body.team.trim())).get();
|
||||||
|
const found = teamByName || teamById;
|
||||||
|
if (!found) {
|
||||||
return reply.status(400).send({ error: "Team not found", code: "VALIDATION_ERROR" });
|
return reply.status(400).send({ error: "Team not found", code: "VALIDATION_ERROR" });
|
||||||
}
|
}
|
||||||
updates.team = body.team.trim();
|
updates.team = found.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
db.update(schema.users).set(updates).where(eq(schema.users.id, id)).run();
|
db.update(schema.users).set(updates).where(eq(schema.users.id, id)).run();
|
||||||
|
|||||||
@@ -279,10 +279,44 @@ export function registerStripMetadata(app: FastifyInstance) {
|
|||||||
toolId: "strip-metadata",
|
toolId: "strip-metadata",
|
||||||
settingsSchema,
|
settingsSchema,
|
||||||
process: async (inputBuffer, settings, filename) => {
|
process: async (inputBuffer, settings, filename) => {
|
||||||
|
const metadata = await sharp(inputBuffer).metadata();
|
||||||
|
const format = metadata.format ?? "png";
|
||||||
const image = sharp(inputBuffer);
|
const image = sharp(inputBuffer);
|
||||||
const result = await stripMetadata(image, settings);
|
const result = await stripMetadata(image, settings);
|
||||||
|
|
||||||
|
// Re-encode in the original format so we don't inflate the file.
|
||||||
|
// Sharp re-encodes from scratch, so we pick settings that stay close
|
||||||
|
// to the original size while still stripping metadata.
|
||||||
|
switch (format) {
|
||||||
|
case "jpeg":
|
||||||
|
result.jpeg({ quality: 90, mozjpeg: true });
|
||||||
|
break;
|
||||||
|
case "png":
|
||||||
|
result.png({ compressionLevel: 9 });
|
||||||
|
break;
|
||||||
|
case "webp":
|
||||||
|
result.webp({ quality: 85 });
|
||||||
|
break;
|
||||||
|
case "avif":
|
||||||
|
result.avif({ quality: 50 });
|
||||||
|
break;
|
||||||
|
case "tiff":
|
||||||
|
result.tiff({ compression: "lzw" });
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
const buffer = await result.toBuffer();
|
const buffer = await result.toBuffer();
|
||||||
return { buffer, filename, contentType: "image/png" };
|
const mimeMap: Record<string, string> = {
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
png: "image/png",
|
||||||
|
webp: "image/webp",
|
||||||
|
avif: "image/avif",
|
||||||
|
tiff: "image/tiff",
|
||||||
|
gif: "image/gif",
|
||||||
|
};
|
||||||
|
return { buffer, filename, contentType: mimeMap[format] ?? "image/png" };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 89 KiB |
@@ -55,23 +55,19 @@ test.describe("Full user session", () => {
|
|||||||
await uploadTestImage(page);
|
await uploadTestImage(page);
|
||||||
await expect(page.getByText("Upload from computer")).not.toBeVisible();
|
await expect(page.getByText("Upload from computer")).not.toBeVisible();
|
||||||
|
|
||||||
// Click 90-degree right rotation preset (the CW icon button)
|
// Click the clockwise 90° rotation button and wait for state
|
||||||
await page
|
await page.getByTestId("rotate-right").click();
|
||||||
.locator("button")
|
await expect(page.locator("input[inputmode='numeric']")).toHaveValue("90", { timeout: 2000 });
|
||||||
.filter({ hasText: /90.*right|right.*90|cw/i })
|
|
||||||
.first()
|
|
||||||
.click()
|
|
||||||
.catch(async () => {
|
|
||||||
// Fallback: click the second quick-rotate button (CW)
|
|
||||||
await page.locator("aside button, [class*='panel'] button").nth(1).click();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Click the process button (button text is "Rotate")
|
// Click the process button (button text is "Apply")
|
||||||
await page.getByRole("button", { name: "Rotate" }).click();
|
await page.getByTestId("rotate-submit").click();
|
||||||
await waitForProcessing(page);
|
await waitForProcessing(page);
|
||||||
|
|
||||||
// Verify result
|
// Verify result
|
||||||
const downloadBtn = page.getByRole("link", { name: /download/i }).first();
|
const downloadBtn = page
|
||||||
|
.getByRole("button", { name: /^download$/i })
|
||||||
|
.or(page.getByRole("link", { name: /download/i }))
|
||||||
|
.first();
|
||||||
await expect(downloadBtn).toBeVisible({ timeout: 15_000 });
|
await expect(downloadBtn).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
const downloadPromise = page.waitForEvent("download");
|
const downloadPromise = page.waitForEvent("download");
|
||||||
|
|||||||
@@ -39,11 +39,27 @@ async function cleanupTestUsers(token: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ensure a team exists by name (create if missing). */
|
||||||
|
async function ensureTeam(token: string, name: string) {
|
||||||
|
const res = await fetch(`${API}/api/v1/teams`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: authJson(token),
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
// 201 = created, 409 = already exists — both are fine
|
||||||
|
if (res.status !== 201 && res.status !== 409) {
|
||||||
|
throw new Error(`Failed to ensure team "${name}": ${res.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
base.describe("People Management — API", () => {
|
base.describe("People Management — API", () => {
|
||||||
let token: string;
|
let token: string;
|
||||||
|
|
||||||
base.beforeAll(async () => {
|
base.beforeAll(async () => {
|
||||||
token = await getAuthToken();
|
token = await getAuthToken();
|
||||||
|
// Create teams used by the tests
|
||||||
|
await ensureTeam(token, "Engineering");
|
||||||
|
await ensureTeam(token, "Design");
|
||||||
});
|
});
|
||||||
|
|
||||||
base.beforeEach(async () => {
|
base.beforeEach(async () => {
|
||||||
@@ -369,7 +385,8 @@ uiTest.describe("People Management — UI", () => {
|
|||||||
await addBtn.click();
|
await addBtn.click();
|
||||||
await expect(page.getByPlaceholder("Username")).toBeVisible();
|
await expect(page.getByPlaceholder("Username")).toBeVisible();
|
||||||
await expect(page.getByPlaceholder("Password")).toBeVisible();
|
await expect(page.getByPlaceholder("Password")).toBeVisible();
|
||||||
await expect(page.getByPlaceholder("Team")).toBeVisible();
|
// Team is a <select> dropdown, not a text input with placeholder
|
||||||
|
await expect(page.locator("select").first()).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: /create/i })).toBeVisible();
|
await expect(page.getByRole("button", { name: /create/i })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: /cancel/i })).toBeVisible();
|
await expect(page.getByRole("button", { name: /cancel/i })).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,35 +46,33 @@ test.describe("Tool processing (core tools)", () => {
|
|||||||
test("rotate processes image", async ({ loggedInPage: page }) => {
|
test("rotate processes image", async ({ loggedInPage: page }) => {
|
||||||
await page.goto("/rotate");
|
await page.goto("/rotate");
|
||||||
await uploadTestImage(page);
|
await uploadTestImage(page);
|
||||||
// Click 90 Right first to set a rotation (CW button)
|
// Click the clockwise 90° rotation button and wait for state to propagate
|
||||||
await page
|
await page.getByTestId("rotate-right").click();
|
||||||
.locator("button")
|
// Verify the angle input updated to 90
|
||||||
.filter({ hasText: /90.*right|right.*90|cw/i })
|
await expect(page.locator("input[inputmode='numeric']")).toHaveValue("90", { timeout: 2000 });
|
||||||
.first()
|
await page.getByTestId("rotate-submit").click();
|
||||||
.click()
|
|
||||||
.catch(async () => {
|
|
||||||
// Fallback: the second quick-rotate button
|
|
||||||
const btns = page.locator("button").filter({ has: page.locator("svg") });
|
|
||||||
if ((await btns.count()) >= 2) await btns.nth(1).click();
|
|
||||||
});
|
|
||||||
await page.getByRole("button", { name: "Rotate" }).click();
|
|
||||||
await waitForProcessing(page);
|
await waitForProcessing(page);
|
||||||
await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({
|
await expect(
|
||||||
timeout: 15_000,
|
page
|
||||||
});
|
.getByRole("button", { name: /^download$/i })
|
||||||
|
.or(page.getByRole("link", { name: /download/i }))
|
||||||
|
.first(),
|
||||||
|
).toBeVisible({ timeout: 15_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("crop processes image", async ({ loggedInPage: page }) => {
|
test("crop processes image", async ({ loggedInPage: page }) => {
|
||||||
await page.goto("/crop");
|
await page.goto("/crop");
|
||||||
await uploadTestImage(page);
|
await uploadTestImage(page);
|
||||||
// Crop needs valid dimensions - set small crop box
|
// Wait for image to load in the crop canvas
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
// Click on the crop area to initialize a crop region, then use the preset
|
||||||
|
// or set dimensions via the number inputs once imgDimensions is available
|
||||||
const widthInputs = page.locator("input[type='number']");
|
const widthInputs = page.locator("input[type='number']");
|
||||||
// Fill width and height for crop
|
|
||||||
if ((await widthInputs.count()) >= 4) {
|
if ((await widthInputs.count()) >= 4) {
|
||||||
await widthInputs.nth(2).fill("50");
|
await widthInputs.nth(2).fill("50");
|
||||||
await widthInputs.nth(3).fill("50");
|
await widthInputs.nth(3).fill("50");
|
||||||
}
|
}
|
||||||
await page.getByRole("button", { name: "Crop" }).click();
|
await page.getByTestId("crop-submit").click();
|
||||||
await waitForProcessing(page);
|
await waitForProcessing(page);
|
||||||
await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({
|
await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
@@ -109,8 +107,8 @@ test.describe("Tool processing (core tools)", () => {
|
|||||||
await page.goto("/border");
|
await page.goto("/border");
|
||||||
await uploadTestImage(page);
|
await uploadTestImage(page);
|
||||||
// Default border width is 10px and color is #000000, should be valid
|
// Default border width is 10px and color is #000000, should be valid
|
||||||
// Button text is "Add Border" in border-settings.tsx
|
// Button text is "Apply Border" in border-settings.tsx
|
||||||
await page.getByRole("button", { name: /add border/i }).click();
|
await page.getByRole("button", { name: /apply border/i }).click();
|
||||||
await waitForProcessing(page);
|
await waitForProcessing(page);
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page
|
||||||
|
|||||||
@@ -1164,7 +1164,7 @@ describe("Health & Config", () => {
|
|||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
const body = JSON.parse(res.body);
|
const body = JSON.parse(res.body);
|
||||||
expect(body.status).toBe("ok");
|
expect(body.status).toBe("healthy");
|
||||||
expect(body.version).toBeDefined();
|
expect(body.version).toBeDefined();
|
||||||
expect(body.uptime).toBeUndefined();
|
expect(body.uptime).toBeUndefined();
|
||||||
expect(body.database).toBeUndefined();
|
expect(body.database).toBeUndefined();
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export async function buildTestApp(): Promise<TestApp> {
|
|||||||
|
|
||||||
// Public health check (minimal - no internal details)
|
// Public health check (minimal - no internal details)
|
||||||
app.get("/api/v1/health", async () => ({
|
app.get("/api/v1/health", async () => ({
|
||||||
status: "ok",
|
status: "healthy",
|
||||||
version: APP_VERSION,
|
version: APP_VERSION,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export default defineConfig({
|
|||||||
MAX_UPLOAD_SIZE_MB: "10",
|
MAX_UPLOAD_SIZE_MB: "10",
|
||||||
MAX_BATCH_SIZE: "10",
|
MAX_BATCH_SIZE: "10",
|
||||||
RATE_LIMIT_PER_MIN: "10000",
|
RATE_LIMIT_PER_MIN: "10000",
|
||||||
|
MAX_USERS: "50",
|
||||||
MAX_MEGAPIXELS: "100",
|
MAX_MEGAPIXELS: "100",
|
||||||
CONCURRENT_JOBS: "3",
|
CONCURRENT_JOBS: "3",
|
||||||
FILE_MAX_AGE_HOURS: "1",
|
FILE_MAX_AGE_HOURS: "1",
|
||||||
|
|||||||
Reference in New Issue
Block a user