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)
|
||||
app.get("/api/v1/health", async () => ({
|
||||
status: "ok",
|
||||
status: "healthy",
|
||||
version: APP_VERSION,
|
||||
}));
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ const envSchema = z.object({
|
||||
DEFAULT_LOCALE: z.string().default("en"),
|
||||
APP_NAME: z.string().default("Stirling Image"),
|
||||
CORS_ORIGIN: z.string().default(""),
|
||||
MAX_USERS: z.coerce.number().default(5),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface AuthUser {
|
||||
role: "admin" | "user";
|
||||
}
|
||||
|
||||
const MAX_USERS = 50;
|
||||
const MAX_USERS = env.MAX_USERS;
|
||||
|
||||
// ── Password hashing ──────────────────────────────────────────────
|
||||
|
||||
@@ -139,6 +139,8 @@ export async function ensureDefaultAdmin(): Promise<void> {
|
||||
const DEFAULT_LOGIN_ATTEMPT_LIMIT = 10;
|
||||
|
||||
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
|
||||
.select()
|
||||
.from(schema.settings)
|
||||
@@ -336,9 +338,14 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
.from(schema.users)
|
||||
.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({
|
||||
users: users.map((u) => ({
|
||||
...u,
|
||||
team: teamNameById.get(u.team) ?? u.team,
|
||||
createdAt: u.createdAt.toISOString(),
|
||||
})),
|
||||
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
|
||||
const requestedTeam = (body as { team?: string }).team;
|
||||
let team: string;
|
||||
let teamId: string;
|
||||
let teamName: string;
|
||||
|
||||
if (requestedTeam) {
|
||||
// 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;
|
||||
if (!found)
|
||||
return reply.status(400).send({ error: "Team not found", code: "VALIDATION_ERROR" });
|
||||
team = found.id;
|
||||
teamId = found.id;
|
||||
teamName = found.name;
|
||||
} else {
|
||||
const defaultTeam = db
|
||||
.select()
|
||||
.from(schema.teams)
|
||||
.where(eq(schema.teams.name, "Default"))
|
||||
.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)
|
||||
@@ -440,7 +450,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
username: body.username,
|
||||
passwordHash,
|
||||
role,
|
||||
team,
|
||||
team: teamId,
|
||||
mustChangePassword: true,
|
||||
})
|
||||
.run();
|
||||
@@ -456,7 +466,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
id,
|
||||
username: body.username,
|
||||
role,
|
||||
team,
|
||||
team: teamName,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -492,15 +502,20 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
}
|
||||
|
||||
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()
|
||||
.from(schema.teams)
|
||||
.where(eq(schema.teams.id, body.team.trim()))
|
||||
.where(eq(schema.teams.name, body.team.trim()))
|
||||
.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" });
|
||||
}
|
||||
updates.team = body.team.trim();
|
||||
updates.team = found.id;
|
||||
}
|
||||
|
||||
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",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const metadata = await sharp(inputBuffer).metadata();
|
||||
const format = metadata.format ?? "png";
|
||||
const image = sharp(inputBuffer);
|
||||
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();
|
||||
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 expect(page.getByText("Upload from computer")).not.toBeVisible();
|
||||
|
||||
// Click 90-degree right rotation preset (the CW icon button)
|
||||
await page
|
||||
.locator("button")
|
||||
.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 clockwise 90° rotation button and wait for state
|
||||
await page.getByTestId("rotate-right").click();
|
||||
await expect(page.locator("input[inputmode='numeric']")).toHaveValue("90", { timeout: 2000 });
|
||||
|
||||
// Click the process button (button text is "Rotate")
|
||||
await page.getByRole("button", { name: "Rotate" }).click();
|
||||
// Click the process button (button text is "Apply")
|
||||
await page.getByTestId("rotate-submit").click();
|
||||
await waitForProcessing(page);
|
||||
|
||||
// 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 });
|
||||
|
||||
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", () => {
|
||||
let token: string;
|
||||
|
||||
base.beforeAll(async () => {
|
||||
token = await getAuthToken();
|
||||
// Create teams used by the tests
|
||||
await ensureTeam(token, "Engineering");
|
||||
await ensureTeam(token, "Design");
|
||||
});
|
||||
|
||||
base.beforeEach(async () => {
|
||||
@@ -369,7 +385,8 @@ uiTest.describe("People Management — UI", () => {
|
||||
await addBtn.click();
|
||||
await expect(page.getByPlaceholder("Username")).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: /cancel/i })).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -46,35 +46,33 @@ test.describe("Tool processing (core tools)", () => {
|
||||
test("rotate processes image", async ({ loggedInPage: page }) => {
|
||||
await page.goto("/rotate");
|
||||
await uploadTestImage(page);
|
||||
// Click 90 Right first to set a rotation (CW button)
|
||||
await page
|
||||
.locator("button")
|
||||
.filter({ hasText: /90.*right|right.*90|cw/i })
|
||||
.first()
|
||||
.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();
|
||||
// Click the clockwise 90° rotation button and wait for state to propagate
|
||||
await page.getByTestId("rotate-right").click();
|
||||
// Verify the angle input updated to 90
|
||||
await expect(page.locator("input[inputmode='numeric']")).toHaveValue("90", { timeout: 2000 });
|
||||
await page.getByTestId("rotate-submit").click();
|
||||
await waitForProcessing(page);
|
||||
await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(
|
||||
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 }) => {
|
||||
await page.goto("/crop");
|
||||
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']");
|
||||
// Fill width and height for crop
|
||||
if ((await widthInputs.count()) >= 4) {
|
||||
await widthInputs.nth(2).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 expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
@@ -109,8 +107,8 @@ test.describe("Tool processing (core tools)", () => {
|
||||
await page.goto("/border");
|
||||
await uploadTestImage(page);
|
||||
// Default border width is 10px and color is #000000, should be valid
|
||||
// Button text is "Add Border" in border-settings.tsx
|
||||
await page.getByRole("button", { name: /add border/i }).click();
|
||||
// Button text is "Apply Border" in border-settings.tsx
|
||||
await page.getByRole("button", { name: /apply border/i }).click();
|
||||
await waitForProcessing(page);
|
||||
await expect(
|
||||
page
|
||||
|
||||
@@ -1164,7 +1164,7 @@ describe("Health & Config", () => {
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.status).toBe("ok");
|
||||
expect(body.status).toBe("healthy");
|
||||
expect(body.version).toBeDefined();
|
||||
expect(body.uptime).toBeUndefined();
|
||||
expect(body.database).toBeUndefined();
|
||||
|
||||
@@ -117,7 +117,7 @@ export async function buildTestApp(): Promise<TestApp> {
|
||||
|
||||
// Public health check (minimal - no internal details)
|
||||
app.get("/api/v1/health", async () => ({
|
||||
status: "ok",
|
||||
status: "healthy",
|
||||
version: APP_VERSION,
|
||||
}));
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export default defineConfig({
|
||||
MAX_UPLOAD_SIZE_MB: "10",
|
||||
MAX_BATCH_SIZE: "10",
|
||||
RATE_LIMIT_PER_MIN: "10000",
|
||||
MAX_USERS: "50",
|
||||
MAX_MEGAPIXELS: "100",
|
||||
CONCURRENT_JOBS: "3",
|
||||
FILE_MAX_AGE_HOURS: "1",
|
||||
|
||||
Reference in New Issue
Block a user