feat(api): add teams CRUD routes and update auth team references

This commit is contained in:
Siddharth Kumar Sah
2026-03-26 01:10:51 +08:00
parent 62fbb5484c
commit ab370a74fe
5 changed files with 893 additions and 206 deletions
+379
View File
@@ -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");
});
});
+14 -12
View File
@@ -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<TestApp> {
// 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<TestApp> {
// ---------------------------------------------------------------------------
/** Log in as the default admin and return the session token. */
export async function loginAsAdmin(
app: ReturnType<typeof Fastify>,
): Promise<string> {
export async function loginAsAdmin(app: ReturnType<typeof Fastify>): Promise<string> {
const res = await app.inject({
method: "POST",
url: "/api/auth/login",