mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(api): add teams CRUD routes and update auth team references
This commit is contained in:
@@ -4,18 +4,19 @@ CREATE TABLE IF NOT EXISTS `teams` (
|
|||||||
`name` text NOT NULL,
|
`name` text NOT NULL,
|
||||||
`created_at` integer NOT NULL DEFAULT (unixepoch())
|
`created_at` integer NOT NULL DEFAULT (unixepoch())
|
||||||
);
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS `teams_name_unique` ON `teams` (`name`);
|
CREATE UNIQUE INDEX IF NOT EXISTS `teams_name_unique` ON `teams` (`name`);
|
||||||
|
--> statement-breakpoint
|
||||||
-- Seed Default team with a known UUID
|
-- Seed Default team with a known UUID
|
||||||
INSERT OR IGNORE INTO `teams` (`id`, `name`, `created_at`) VALUES ('default-team-00000000', 'Default', unixepoch());
|
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
|
-- 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`)
|
INSERT OR IGNORE INTO `teams` (`id`, `name`, `created_at`)
|
||||||
SELECT lower(hex(randomblob(16))), `team`, unixepoch()
|
SELECT lower(hex(randomblob(16))), `team`, unixepoch()
|
||||||
FROM `users`
|
FROM `users`
|
||||||
WHERE `team` != 'Default'
|
WHERE `team` != 'Default'
|
||||||
GROUP BY `team`;
|
GROUP BY `team`;
|
||||||
|
--> statement-breakpoint
|
||||||
-- Update users to reference team IDs instead of team names
|
-- Update users to reference team IDs instead of team names
|
||||||
UPDATE `users` SET `team` = (
|
UPDATE `users` SET `team` = (
|
||||||
SELECT `id` FROM `teams` WHERE `teams`.`name` = `users`.`team`
|
SELECT `id` FROM `teams` WHERE `teams`.`name` = `users`.`team`
|
||||||
|
|||||||
+332
-191
@@ -1,10 +1,9 @@
|
|||||||
import { randomBytes, scrypt, timingSafeEqual, createHash } from "node:crypto";
|
import { createHash, randomBytes, randomUUID, scrypt, timingSafeEqual } from "node:crypto";
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
|
||||||
import { eq } from "drizzle-orm";
|
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 { env } from "../config.js";
|
||||||
|
import { db, schema } from "../db/index.js";
|
||||||
|
|
||||||
const scryptAsync = promisify(scrypt);
|
const scryptAsync = promisify(scrypt);
|
||||||
|
|
||||||
@@ -16,6 +15,8 @@ export interface AuthUser {
|
|||||||
role: "admin" | "user";
|
role: "admin" | "user";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_USERS = 5;
|
||||||
|
|
||||||
// ── Password hashing ──────────────────────────────────────────────
|
// ── Password hashing ──────────────────────────────────────────────
|
||||||
|
|
||||||
const SALT_LENGTH = 32;
|
const SALT_LENGTH = 32;
|
||||||
@@ -27,10 +28,7 @@ export async function hashPassword(password: string): Promise<string> {
|
|||||||
return `${salt}:${derived.toString("hex")}`;
|
return `${salt}:${derived.toString("hex")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyPassword(
|
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||||||
password: string,
|
|
||||||
stored: string,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const [salt, hash] = stored.split(":");
|
const [salt, hash] = stored.split(":");
|
||||||
if (!salt || !hash) return false;
|
if (!salt || !hash) return false;
|
||||||
const derived = (await scryptAsync(password, salt, KEY_LENGTH)) as Buffer;
|
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);
|
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 {
|
function validatePasswordStrength(password: string): string | null {
|
||||||
if (password.length < 8) return PASSWORD_RULES;
|
if (password.length < 8) return PASSWORD_RULES;
|
||||||
@@ -61,7 +60,7 @@ function validateUsername(username: string): string | null {
|
|||||||
if (username.length < 3 || username.length > 50) {
|
if (username.length < 3 || username.length > 50) {
|
||||||
return "Username must be between 3 and 50 characters";
|
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 "Username can only contain letters, numbers, dots, hyphens, and underscores";
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -122,58 +121,81 @@ export async function ensureDefaultAdmin(): Promise<void> {
|
|||||||
})
|
})
|
||||||
.run();
|
.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 ────────────────────────────────────────────────────
|
// ── Auth routes ────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||||
// POST /api/auth/login
|
// POST /api/auth/login
|
||||||
app.post("/api/auth/login", { config: { rateLimit: { max: 5, timeWindow: "1 minute" } } }, async (request: FastifyRequest, reply: FastifyReply) => {
|
app.post(
|
||||||
const body = request.body as { username?: string; password?: string } | null;
|
"/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) {
|
if (!body?.username || !body?.password) {
|
||||||
return reply.status(400).send({ error: "Username and password are required" });
|
return reply.status(400).send({ error: "Username and password are required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = db
|
const user = db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.users)
|
.from(schema.users)
|
||||||
.where(eq(schema.users.username, body.username))
|
.where(eq(schema.users.username, body.username))
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return reply.status(401).send({ error: "Invalid credentials" });
|
return reply.status(401).send({ error: "Invalid credentials" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = await verifyPassword(body.password, user.passwordHash);
|
const valid = await verifyPassword(body.password, user.passwordHash);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
return reply.status(401).send({ error: "Invalid credentials" });
|
return reply.status(401).send({ error: "Invalid credentials" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create session
|
// Create session
|
||||||
const token = createSessionToken();
|
const token = createSessionToken();
|
||||||
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
|
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
|
||||||
|
|
||||||
db.insert(schema.sessions)
|
db.insert(schema.sessions)
|
||||||
.values({
|
.values({
|
||||||
id: token,
|
id: token,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
return reply.send({
|
return reply.send({
|
||||||
token,
|
token,
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
mustChangePassword: user.mustChangePassword,
|
mustChangePassword: user.mustChangePassword,
|
||||||
},
|
},
|
||||||
expiresAt: expiresAt.toISOString(),
|
expiresAt: expiresAt.toISOString(),
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// POST /api/auth/logout
|
// POST /api/auth/logout
|
||||||
app.post("/api/auth/logout", async (request: FastifyRequest, reply: FastifyReply) => {
|
app.post("/api/auth/logout", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
@@ -191,11 +213,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.status(401).send({ error: "No session token provided" });
|
return reply.status(401).send({ error: "No session token provided" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = db
|
const session = db.select().from(schema.sessions).where(eq(schema.sessions.id, token)).get();
|
||||||
.select()
|
|
||||||
.from(schema.sessions)
|
|
||||||
.where(eq(schema.sessions.id, token))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!session || session.expiresAt < new Date()) {
|
if (!session || session.expiresAt < new Date()) {
|
||||||
// Clean up expired session if it exists
|
// Clean up expired session if it exists
|
||||||
@@ -205,11 +223,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.status(401).send({ error: "Session expired or invalid" });
|
return reply.status(401).send({ error: "Session expired or invalid" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = db
|
const user = db.select().from(schema.users).where(eq(schema.users.id, session.userId)).get();
|
||||||
.select()
|
|
||||||
.from(schema.users)
|
|
||||||
.where(eq(schema.users.id, session.userId))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return reply.status(401).send({ error: "User not found" });
|
return reply.status(401).send({ error: "User not found" });
|
||||||
@@ -251,11 +265,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = db
|
const user = db.select().from(schema.users).where(eq(schema.users.id, authUser.id)).get();
|
||||||
.select()
|
|
||||||
.from(schema.users)
|
|
||||||
.where(eq(schema.users.id, authUser.id))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return reply.status(404).send({ error: "User not found", code: "NOT_FOUND" });
|
return reply.status(404).send({ error: "User not found", code: "NOT_FOUND" });
|
||||||
@@ -263,7 +273,9 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
const valid = await verifyPassword(body.currentPassword, user.passwordHash);
|
const valid = await verifyPassword(body.currentPassword, user.passwordHash);
|
||||||
if (!valid) {
|
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);
|
const newHash = await hashPassword(body.newPassword);
|
||||||
@@ -275,7 +287,11 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
// Invalidate all other sessions for this user
|
// Invalidate all other sessions for this user
|
||||||
const currentToken = extractToken(request);
|
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) {
|
for (const s of allSessions) {
|
||||||
if (s.id !== currentToken) {
|
if (s.id !== currentToken) {
|
||||||
db.delete(schema.sessions).where(eq(schema.sessions.id, s.id)).run();
|
db.delete(schema.sessions).where(eq(schema.sessions.id, s.id)).run();
|
||||||
@@ -298,6 +314,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
id: schema.users.id,
|
id: schema.users.id,
|
||||||
username: schema.users.username,
|
username: schema.users.username,
|
||||||
role: schema.users.role,
|
role: schema.users.role,
|
||||||
|
team: schema.users.team,
|
||||||
createdAt: schema.users.createdAt,
|
createdAt: schema.users.createdAt,
|
||||||
})
|
})
|
||||||
.from(schema.users)
|
.from(schema.users)
|
||||||
@@ -308,6 +325,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
...u,
|
...u,
|
||||||
createdAt: u.createdAt.toISOString(),
|
createdAt: u.createdAt.toISOString(),
|
||||||
})),
|
})),
|
||||||
|
maxUsers: MAX_USERS,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -347,6 +365,36 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
const role = body.role === "admin" ? "admin" : "user";
|
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
|
// Check for duplicate username
|
||||||
const existing = db
|
const existing = db
|
||||||
.select()
|
.select()
|
||||||
@@ -370,6 +418,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
username: body.username,
|
username: body.username,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
role,
|
role,
|
||||||
|
team,
|
||||||
mustChangePassword: true,
|
mustChangePassword: true,
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
@@ -378,16 +427,111 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
id,
|
id,
|
||||||
username: body.username,
|
username: body.username,
|
||||||
role,
|
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)
|
// DELETE /api/auth/users/:id (admin only, can't delete self)
|
||||||
app.delete(
|
app.delete(
|
||||||
"/api/auth/users/:id",
|
"/api/auth/users/:id",
|
||||||
async (
|
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
||||||
request: FastifyRequest<{ Params: { id: string } }>,
|
|
||||||
reply: FastifyReply,
|
|
||||||
) => {
|
|
||||||
const admin = requireAdmin(request, reply);
|
const admin = requireAdmin(request, reply);
|
||||||
if (!admin) return;
|
if (!admin) return;
|
||||||
|
|
||||||
@@ -400,25 +544,17 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = db
|
const user = db.select().from(schema.users).where(eq(schema.users.id, id)).get();
|
||||||
.select()
|
|
||||||
.from(schema.users)
|
|
||||||
.where(eq(schema.users.id, id))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return reply.status(404).send({ error: "User not found", code: "NOT_FOUND" });
|
return reply.status(404).send({ error: "User not found", code: "NOT_FOUND" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete associated sessions
|
// Delete associated sessions
|
||||||
db.delete(schema.sessions)
|
db.delete(schema.sessions).where(eq(schema.sessions.userId, id)).run();
|
||||||
.where(eq(schema.sessions.userId, id))
|
|
||||||
.run();
|
|
||||||
|
|
||||||
// Delete the user (cascades to api_keys via FK)
|
// Delete the user (cascades to api_keys via FK)
|
||||||
db.delete(schema.users)
|
db.delete(schema.users).where(eq(schema.users.id, id)).run();
|
||||||
.where(eq(schema.users.id, id))
|
|
||||||
.run();
|
|
||||||
|
|
||||||
return reply.send({ ok: true });
|
return reply.send({ ok: true });
|
||||||
},
|
},
|
||||||
@@ -438,7 +574,13 @@ function extractToken(request: FastifyRequest): string | null {
|
|||||||
|
|
||||||
// ── Auth middleware ────────────────────────────────────────────────
|
// ── 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 {
|
function isPublicRoute(url: string): boolean {
|
||||||
// Non-API routes are public (SPA static files — auth is handled client-side)
|
// 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<void> {
|
export async function authMiddleware(app: FastifyInstance): Promise<void> {
|
||||||
app.addHook(
|
app.addHook("preHandler", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
"preHandler",
|
// When auth is disabled, attach the first admin user so requireAuth/requireAdmin pass
|
||||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
if (!env.AUTH_ENABLED) {
|
||||||
// When auth is disabled, attach the first admin user so requireAuth/requireAdmin pass
|
const adminUser = db.select().from(schema.users).where(eq(schema.users.role, "admin")).get();
|
||||||
if (!env.AUTH_ENABLED) {
|
if (adminUser) {
|
||||||
const adminUser = db
|
(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()
|
.select()
|
||||||
.from(schema.users)
|
.from(schema.apiKeys)
|
||||||
.where(eq(schema.users.role, "admin"))
|
.where(eq(schema.apiKeys.keyPrefix, prefix))
|
||||||
.get();
|
.all();
|
||||||
if (adminUser) {
|
// Fall back to full scan for legacy keys without a prefix
|
||||||
(request as FastifyRequest & { user?: AuthUser }).user = {
|
const keysToCheck =
|
||||||
id: adminUser.id,
|
candidates.length > 0
|
||||||
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
|
|
||||||
? candidates
|
? candidates
|
||||||
: db.select().from(schema.apiKeys).all().filter(k => !k.keyPrefix);
|
: db
|
||||||
for (const key of keysToCheck) {
|
.select()
|
||||||
const matches = await verifyPassword(token, key.keyHash);
|
.from(schema.apiKeys)
|
||||||
if (matches) {
|
.all()
|
||||||
// Backfill prefix for legacy keys
|
.filter((k) => !k.keyPrefix);
|
||||||
if (!key.keyPrefix) {
|
for (const key of keysToCheck) {
|
||||||
db.update(schema.apiKeys)
|
const matches = await verifyPassword(token, key.keyHash);
|
||||||
.set({ keyPrefix: prefix, lastUsedAt: new Date() })
|
if (matches) {
|
||||||
.where(eq(schema.apiKeys.id, key.id))
|
// Backfill prefix for legacy keys
|
||||||
.run();
|
if (!key.keyPrefix) {
|
||||||
} else {
|
db.update(schema.apiKeys)
|
||||||
db.update(schema.apiKeys)
|
.set({ keyPrefix: prefix, lastUsedAt: new Date() })
|
||||||
.set({ lastUsedAt: new Date() })
|
.where(eq(schema.apiKeys.id, key.id))
|
||||||
.where(eq(schema.apiKeys.id, key.id))
|
.run();
|
||||||
.run();
|
} else {
|
||||||
}
|
db.update(schema.apiKeys)
|
||||||
// Load the user
|
.set({ lastUsedAt: new Date() })
|
||||||
const apiUser = db.select().from(schema.users).where(eq(schema.users.id, key.userId)).get();
|
.where(eq(schema.apiKeys.id, key.id))
|
||||||
if (apiUser) {
|
.run();
|
||||||
(request as FastifyRequest & { user?: AuthUser }).user = {
|
}
|
||||||
id: apiUser.id,
|
// Load the user
|
||||||
username: apiUser.username,
|
const apiUser = db
|
||||||
role: apiUser.role as "admin" | "user",
|
.select()
|
||||||
};
|
.from(schema.users)
|
||||||
return;
|
.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
|
// Public routes can proceed without a valid session
|
||||||
.select()
|
if (isPublic) return;
|
||||||
.from(schema.users)
|
return reply.status(401).send({ error: "Session expired or invalid" });
|
||||||
.where(eq(schema.users.id, session.userId))
|
}
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!user) {
|
const user = db.select().from(schema.users).where(eq(schema.users.id, session.userId)).get();
|
||||||
if (isPublic) return;
|
|
||||||
return reply.status(401).send({ error: "User not found" });
|
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
// 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<number>`(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<number>`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");
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,25 +19,26 @@ import { dirname } from "node:path";
|
|||||||
mkdirSync(dirname(process.env.DB_PATH!), { recursive: true });
|
mkdirSync(dirname(process.env.DB_PATH!), { recursive: true });
|
||||||
mkdirSync(process.env.WORKSPACE_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.
|
// 2. Import app modules. config.ts already captured our env vars.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
import cors from "@fastify/cors";
|
import { env } from "../../apps/api/src/config.js";
|
||||||
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 { db, schema } from "../../apps/api/src/db/index.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 { registerUpload } from "../../apps/api/src/plugins/upload.js";
|
||||||
import { fileRoutes } from "../../apps/api/src/routes/files.js";
|
import { apiKeyRoutes } from "../../apps/api/src/routes/api-keys.js";
|
||||||
import { registerToolRoutes } from "../../apps/api/src/routes/tools/index.js";
|
|
||||||
import { registerBatchRoutes } from "../../apps/api/src/routes/batch.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 { registerPipelineRoutes } from "../../apps/api/src/routes/pipeline.js";
|
||||||
import { registerProgressRoutes } from "../../apps/api/src/routes/progress.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 { settingsRoutes } from "../../apps/api/src/routes/settings.js";
|
||||||
import { env } from "../../apps/api/src/config.js";
|
import { teamsRoutes } from "../../apps/api/src/routes/teams.js";
|
||||||
import { APP_VERSION } from "@stirling-image/shared";
|
import { registerToolRoutes } from "../../apps/api/src/routes/tools/index.js";
|
||||||
|
|
||||||
// Run migrations to create all tables in the temp DB
|
// Run migrations to create all tables in the temp DB
|
||||||
runMigrations();
|
runMigrations();
|
||||||
@@ -98,6 +99,9 @@ export async function buildTestApp(): Promise<TestApp> {
|
|||||||
// Settings routes
|
// Settings routes
|
||||||
await settingsRoutes(app);
|
await settingsRoutes(app);
|
||||||
|
|
||||||
|
// Teams routes
|
||||||
|
await teamsRoutes(app);
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get("/api/v1/health", async () => ({
|
app.get("/api/v1/health", async () => ({
|
||||||
status: "healthy",
|
status: "healthy",
|
||||||
@@ -133,9 +137,7 @@ export async function buildTestApp(): Promise<TestApp> {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Log in as the default admin and return the session token. */
|
/** Log in as the default admin and return the session token. */
|
||||||
export async function loginAsAdmin(
|
export async function loginAsAdmin(app: ReturnType<typeof Fastify>): Promise<string> {
|
||||||
app: ReturnType<typeof Fastify>,
|
|
||||||
): Promise<string> {
|
|
||||||
const res = await app.inject({
|
const res = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/auth/login",
|
url: "/api/auth/login",
|
||||||
|
|||||||
Reference in New Issue
Block a user