From ab370a74fefebc8735e337f9b42e48ab6625c5b4 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Wed, 25 Mar 2026 09:25:59 +0800 Subject: [PATCH] feat(api): add teams CRUD routes and update auth team references --- apps/api/drizzle/0005_add_teams_table.sql | 7 +- apps/api/src/plugins/auth.ts | 523 ++++++++++++++-------- apps/api/src/routes/teams.ts | 164 +++++++ tests/integration/teams.test.ts | 379 ++++++++++++++++ tests/integration/test-server.ts | 26 +- 5 files changed, 893 insertions(+), 206 deletions(-) create mode 100644 apps/api/src/routes/teams.ts create mode 100644 tests/integration/teams.test.ts diff --git a/apps/api/drizzle/0005_add_teams_table.sql b/apps/api/drizzle/0005_add_teams_table.sql index e403f318..4485c8ff 100644 --- a/apps/api/drizzle/0005_add_teams_table.sql +++ b/apps/api/drizzle/0005_add_teams_table.sql @@ -4,18 +4,19 @@ CREATE TABLE IF NOT EXISTS `teams` ( `name` text NOT NULL, `created_at` integer NOT NULL DEFAULT (unixepoch()) ); +--> statement-breakpoint CREATE UNIQUE INDEX IF NOT EXISTS `teams_name_unique` ON `teams` (`name`); - +--> statement-breakpoint -- Seed Default team with a known UUID INSERT OR IGNORE INTO `teams` (`id`, `name`, `created_at`) VALUES ('default-team-00000000', 'Default', unixepoch()); - +--> statement-breakpoint -- Migrate existing users: for each distinct team value, create a team if it doesn't exist INSERT OR IGNORE INTO `teams` (`id`, `name`, `created_at`) SELECT lower(hex(randomblob(16))), `team`, unixepoch() FROM `users` WHERE `team` != 'Default' GROUP BY `team`; - +--> statement-breakpoint -- Update users to reference team IDs instead of team names UPDATE `users` SET `team` = ( SELECT `id` FROM `teams` WHERE `teams`.`name` = `users`.`team` diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index 578f4076..9c843770 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -1,10 +1,9 @@ -import { randomBytes, scrypt, timingSafeEqual, createHash } from "node:crypto"; -import { randomUUID } from "node:crypto"; +import { createHash, randomBytes, randomUUID, scrypt, timingSafeEqual } from "node:crypto"; import { promisify } from "node:util"; -import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; import { eq } from "drizzle-orm"; -import { db, schema } from "../db/index.js"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { env } from "../config.js"; +import { db, schema } from "../db/index.js"; const scryptAsync = promisify(scrypt); @@ -16,6 +15,8 @@ export interface AuthUser { role: "admin" | "user"; } +const MAX_USERS = 5; + // ── Password hashing ────────────────────────────────────────────── const SALT_LENGTH = 32; @@ -27,10 +28,7 @@ export async function hashPassword(password: string): Promise { return `${salt}:${derived.toString("hex")}`; } -export async function verifyPassword( - password: string, - stored: string, -): Promise { +export async function verifyPassword(password: string, stored: string): Promise { const [salt, hash] = stored.split(":"); if (!salt || !hash) return false; const derived = (await scryptAsync(password, salt, KEY_LENGTH)) as Buffer; @@ -47,7 +45,8 @@ export function computeKeyPrefix(rawKey: string): string { return createHash("sha256").update(rawKey).digest("hex").slice(0, 16); } -const PASSWORD_RULES = "Password must be at least 8 characters with uppercase, lowercase, and a number"; +const PASSWORD_RULES = + "Password must be at least 8 characters with uppercase, lowercase, and a number"; function validatePasswordStrength(password: string): string | null { if (password.length < 8) return PASSWORD_RULES; @@ -61,7 +60,7 @@ function validateUsername(username: string): string | null { if (username.length < 3 || username.length > 50) { return "Username must be between 3 and 50 characters"; } - if (!/^[a-zA-Z0-9_.\-]+$/.test(username)) { + if (!/^[a-zA-Z0-9_.-]+$/.test(username)) { return "Username can only contain letters, numbers, dots, hyphens, and underscores"; } return null; @@ -122,58 +121,81 @@ export async function ensureDefaultAdmin(): Promise { }) .run(); - console.log(`Default admin user '${env.DEFAULT_USERNAME}' created — password change required on first login`); + console.log( + `Default admin user '${env.DEFAULT_USERNAME}' created — password change required on first login`, + ); +} + +// ── Login attempt limit ────────────────────────────────────────── + +const DEFAULT_LOGIN_ATTEMPT_LIMIT = 5; + +function getLoginAttemptLimit(): number { + const row = db + .select() + .from(schema.settings) + .where(eq(schema.settings.key, "loginAttemptLimit")) + .get(); + if (row) { + const parsed = parseInt(row.value, 10); + if (!Number.isNaN(parsed) && parsed > 0) return parsed; + } + return DEFAULT_LOGIN_ATTEMPT_LIMIT; } // ── Auth routes ──────────────────────────────────────────────────── export async function authRoutes(app: FastifyInstance): Promise { // POST /api/auth/login - app.post("/api/auth/login", { config: { rateLimit: { max: 5, timeWindow: "1 minute" } } }, async (request: FastifyRequest, reply: FastifyReply) => { - const body = request.body as { username?: string; password?: string } | null; + app.post( + "/api/auth/login", + { config: { rateLimit: { max: getLoginAttemptLimit, timeWindow: "1 minute" } } }, + async (request: FastifyRequest, reply: FastifyReply) => { + const body = request.body as { username?: string; password?: string } | null; - if (!body?.username || !body?.password) { - return reply.status(400).send({ error: "Username and password are required" }); - } + if (!body?.username || !body?.password) { + return reply.status(400).send({ error: "Username and password are required" }); + } - const user = db - .select() - .from(schema.users) - .where(eq(schema.users.username, body.username)) - .get(); + const user = db + .select() + .from(schema.users) + .where(eq(schema.users.username, body.username)) + .get(); - if (!user) { - return reply.status(401).send({ error: "Invalid credentials" }); - } + if (!user) { + return reply.status(401).send({ error: "Invalid credentials" }); + } - const valid = await verifyPassword(body.password, user.passwordHash); - if (!valid) { - return reply.status(401).send({ error: "Invalid credentials" }); - } + const valid = await verifyPassword(body.password, user.passwordHash); + if (!valid) { + return reply.status(401).send({ error: "Invalid credentials" }); + } - // Create session - const token = createSessionToken(); - const expiresAt = new Date(Date.now() + SESSION_DURATION_MS); + // Create session + const token = createSessionToken(); + const expiresAt = new Date(Date.now() + SESSION_DURATION_MS); - db.insert(schema.sessions) - .values({ - id: token, - userId: user.id, - expiresAt, - }) - .run(); + db.insert(schema.sessions) + .values({ + id: token, + userId: user.id, + expiresAt, + }) + .run(); - return reply.send({ - token, - user: { - id: user.id, - username: user.username, - role: user.role, - mustChangePassword: user.mustChangePassword, - }, - expiresAt: expiresAt.toISOString(), - }); - }); + return reply.send({ + token, + user: { + id: user.id, + username: user.username, + role: user.role, + mustChangePassword: user.mustChangePassword, + }, + expiresAt: expiresAt.toISOString(), + }); + }, + ); // POST /api/auth/logout app.post("/api/auth/logout", async (request: FastifyRequest, reply: FastifyReply) => { @@ -191,11 +213,7 @@ export async function authRoutes(app: FastifyInstance): Promise { return reply.status(401).send({ error: "No session token provided" }); } - const session = db - .select() - .from(schema.sessions) - .where(eq(schema.sessions.id, token)) - .get(); + const session = db.select().from(schema.sessions).where(eq(schema.sessions.id, token)).get(); if (!session || session.expiresAt < new Date()) { // Clean up expired session if it exists @@ -205,11 +223,7 @@ export async function authRoutes(app: FastifyInstance): Promise { return reply.status(401).send({ error: "Session expired or invalid" }); } - const user = db - .select() - .from(schema.users) - .where(eq(schema.users.id, session.userId)) - .get(); + const user = db.select().from(schema.users).where(eq(schema.users.id, session.userId)).get(); if (!user) { return reply.status(401).send({ error: "User not found" }); @@ -251,11 +265,7 @@ export async function authRoutes(app: FastifyInstance): Promise { }); } - const user = db - .select() - .from(schema.users) - .where(eq(schema.users.id, authUser.id)) - .get(); + const user = db.select().from(schema.users).where(eq(schema.users.id, authUser.id)).get(); if (!user) { return reply.status(404).send({ error: "User not found", code: "NOT_FOUND" }); @@ -263,7 +273,9 @@ export async function authRoutes(app: FastifyInstance): Promise { const valid = await verifyPassword(body.currentPassword, user.passwordHash); if (!valid) { - return reply.status(401).send({ error: "Current password is incorrect", code: "INVALID_PASSWORD" }); + return reply + .status(401) + .send({ error: "Current password is incorrect", code: "INVALID_PASSWORD" }); } const newHash = await hashPassword(body.newPassword); @@ -275,7 +287,11 @@ export async function authRoutes(app: FastifyInstance): Promise { // Invalidate all other sessions for this user const currentToken = extractToken(request); - const allSessions = db.select().from(schema.sessions).where(eq(schema.sessions.userId, authUser.id)).all(); + const allSessions = db + .select() + .from(schema.sessions) + .where(eq(schema.sessions.userId, authUser.id)) + .all(); for (const s of allSessions) { if (s.id !== currentToken) { db.delete(schema.sessions).where(eq(schema.sessions.id, s.id)).run(); @@ -298,6 +314,7 @@ export async function authRoutes(app: FastifyInstance): Promise { id: schema.users.id, username: schema.users.username, role: schema.users.role, + team: schema.users.team, createdAt: schema.users.createdAt, }) .from(schema.users) @@ -308,6 +325,7 @@ export async function authRoutes(app: FastifyInstance): Promise { ...u, createdAt: u.createdAt.toISOString(), })), + maxUsers: MAX_USERS, }); }); @@ -347,6 +365,36 @@ export async function authRoutes(app: FastifyInstance): Promise { const role = body.role === "admin" ? "admin" : "user"; + // Look up Default team ID + const defaultTeam = db + .select() + .from(schema.teams) + .where(eq(schema.teams.name, "Default")) + .get(); + const teamId = (body as { team?: string }).team || defaultTeam?.id || "default-team-00000000"; + + // If a specific team was provided, validate it exists + if ((body as { team?: string }).team) { + const teamExists = db + .select() + .from(schema.teams) + .where(eq(schema.teams.id, (body as { team?: string }).team!)) + .get(); + if (!teamExists) + return reply.status(400).send({ error: "Team not found", code: "VALIDATION_ERROR" }); + } + + const team = teamId; + + // Check user limit + const userCount = db.select().from(schema.users).all().length; + if (userCount >= MAX_USERS) { + return reply.status(403).send({ + error: `User limit reached (${MAX_USERS} max)`, + code: "USER_LIMIT_REACHED", + }); + } + // Check for duplicate username const existing = db .select() @@ -370,6 +418,7 @@ export async function authRoutes(app: FastifyInstance): Promise { username: body.username, passwordHash, role, + team, mustChangePassword: true, }) .run(); @@ -378,16 +427,111 @@ export async function authRoutes(app: FastifyInstance): Promise { id, username: body.username, role, + team, }); }); + // PUT /api/auth/users/:id (admin only — update role/team) + app.put( + "/api/auth/users/:id", + async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { + const admin = requireAdmin(request, reply); + if (!admin) return; + + const { id } = request.params; + const body = request.body as { role?: string; team?: string } | null; + + const user = db.select().from(schema.users).where(eq(schema.users.id, id)).get(); + + if (!user) { + return reply.status(404).send({ error: "User not found", code: "NOT_FOUND" }); + } + + const updates: { role?: "admin" | "user"; team?: string; updatedAt: Date } = { + updatedAt: new Date(), + }; + + if (body?.role === "admin" || body?.role === "user") { + // Prevent removing your own admin role + if (id === admin.id && body.role !== "admin") { + return reply.status(400).send({ + error: "Cannot remove your own admin role", + code: "SELF_DEMOTE", + }); + } + updates.role = body.role; + } + + if (typeof body?.team === "string" && body.team.trim()) { + const teamExists = db + .select() + .from(schema.teams) + .where(eq(schema.teams.id, body.team.trim())) + .get(); + if (!teamExists) { + return reply.status(400).send({ error: "Team not found", code: "VALIDATION_ERROR" }); + } + updates.team = body.team.trim(); + } + + db.update(schema.users).set(updates).where(eq(schema.users.id, id)).run(); + + return reply.send({ ok: true }); + }, + ); + + // POST /api/auth/users/:id/reset-password (admin only) + app.post( + "/api/auth/users/:id/reset-password", + async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { + const admin = requireAdmin(request, reply); + if (!admin) return; + + const { id } = request.params; + const body = request.body as { newPassword?: string } | null; + + if (!body?.newPassword) { + return reply.status(400).send({ + error: "New password is required", + code: "VALIDATION_ERROR", + }); + } + + const pwError = validatePasswordStrength(body.newPassword); + if (pwError) { + return reply.status(400).send({ + error: pwError, + code: "VALIDATION_ERROR", + }); + } + + const user = db.select().from(schema.users).where(eq(schema.users.id, id)).get(); + + if (!user) { + return reply.status(404).send({ error: "User not found", code: "NOT_FOUND" }); + } + + const newHash = await hashPassword(body.newPassword); + + db.update(schema.users) + .set({ passwordHash: newHash, mustChangePassword: true, updatedAt: new Date() }) + .where(eq(schema.users.id, id)) + .run(); + + // Invalidate all sessions for this user + db.delete(schema.sessions).where(eq(schema.sessions.userId, id)).run(); + + // Revoke all API keys + db.delete(schema.apiKeys).where(eq(schema.apiKeys.userId, id)).run(); + + return reply.send({ ok: true }); + }, + ); + // DELETE /api/auth/users/:id (admin only, can't delete self) app.delete( "/api/auth/users/:id", - async ( - request: FastifyRequest<{ Params: { id: string } }>, - reply: FastifyReply, - ) => { + async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { const admin = requireAdmin(request, reply); if (!admin) return; @@ -400,25 +544,17 @@ export async function authRoutes(app: FastifyInstance): Promise { }); } - const user = db - .select() - .from(schema.users) - .where(eq(schema.users.id, id)) - .get(); + const user = db.select().from(schema.users).where(eq(schema.users.id, id)).get(); if (!user) { return reply.status(404).send({ error: "User not found", code: "NOT_FOUND" }); } // Delete associated sessions - db.delete(schema.sessions) - .where(eq(schema.sessions.userId, id)) - .run(); + db.delete(schema.sessions).where(eq(schema.sessions.userId, id)).run(); // Delete the user (cascades to api_keys via FK) - db.delete(schema.users) - .where(eq(schema.users.id, id)) - .run(); + db.delete(schema.users).where(eq(schema.users.id, id)).run(); return reply.send({ ok: true }); }, @@ -438,7 +574,13 @@ function extractToken(request: FastifyRequest): string | null { // ── Auth middleware ──────────────────────────────────────────────── -const PUBLIC_PATHS = ["/api/v1/health", "/api/v1/config/", "/api/auth/", "/api/v1/download/", "/api/v1/jobs/"]; +const PUBLIC_PATHS = [ + "/api/v1/health", + "/api/v1/config/", + "/api/auth/", + "/api/v1/download/", + "/api/v1/jobs/", +]; function isPublicRoute(url: string): boolean { // Non-API routes are public (SPA static files — auth is handled client-side) @@ -448,122 +590,121 @@ function isPublicRoute(url: string): boolean { } export async function authMiddleware(app: FastifyInstance): Promise { - app.addHook( - "preHandler", - async (request: FastifyRequest, reply: FastifyReply) => { - // When auth is disabled, attach the first admin user so requireAuth/requireAdmin pass - if (!env.AUTH_ENABLED) { - const adminUser = db + app.addHook("preHandler", async (request: FastifyRequest, reply: FastifyReply) => { + // When auth is disabled, attach the first admin user so requireAuth/requireAdmin pass + if (!env.AUTH_ENABLED) { + const adminUser = db.select().from(schema.users).where(eq(schema.users.role, "admin")).get(); + if (adminUser) { + (request as FastifyRequest & { user?: AuthUser }).user = { + id: adminUser.id, + username: adminUser.username, + role: "admin", + }; + } + return; + } + + const isPublic = isPublicRoute(request.url); + + const token = extractToken(request); + if (!token) { + // Public routes don't require a token + if (isPublic) return; + return reply.status(401).send({ error: "Authentication required" }); + } + + const session = db.select().from(schema.sessions).where(eq(schema.sessions.id, token)).get(); + + if (!session || session.expiresAt < new Date()) { + if (session) { + db.delete(schema.sessions).where(eq(schema.sessions.id, token)).run(); + } + + // Try API key authentication if token has si_ prefix + if (token.startsWith("si_")) { + const prefix = computeKeyPrefix(token); + // Lookup by prefix (O(1) instead of scanning all keys) + const candidates = db .select() - .from(schema.users) - .where(eq(schema.users.role, "admin")) - .get(); - if (adminUser) { - (request as FastifyRequest & { user?: AuthUser }).user = { - id: adminUser.id, - username: adminUser.username, - role: "admin", - }; - } - return; - } - - const isPublic = isPublicRoute(request.url); - - const token = extractToken(request); - if (!token) { - // Public routes don't require a token - if (isPublic) return; - return reply.status(401).send({ error: "Authentication required" }); - } - - const session = db - .select() - .from(schema.sessions) - .where(eq(schema.sessions.id, token)) - .get(); - - if (!session || session.expiresAt < new Date()) { - if (session) { - db.delete(schema.sessions) - .where(eq(schema.sessions.id, token)) - .run(); - } - - // Try API key authentication if token has si_ prefix - if (token.startsWith("si_")) { - const prefix = computeKeyPrefix(token); - // Lookup by prefix (O(1) instead of scanning all keys) - const candidates = db.select().from(schema.apiKeys) - .where(eq(schema.apiKeys.keyPrefix, prefix)) - .all(); - // Fall back to full scan for legacy keys without a prefix - const keysToCheck = candidates.length > 0 + .from(schema.apiKeys) + .where(eq(schema.apiKeys.keyPrefix, prefix)) + .all(); + // Fall back to full scan for legacy keys without a prefix + const keysToCheck = + candidates.length > 0 ? candidates - : db.select().from(schema.apiKeys).all().filter(k => !k.keyPrefix); - for (const key of keysToCheck) { - const matches = await verifyPassword(token, key.keyHash); - if (matches) { - // Backfill prefix for legacy keys - if (!key.keyPrefix) { - db.update(schema.apiKeys) - .set({ keyPrefix: prefix, lastUsedAt: new Date() }) - .where(eq(schema.apiKeys.id, key.id)) - .run(); - } else { - db.update(schema.apiKeys) - .set({ lastUsedAt: new Date() }) - .where(eq(schema.apiKeys.id, key.id)) - .run(); - } - // Load the user - const apiUser = db.select().from(schema.users).where(eq(schema.users.id, key.userId)).get(); - if (apiUser) { - (request as FastifyRequest & { user?: AuthUser }).user = { - id: apiUser.id, - username: apiUser.username, - role: apiUser.role as "admin" | "user", - }; - return; - } + : db + .select() + .from(schema.apiKeys) + .all() + .filter((k) => !k.keyPrefix); + for (const key of keysToCheck) { + const matches = await verifyPassword(token, key.keyHash); + if (matches) { + // Backfill prefix for legacy keys + if (!key.keyPrefix) { + db.update(schema.apiKeys) + .set({ keyPrefix: prefix, lastUsedAt: new Date() }) + .where(eq(schema.apiKeys.id, key.id)) + .run(); + } else { + db.update(schema.apiKeys) + .set({ lastUsedAt: new Date() }) + .where(eq(schema.apiKeys.id, key.id)) + .run(); + } + // Load the user + const apiUser = db + .select() + .from(schema.users) + .where(eq(schema.users.id, key.userId)) + .get(); + if (apiUser) { + (request as FastifyRequest & { user?: AuthUser }).user = { + id: apiUser.id, + username: apiUser.username, + role: apiUser.role as "admin" | "user", + }; + return; } } } - - // Public routes can proceed without a valid session - if (isPublic) return; - return reply.status(401).send({ error: "Session expired or invalid" }); } - const user = db - .select() - .from(schema.users) - .where(eq(schema.users.id, session.userId)) - .get(); + // Public routes can proceed without a valid session + if (isPublic) return; + return reply.status(401).send({ error: "Session expired or invalid" }); + } - if (!user) { - if (isPublic) return; - return reply.status(401).send({ error: "User not found" }); + const user = db.select().from(schema.users).where(eq(schema.users.id, session.userId)).get(); + + if (!user) { + if (isPublic) return; + return reply.status(401).send({ error: "User not found" }); + } + + // Attach user info to request for downstream handlers + // (always populate when a valid session exists, even on public routes) + (request as FastifyRequest & { user?: AuthUser }).user = { + id: user.id, + username: user.username, + role: user.role as "admin" | "user", + }; + + // Enforce mustChangePassword — block non-auth API calls + if (user.mustChangePassword) { + const allowed = [ + "/api/auth/change-password", + "/api/auth/logout", + "/api/auth/session", + "/api/v1/config/", + ]; + if (!allowed.some((p) => request.url.startsWith(p)) && request.url.startsWith("/api/")) { + return reply.status(403).send({ + error: "Password change required", + code: "MUST_CHANGE_PASSWORD", + }); } - - // Attach user info to request for downstream handlers - // (always populate when a valid session exists, even on public routes) - (request as FastifyRequest & { user?: AuthUser }).user = { - id: user.id, - username: user.username, - role: user.role as "admin" | "user", - }; - - // Enforce mustChangePassword — block non-auth API calls - if (user.mustChangePassword) { - const allowed = ["/api/auth/change-password", "/api/auth/logout", "/api/auth/session", "/api/v1/config/"]; - if (!allowed.some((p) => request.url.startsWith(p)) && request.url.startsWith("/api/")) { - return reply.status(403).send({ - error: "Password change required", - code: "MUST_CHANGE_PASSWORD", - }); - } - } - }, - ); + } + }); } diff --git a/apps/api/src/routes/teams.ts b/apps/api/src/routes/teams.ts new file mode 100644 index 00000000..a471ff80 --- /dev/null +++ b/apps/api/src/routes/teams.ts @@ -0,0 +1,164 @@ +/** + * Team management routes (CRUD). + * + * GET /api/v1/teams — List all teams with member count + * POST /api/v1/teams — Create team (admin only) + * PUT /api/v1/teams/:id — Rename team (admin only) + * DELETE /api/v1/teams/:id — Delete team (admin only) + */ + +import { randomUUID } from "node:crypto"; +import { eq, sql } from "drizzle-orm"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { db, schema } from "../db/index.js"; +import { requireAdmin, requireAuth } from "../plugins/auth.js"; + +function validateTeamName(name: unknown): string | null { + if (typeof name !== "string") return "Team name is required"; + const trimmed = name.trim(); + if (trimmed.length === 0) return "Team name is required"; + if (trimmed.length > 50) return "Team name must be 50 characters or fewer"; + return null; +} + +export async function teamsRoutes(app: FastifyInstance): Promise { + // GET /api/v1/teams — List all teams with member count + app.get("/api/v1/teams", async (request: FastifyRequest, reply: FastifyReply) => { + const user = requireAuth(request, reply); + if (!user) return; + + const teams = db + .select({ + id: schema.teams.id, + name: schema.teams.name, + memberCount: sql`(SELECT COUNT(*) FROM users WHERE users.team = ${schema.teams.id})`, + createdAt: schema.teams.createdAt, + }) + .from(schema.teams) + .all(); + + return reply.send({ + teams: teams.map((t) => ({ + ...t, + createdAt: t.createdAt.toISOString(), + })), + }); + }); + + // POST /api/v1/teams — Create team (admin only) + app.post("/api/v1/teams", async (request: FastifyRequest, reply: FastifyReply) => { + const admin = requireAdmin(request, reply); + if (!admin) return; + + const body = request.body as { name?: string } | null; + + const nameError = validateTeamName(body?.name); + if (nameError) { + return reply.status(400).send({ error: nameError, code: "VALIDATION_ERROR" }); + } + + const trimmedName = (body!.name as string).trim(); + + // Check for duplicate name (case-insensitive) + const existing = db + .select() + .from(schema.teams) + .where(sql`LOWER(${schema.teams.name}) = LOWER(${trimmedName})`) + .get(); + + if (existing) { + return reply.status(409).send({ error: "Team name already exists", code: "CONFLICT" }); + } + + const id = randomUUID(); + + db.insert(schema.teams).values({ id, name: trimmedName }).run(); + + return reply.status(201).send({ id, name: trimmedName }); + }); + + // PUT /api/v1/teams/:id — Rename team (admin only) + app.put( + "/api/v1/teams/:id", + async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { + const admin = requireAdmin(request, reply); + if (!admin) return; + + const { id } = request.params; + const body = request.body as { name?: string } | null; + + const team = db.select().from(schema.teams).where(eq(schema.teams.id, id)).get(); + if (!team) { + return reply.status(404).send({ error: "Team not found", code: "NOT_FOUND" }); + } + + const nameError = validateTeamName(body?.name); + if (nameError) { + return reply.status(400).send({ error: nameError, code: "VALIDATION_ERROR" }); + } + + const trimmedName = (body!.name as string).trim(); + + // Check for duplicate name (case-insensitive), excluding current team + const duplicate = db + .select() + .from(schema.teams) + .where( + sql`LOWER(${schema.teams.name}) = LOWER(${trimmedName}) AND ${schema.teams.id} != ${id}`, + ) + .get(); + + if (duplicate) { + return reply.status(409).send({ error: "Team name already exists", code: "CONFLICT" }); + } + + db.update(schema.teams).set({ name: trimmedName }).where(eq(schema.teams.id, id)).run(); + + return reply.send({ ok: true }); + }, + ); + + // DELETE /api/v1/teams/:id — Delete team (admin only) + app.delete( + "/api/v1/teams/:id", + async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { + const admin = requireAdmin(request, reply); + if (!admin) return; + + const { id } = request.params; + + const team = db.select().from(schema.teams).where(eq(schema.teams.id, id)).get(); + if (!team) { + return reply.status(404).send({ error: "Team not found", code: "NOT_FOUND" }); + } + + // Cannot delete the Default team + if (team.name === "Default") { + return reply.status(400).send({ + error: "Cannot delete the Default team", + code: "VALIDATION_ERROR", + }); + } + + // Cannot delete a team that has members + const memberCount = db + .select({ count: sql`COUNT(*)` }) + .from(schema.users) + .where(eq(schema.users.team, id)) + .get(); + + if (memberCount && memberCount.count > 0) { + return reply.status(400).send({ + error: "Cannot delete a team that has members", + code: "VALIDATION_ERROR", + }); + } + + db.delete(schema.teams).where(eq(schema.teams.id, id)).run(); + + return reply.send({ ok: true }); + }, + ); + + app.log.info("Teams routes registered"); +} diff --git a/tests/integration/teams.test.ts b/tests/integration/teams.test.ts new file mode 100644 index 00000000..c6fea7ba --- /dev/null +++ b/tests/integration/teams.test.ts @@ -0,0 +1,379 @@ +/** + * Integration tests for the Teams API routes. + * + * Uses the same test server infrastructure as api.test.ts — a real Fastify + * app backed by an isolated temp SQLite DB, exercised via `app.inject()`. + */ + +import { randomUUID } from "node:crypto"; +import { eq, sql } from "drizzle-orm"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { db, schema } from "../../apps/api/src/db/index.js"; +import { buildTestApp, 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: seed a Default team if not present +function ensureDefaultTeam(): string { + const existing = db.select().from(schema.teams).where(eq(schema.teams.name, "Default")).get(); + if (existing) return existing.id; + const id = randomUUID(); + db.insert(schema.teams).values({ id, name: "Default" }).run(); + return id; +} + +// Helper: clean all teams except Default, and recreate Default if missing +function resetTeams(): string { + // Delete non-Default teams + db.delete(schema.teams).where(sql`${schema.teams.name} != 'Default'`).run(); + return ensureDefaultTeam(); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// GET /api/v1/teams +// ═══════════════════════════════════════════════════════════════════════════ +describe("GET /api/v1/teams", () => { + beforeEach(() => resetTeams()); + + it("returns teams with member counts", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/v1/teams", + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.teams).toBeDefined(); + expect(Array.isArray(body.teams)).toBe(true); + // Default team should exist + const defaultTeam = body.teams.find((t: { name: string }) => t.name === "Default"); + expect(defaultTeam).toBeDefined(); + expect(typeof defaultTeam.memberCount).toBe("number"); + expect(typeof defaultTeam.createdAt).toBe("string"); + expect(defaultTeam.id).toBeDefined(); + }); + + it("requires authentication", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/v1/teams", + }); + expect(res.statusCode).toBe(401); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// POST /api/v1/teams +// ═══════════════════════════════════════════════════════════════════════════ +describe("POST /api/v1/teams", () => { + beforeEach(() => resetTeams()); + + it("creates a team", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/teams", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { name: "Engineering" }, + }); + expect(res.statusCode).toBe(201); + const body = JSON.parse(res.body); + expect(body.id).toBeDefined(); + expect(body.name).toBe("Engineering"); + }); + + it("requires admin", async () => { + // Register a non-admin user + const regRes = await app.inject({ + method: "POST", + url: "/api/auth/register", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { username: "teamuser1", password: "TestPass1", role: "user" }, + }); + expect(regRes.statusCode).toBe(201); + + // Login as non-admin + // First clear mustChangePassword + const userId = JSON.parse(regRes.body).id; + db.update(schema.users) + .set({ mustChangePassword: false }) + .where(eq(schema.users.id, userId)) + .run(); + + const loginRes = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: "teamuser1", password: "TestPass1" }, + }); + const userToken = JSON.parse(loginRes.body).token; + + const res = await app.inject({ + method: "POST", + url: "/api/v1/teams", + headers: { authorization: `Bearer ${userToken}` }, + payload: { name: "ShouldFail" }, + }); + expect(res.statusCode).toBe(403); + + // Cleanup + db.delete(schema.users).where(eq(schema.users.id, userId)).run(); + }); + + it("rejects duplicate names (case-insensitive)", async () => { + // First create + const res1 = await app.inject({ + method: "POST", + url: "/api/v1/teams", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { name: "Marketing" }, + }); + expect(res1.statusCode).toBe(201); + + // Duplicate with different case + const res2 = await app.inject({ + method: "POST", + url: "/api/v1/teams", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { name: "MARKETING" }, + }); + expect(res2.statusCode).toBe(409); + expect(JSON.parse(res2.body).code).toBe("CONFLICT"); + }); + + it("rejects empty name", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/teams", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { name: "" }, + }); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).code).toBe("VALIDATION_ERROR"); + }); + + it("rejects whitespace-only name", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/teams", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { name: " " }, + }); + expect(res.statusCode).toBe(400); + }); + + it("rejects name longer than 50 characters", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/teams", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { name: "A".repeat(51) }, + }); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).code).toBe("VALIDATION_ERROR"); + }); + + it("trims whitespace from name", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/teams", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { name: " Sales " }, + }); + expect(res.statusCode).toBe(201); + expect(JSON.parse(res.body).name).toBe("Sales"); + }); + + it("rejects missing name field", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/teams", + headers: { authorization: `Bearer ${adminToken}` }, + payload: {}, + }); + expect(res.statusCode).toBe(400); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// PUT /api/v1/teams/:id +// ═══════════════════════════════════════════════════════════════════════════ +describe("PUT /api/v1/teams/:id", () => { + let teamId: string; + + beforeEach(() => { + resetTeams(); + // Create a team to rename + teamId = randomUUID(); + db.insert(schema.teams).values({ id: teamId, name: "OldName" }).run(); + }); + + it("renames a team", async () => { + const res = await app.inject({ + method: "PUT", + url: `/api/v1/teams/${teamId}`, + headers: { authorization: `Bearer ${adminToken}` }, + payload: { name: "NewName" }, + }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).ok).toBe(true); + + // Verify + const team = db.select().from(schema.teams).where(eq(schema.teams.id, teamId)).get(); + expect(team?.name).toBe("NewName"); + }); + + it("requires admin", async () => { + const res = await app.inject({ + method: "PUT", + url: `/api/v1/teams/${teamId}`, + payload: { name: "Hacked" }, + }); + expect(res.statusCode).toBe(401); + }); + + it("rejects duplicate names", async () => { + // Try to rename to "Default" which already exists + const res = await app.inject({ + method: "PUT", + url: `/api/v1/teams/${teamId}`, + headers: { authorization: `Bearer ${adminToken}` }, + payload: { name: "Default" }, + }); + expect(res.statusCode).toBe(409); + expect(JSON.parse(res.body).code).toBe("CONFLICT"); + }); + + it("allows renaming to same name (case-exact)", async () => { + const res = await app.inject({ + method: "PUT", + url: `/api/v1/teams/${teamId}`, + headers: { authorization: `Bearer ${adminToken}` }, + payload: { name: "OldName" }, + }); + // Should succeed — same team, same name + expect(res.statusCode).toBe(200); + }); + + it("returns 404 for non-existent team", async () => { + const res = await app.inject({ + method: "PUT", + url: `/api/v1/teams/${randomUUID()}`, + headers: { authorization: `Bearer ${adminToken}` }, + payload: { name: "Whatever" }, + }); + expect(res.statusCode).toBe(404); + expect(JSON.parse(res.body).code).toBe("NOT_FOUND"); + }); + + it("rejects empty name", async () => { + const res = await app.inject({ + method: "PUT", + url: `/api/v1/teams/${teamId}`, + headers: { authorization: `Bearer ${adminToken}` }, + payload: { name: "" }, + }); + expect(res.statusCode).toBe(400); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// DELETE /api/v1/teams/:id +// ═══════════════════════════════════════════════════════════════════════════ +describe("DELETE /api/v1/teams/:id", () => { + let defaultTeamId: string; + + beforeEach(() => { + defaultTeamId = resetTeams(); + }); + + it("deletes an empty team", async () => { + const teamId = randomUUID(); + db.insert(schema.teams).values({ id: teamId, name: "ToDelete" }).run(); + + const res = await app.inject({ + method: "DELETE", + url: `/api/v1/teams/${teamId}`, + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).ok).toBe(true); + + // Verify it's gone + const team = db.select().from(schema.teams).where(eq(schema.teams.id, teamId)).get(); + expect(team).toBeUndefined(); + }); + + it("rejects deleting a team with members", async () => { + const teamId = randomUUID(); + db.insert(schema.teams).values({ id: teamId, name: "HasMembers" }).run(); + + // Assign a user to this team + const userId = randomUUID(); + db.insert(schema.users) + .values({ + id: userId, + username: "memberuser", + passwordHash: "dummy:hash", + role: "user", + team: teamId, + }) + .run(); + + const res = await app.inject({ + method: "DELETE", + url: `/api/v1/teams/${teamId}`, + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error).toMatch(/members/i); + + // Cleanup + db.delete(schema.users).where(eq(schema.users.id, userId)).run(); + db.delete(schema.teams).where(eq(schema.teams.id, teamId)).run(); + }); + + it("rejects deleting the Default team", async () => { + const res = await app.inject({ + method: "DELETE", + url: `/api/v1/teams/${defaultTeamId}`, + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error).toMatch(/default/i); + }); + + it("requires admin", async () => { + const teamId = randomUUID(); + db.insert(schema.teams).values({ id: teamId, name: "NoAuth" }).run(); + + const res = await app.inject({ + method: "DELETE", + url: `/api/v1/teams/${teamId}`, + }); + expect(res.statusCode).toBe(401); + + // Cleanup + db.delete(schema.teams).where(eq(schema.teams.id, teamId)).run(); + }); + + it("returns 404 for non-existent team", async () => { + const res = await app.inject({ + method: "DELETE", + url: `/api/v1/teams/${randomUUID()}`, + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(404); + expect(JSON.parse(res.body).code).toBe("NOT_FOUND"); + }); +}); diff --git a/tests/integration/test-server.ts b/tests/integration/test-server.ts index fbc1da8f..276cdc50 100644 --- a/tests/integration/test-server.ts +++ b/tests/integration/test-server.ts @@ -19,25 +19,26 @@ import { dirname } from "node:path"; mkdirSync(dirname(process.env.DB_PATH!), { recursive: true }); mkdirSync(process.env.WORKSPACE_PATH!, { recursive: true }); +import cors from "@fastify/cors"; +import { APP_VERSION } from "@stirling-image/shared"; +import { eq } from "drizzle-orm"; // --------------------------------------------------------------------------- // 2. Import app modules. config.ts already captured our env vars. // --------------------------------------------------------------------------- import Fastify from "fastify"; -import cors from "@fastify/cors"; -import { eq } from "drizzle-orm"; -import { runMigrations } from "../../apps/api/src/db/migrate.js"; -import { ensureDefaultAdmin, authRoutes, authMiddleware } from "../../apps/api/src/plugins/auth.js"; +import { env } from "../../apps/api/src/config.js"; import { db, schema } from "../../apps/api/src/db/index.js"; +import { runMigrations } from "../../apps/api/src/db/migrate.js"; +import { authMiddleware, authRoutes, ensureDefaultAdmin } from "../../apps/api/src/plugins/auth.js"; import { registerUpload } from "../../apps/api/src/plugins/upload.js"; -import { fileRoutes } from "../../apps/api/src/routes/files.js"; -import { registerToolRoutes } from "../../apps/api/src/routes/tools/index.js"; +import { apiKeyRoutes } from "../../apps/api/src/routes/api-keys.js"; import { registerBatchRoutes } from "../../apps/api/src/routes/batch.js"; +import { fileRoutes } from "../../apps/api/src/routes/files.js"; import { registerPipelineRoutes } from "../../apps/api/src/routes/pipeline.js"; import { registerProgressRoutes } from "../../apps/api/src/routes/progress.js"; -import { apiKeyRoutes } from "../../apps/api/src/routes/api-keys.js"; import { settingsRoutes } from "../../apps/api/src/routes/settings.js"; -import { env } from "../../apps/api/src/config.js"; -import { APP_VERSION } from "@stirling-image/shared"; +import { teamsRoutes } from "../../apps/api/src/routes/teams.js"; +import { registerToolRoutes } from "../../apps/api/src/routes/tools/index.js"; // Run migrations to create all tables in the temp DB runMigrations(); @@ -98,6 +99,9 @@ export async function buildTestApp(): Promise { // Settings routes await settingsRoutes(app); + // Teams routes + await teamsRoutes(app); + // Health check app.get("/api/v1/health", async () => ({ status: "healthy", @@ -133,9 +137,7 @@ export async function buildTestApp(): Promise { // --------------------------------------------------------------------------- /** Log in as the default admin and return the session token. */ -export async function loginAsAdmin( - app: ReturnType, -): Promise { +export async function loginAsAdmin(app: ReturnType): Promise { const res = await app.inject({ method: "POST", url: "/api/auth/login",