71 implemented delete tenant route (#73)

* 71 implemented delete tenant route

* Fixed test
This commit is contained in:
Hendrik
2025-09-08 10:35:47 +02:00
committed by GitHub
parent e343c16c46
commit 22dc3e71d6
4 changed files with 730 additions and 1 deletions
@@ -23,6 +23,7 @@ vi.mock("../../db/tenant-config", () => ({
vi.mock("../tenant-migration-service", () => ({
TenantMigrationService: {
createAndInitializeTenantDatabase: vi.fn(),
parseDatabaseUrl: vi.fn(),
},
}));
@@ -353,4 +354,293 @@ describe("TenantAdminService", () => {
await expect(service.updateTenantConfig(configUpdates)).rejects.toThrow("Config error");
});
});
describe("deleteTenant", () => {
it("should delete tenant and all associated data successfully", async () => {
const tenantId = "tenant-123";
const mockTenant = {
id: tenantId,
shortName: "test-clinic",
longName: "Test Clinic",
databaseUrl: "postgresql://user:pass@localhost:5432/test-clinic",
};
const mockDeletedUsers = [
{ id: "user-1", email: "admin@test.com", role: "TENANT_ADMIN" },
{ id: "user-2", email: "staff@test.com", role: "STAFF" },
];
const mockUpdatedGlobalAdmins = [
{ id: "global-1", email: "global@system.com", role: "GLOBAL_ADMIN" },
];
const mockDeletedConfigs = [
{ id: "config-1", name: "brandColor" },
{ id: "config-2", name: "maxChannels" },
];
const mockConfig = { setConfig: vi.fn() };
// Mock database query builders
const mockSelectBuilder = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([mockTenant]),
};
const mockUpdateBuilder = {
set: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue(mockUpdatedGlobalAdmins),
};
const mockDeleteUserBuilder = {
where: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue(mockDeletedUsers),
};
const mockDeleteConfigBuilder = {
where: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue(mockDeletedConfigs),
};
const mockDeleteTenantBuilder = {
where: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([mockTenant]),
};
// Mock database connection ending
const mockAdminClient = {
unsafe: vi.fn().mockResolvedValue([]),
end: vi.fn().mockResolvedValue(undefined),
};
// Mock postgres import
vi.doMock("postgres", () => ({
default: vi.fn(() => mockAdminClient),
}));
mockTenantConfig.create.mockResolvedValue(mockConfig);
mockCentralDb.select.mockReturnValue(mockSelectBuilder);
mockCentralDb.update.mockReturnValue(mockUpdateBuilder);
// First delete call for users, second for configs, third for tenant
mockCentralDb.delete
.mockReturnValueOnce(mockDeleteUserBuilder)
.mockReturnValueOnce(mockDeleteConfigBuilder)
.mockReturnValueOnce(mockDeleteTenantBuilder);
mockTenantMigrationService.parseDatabaseUrl.mockReturnValue({
host: "localhost",
port: 5432,
database: "test-clinic",
username: "user",
password: "pass",
});
const service = await TenantAdminService.getTenantById(tenantId);
//
(service as any)["#tenant"] = mockTenant; // Set private field for testing
const result = await service.deleteTenant();
// Verify user updates (global admins)
expect(mockCentralDb.update).toHaveBeenCalled();
expect(mockUpdateBuilder.set).toHaveBeenCalledWith({
tenantId: null,
updatedAt: expect.any(Date),
});
// Verify user deletions (non-global-admins)
expect(mockCentralDb.delete).toHaveBeenCalledTimes(3);
expect(mockDeleteUserBuilder.where).toHaveBeenCalled();
expect(mockDeleteUserBuilder.returning).toHaveBeenCalled();
// Verify config deletions
expect(mockDeleteConfigBuilder.where).toHaveBeenCalled();
expect(mockDeleteConfigBuilder.returning).toHaveBeenCalled();
// Verify database parsing and dropping
expect(mockTenantMigrationService.parseDatabaseUrl).toHaveBeenCalledWith(
mockTenant.databaseUrl,
);
expect(mockAdminClient.unsafe).toHaveBeenCalledWith(
expect.stringContaining("pg_terminate_backend"),
);
expect(mockAdminClient.unsafe).toHaveBeenCalledWith(`DROP DATABASE IF EXISTS "test-clinic"`);
expect(mockAdminClient.end).toHaveBeenCalled();
// Verify tenant deletion
expect(mockDeleteTenantBuilder.where).toHaveBeenCalled();
expect(mockDeleteTenantBuilder.returning).toHaveBeenCalled();
// Verify result
expect(result).toEqual({
tenantId,
shortName: "test-clinic",
deletedUsersCount: 2,
updatedGlobalAdminsCount: 1,
deletedConfigsCount: 2,
deletedUsers: mockDeletedUsers,
updatedGlobalAdmins: mockUpdatedGlobalAdmins,
});
});
it("should throw NotFoundError when tenant does not exist", async () => {
const tenantId = "non-existent";
const mockSelectBuilder = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([]), // Empty result
};
const mockConfig = { setConfig: vi.fn() };
mockTenantConfig.create.mockResolvedValue(mockConfig);
mockCentralDb.select.mockReturnValue(mockSelectBuilder);
const service = await TenantAdminService.getTenantById(tenantId);
await expect(service.deleteTenant()).rejects.toThrow("Tenant with ID non-existent not found");
});
it("should continue with deletion even if database drop fails", async () => {
const tenantId = "tenant-123";
const mockTenant = {
id: tenantId,
shortName: "test-clinic",
longName: "Test Clinic",
databaseUrl: "postgresql://user:pass@localhost:5432/test-clinic",
};
const mockConfig = { setConfig: vi.fn() };
// Mock successful database operations but failing database drop
const mockSelectBuilder = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([mockTenant]),
};
const mockUpdateBuilder = {
set: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([]),
};
const mockDeleteBuilder = {
where: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([]),
};
const mockDeleteTenantBuilder = {
where: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([mockTenant]),
};
// Mock failing database drop
const mockAdminClient = {
unsafe: vi.fn().mockRejectedValue(new Error("Database drop failed")),
end: vi.fn().mockResolvedValue(undefined),
};
vi.doMock("postgres", () => ({
default: vi.fn(() => mockAdminClient),
}));
mockTenantConfig.create.mockResolvedValue(mockConfig);
mockCentralDb.select.mockReturnValue(mockSelectBuilder);
mockCentralDb.update.mockReturnValue(mockUpdateBuilder);
mockCentralDb.delete
.mockReturnValueOnce(mockDeleteBuilder) // users
.mockReturnValueOnce(mockDeleteBuilder) // configs
.mockReturnValueOnce(mockDeleteTenantBuilder); // tenant
mockTenantMigrationService.parseDatabaseUrl.mockReturnValue({
host: "localhost",
port: 5432,
database: "test-clinic",
username: "user",
password: "pass",
});
const service = await TenantAdminService.getTenantById(tenantId);
(service as any)["#tenant"] = mockTenant;
// Should not throw error even if database drop fails
const result = await service.deleteTenant();
expect(result.tenantId).toBe(tenantId);
expect(result.shortName).toBe("test-clinic");
// Verify tenant record was still deleted
expect(mockDeleteTenantBuilder.where).toHaveBeenCalled();
expect(mockDeleteTenantBuilder.returning).toHaveBeenCalled();
});
it("should throw error if tenant record deletion fails", async () => {
const tenantId = "tenant-123";
const mockTenant = {
id: tenantId,
shortName: "test-clinic",
longName: "Test Clinic",
databaseUrl: "postgresql://user:pass@localhost:5432/test-clinic",
};
const mockConfig = { setConfig: vi.fn() };
const mockSelectBuilder = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([mockTenant]),
};
const mockUpdateBuilder = {
set: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([]),
};
const mockDeleteBuilder = {
where: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([]),
};
const mockDeleteTenantBuilder = {
where: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([]), // Empty result = tenant not found
};
mockTenantConfig.create.mockResolvedValue(mockConfig);
mockCentralDb.select.mockReturnValue(mockSelectBuilder);
mockCentralDb.update.mockReturnValue(mockUpdateBuilder);
mockCentralDb.delete
.mockReturnValueOnce(mockDeleteBuilder) // users
.mockReturnValueOnce(mockDeleteBuilder) // configs
.mockReturnValueOnce(mockDeleteTenantBuilder); // tenant
mockTenantMigrationService.parseDatabaseUrl.mockReturnValue({
host: "localhost",
port: 5432,
database: "test-clinic",
username: "user",
password: "pass",
});
// Mock successful database operations
const mockAdminClient = {
unsafe: vi.fn().mockResolvedValue([]),
end: vi.fn().mockResolvedValue(undefined),
};
vi.doMock("postgres", () => ({
default: vi.fn(() => mockAdminClient),
}));
const service = await TenantAdminService.getTenantById(tenantId);
(service as any)["#tenant"] = mockTenant;
await expect(service.deleteTenant()).rejects.toThrow("Tenant with ID tenant-123 not found");
});
});
});
+191 -1
View File
@@ -5,7 +5,7 @@ import { TenantConfig } from "../db/tenant-config";
import { TenantMigrationService } from "./tenant-migration-service";
import { env } from "$env/dynamic/private";
import { eq } from "drizzle-orm";
import { eq, and, not } from "drizzle-orm";
import logger from "$lib/logger";
import z from "zod/v4";
import { ValidationError, NotFoundError, ConflictError } from "../utils/errors";
@@ -373,4 +373,194 @@ export class TenantAdminService {
return this.#db;
}
/**
* Delete a tenant and all associated data
* This operation:
* - Drops the tenant's isolated database
* - Deletes all non-global-admin users associated with the tenant
* - Removes tenant assignment from global admins (sets tenantId to null)
* - Removes the tenant record from the central database
* - Cleans up tenant configuration entries
*
* @throws {NotFoundError} If the tenant doesn't exist
* @throws {Error} If database operations fail
*/
async deleteTenant() {
const log = logger.setContext("TenantAdminService");
log.info("Starting tenant deletion process", { tenantId: this.tenantId });
// First, get tenant data to ensure it exists
if (!this.#tenant) {
const tenantData = await centralDb
.select()
.from(centralSchema.tenant)
.where(eq(centralSchema.tenant.id, this.tenantId))
.limit(1);
if (!tenantData[0]) {
log.warn("Tenant deletion failed: Tenant not found", { tenantId: this.tenantId });
throw new NotFoundError(`Tenant with ID ${this.tenantId} not found`);
}
this.#tenant = tenantData[0];
}
const tenantDbUrl = this.#tenant.databaseUrl;
const tenantShortName = this.#tenant.shortName;
try {
// Step 1: Handle users associated with this tenant
log.debug("Processing tenant users", { tenantId: this.tenantId });
// First, remove tenant assignment from global admins (they must not be deleted)
const updatedGlobalAdmins = await centralDb
.update(centralSchema.user)
.set({
tenantId: null,
updatedAt: new Date(),
})
.where(
and(
eq(centralSchema.user.tenantId, this.tenantId),
eq(centralSchema.user.role, "GLOBAL_ADMIN"),
),
)
.returning({
id: centralSchema.user.id,
email: centralSchema.user.email,
role: centralSchema.user.role,
});
log.info("Removed tenant assignment from global admins", {
tenantId: this.tenantId,
updatedCount: updatedGlobalAdmins.length,
updatedAdmins: updatedGlobalAdmins.map((u) => ({ id: u.id, email: u.email, role: u.role })),
});
// Then, delete non-global-admin users (TENANT_ADMIN, STAFF)
const deletedUsers = await centralDb
.delete(centralSchema.user)
.where(
and(
eq(centralSchema.user.tenantId, this.tenantId),
not(eq(centralSchema.user.role, "GLOBAL_ADMIN")),
),
)
.returning({
id: centralSchema.user.id,
email: centralSchema.user.email,
role: centralSchema.user.role,
});
log.info("Deleted non-global-admin tenant users", {
tenantId: this.tenantId,
deletedCount: deletedUsers.length,
deletedUsers: deletedUsers.map((u) => ({ id: u.id, email: u.email, role: u.role })),
});
// Step 2: Delete tenant configuration entries
log.debug("Deleting tenant configuration entries", { tenantId: this.tenantId });
const deletedConfigs = await centralDb
.delete(centralSchema.tenantConfig)
.where(eq(centralSchema.tenantConfig.tenantId, this.tenantId))
.returning({ id: centralSchema.tenantConfig.id, name: centralSchema.tenantConfig.name });
log.info("Deleted tenant configuration entries", {
tenantId: this.tenantId,
deletedCount: deletedConfigs.length,
});
// Step 3: Drop the tenant database
log.debug("Dropping tenant database", {
tenantId: this.tenantId,
databaseUrl: tenantDbUrl,
shortName: tenantShortName,
});
try {
const dbConfig = TenantMigrationService.parseDatabaseUrl(tenantDbUrl);
const adminConnectionString = `postgres://${dbConfig.username}:${dbConfig.password}@${dbConfig.host}:${dbConfig.port}/postgres`;
// Import postgres here to avoid top-level import issues
const { default: postgres } = await import("postgres");
const adminClient = postgres(adminConnectionString);
try {
// Terminate all connections to the database first
await adminClient.unsafe(`
SELECT pg_terminate_backend(pg_stat_activity.pid)
FROM pg_stat_activity
WHERE pg_stat_activity.datname = '${dbConfig.database}'
AND pid <> pg_backend_pid()
`);
// Drop the database
await adminClient.unsafe(`DROP DATABASE IF EXISTS "${dbConfig.database}"`);
log.info("Tenant database dropped successfully", {
tenantId: this.tenantId,
database: dbConfig.database,
});
} finally {
await adminClient.end();
}
} catch (dbError) {
log.error("Failed to drop tenant database", {
tenantId: this.tenantId,
databaseUrl: tenantDbUrl,
error: String(dbError),
});
// Continue with tenant record deletion even if database drop fails
// This allows cleanup of orphaned tenant records
}
// Step 4: Delete the tenant record from central database
log.debug("Deleting tenant record from central database", { tenantId: this.tenantId });
const deletedTenant = await centralDb
.delete(centralSchema.tenant)
.where(eq(centralSchema.tenant.id, this.tenantId))
.returning();
if (!deletedTenant[0]) {
log.error("Failed to delete tenant record: Tenant not found", { tenantId: this.tenantId });
throw new NotFoundError(`Tenant with ID ${this.tenantId} not found`);
}
log.info("Tenant deletion completed successfully", {
tenantId: this.tenantId,
shortName: tenantShortName,
deletedUsersCount: deletedUsers.length,
updatedGlobalAdminsCount: updatedGlobalAdmins.length,
deletedConfigsCount: deletedConfigs.length,
});
// Clear cached data
this.#tenant = null;
this.#db = null;
return {
tenantId: this.tenantId,
shortName: tenantShortName,
deletedUsersCount: deletedUsers.length,
updatedGlobalAdminsCount: updatedGlobalAdmins.length,
deletedConfigsCount: deletedConfigs.length,
deletedUsers: deletedUsers.map((u) => ({ id: u.id, email: u.email, role: u.role })),
updatedGlobalAdmins: updatedGlobalAdmins.map((u) => ({
id: u.id,
email: u.email,
role: u.role,
})),
};
} catch (error) {
log.error("Tenant deletion failed", {
tenantId: this.tenantId,
shortName: tenantShortName,
error: String(error),
});
throw error;
}
}
}
+218
View File
@@ -1,6 +1,7 @@
import { json } from "@sveltejs/kit";
import { TenantAdminService } from "$lib/server/services/tenant-admin-service";
import { ValidationError, NotFoundError } from "$lib/server/utils/errors";
import { AuthorizationService } from "$lib/server/auth/authorization-service";
import type { RequestHandler } from "@sveltejs/kit";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import logger from "$lib/logger";
@@ -227,6 +228,158 @@ registerOpenAPIRoute("/tenants/{id}", "GET", {
},
});
// Register OpenAPI documentation for DELETE
registerOpenAPIRoute("/tenants/{id}", "DELETE", {
summary: "Delete tenant",
description:
"Permanently deletes a tenant and all associated data. This operation drops the tenant's database, removes tenant assignments from global admins, deletes non-global-admin users, and removes the tenant record. Only global admins can perform this operation.",
tags: ["Tenants"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID to delete",
},
],
responses: {
"200": {
description: "Tenant deleted successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
message: { type: "string", description: "Success message" },
result: {
type: "object",
properties: {
tenantId: { type: "string", format: "uuid", description: "Deleted tenant ID" },
shortName: { type: "string", description: "Deleted tenant short name" },
deletedUsersCount: { type: "number", description: "Number of users deleted" },
updatedGlobalAdminsCount: {
type: "number",
description: "Number of global admins whose tenant assignment was removed",
},
deletedConfigsCount: {
type: "number",
description: "Number of configuration entries deleted",
},
deletedUsers: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string", format: "uuid" },
email: { type: "string" },
role: { type: "string" },
},
},
description: "List of deleted users",
},
updatedGlobalAdmins: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string", format: "uuid" },
email: { type: "string" },
role: { type: "string" },
},
},
description: "List of global admins whose tenant assignment was removed",
},
},
required: [
"tenantId",
"shortName",
"deletedUsersCount",
"updatedGlobalAdminsCount",
"deletedConfigsCount",
"deletedUsers",
"updatedGlobalAdmins",
],
},
},
required: ["message", "result"],
},
example: {
message: "Tenant deleted successfully",
result: {
tenantId: "01234567-89ab-cdef-0123-456789abcdef",
shortName: "acme-corp",
deletedUsersCount: 3,
updatedGlobalAdminsCount: 1,
deletedConfigsCount: 12,
deletedUsers: [
{
id: "11111111-1111-1111-1111-111111111111",
email: "admin@acme.com",
role: "TENANT_ADMIN",
},
{
id: "22222222-2222-2222-2222-222222222222",
email: "staff1@acme.com",
role: "STAFF",
},
{
id: "33333333-3333-3333-3333-333333333333",
email: "staff2@acme.com",
role: "STAFF",
},
],
updatedGlobalAdmins: [
{
id: "44444444-4444-4444-4444-444444444444",
email: "global@system.com",
role: "GLOBAL_ADMIN",
},
],
},
},
},
},
},
"401": {
description: "Authentication required",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
example: { error: "Authentication required" },
},
},
},
"403": {
description: "Insufficient permissions (only global admins can delete tenants)",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
example: { error: "Insufficient permissions" },
},
},
},
"404": {
description: "Tenant not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
example: { error: "Tenant not found" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
example: { error: "Internal server error" },
},
},
},
},
});
export const PUT: RequestHandler = async ({ params, request }) => {
const log = logger.setContext("API");
@@ -329,3 +482,68 @@ export const GET: RequestHandler = async ({ params, locals }) => {
return json({ error: "Internal server error" }, { status: 500 });
}
};
export const DELETE: RequestHandler = async ({ params, locals }) => {
const log = logger.setContext("API");
try {
const tenantId = params.id;
// Check if user is authenticated
if (!locals.user) {
return json({ error: "Authentication required" }, { status: 401 });
}
if (!tenantId) {
return json({ error: "No tenant id given" }, { status: 400 });
}
log.info("Attempting tenant deletion", {
tenantId,
requestedBy: locals.user.userId,
userRole: locals.user.role,
});
// Authorization: Only global admins can delete tenants
try {
AuthorizationService.requireGlobalAdmin(locals.user);
} catch {
log.warn("Tenant deletion denied: insufficient permissions", {
tenantId,
requestedBy: locals.user.userId,
userRole: locals.user.role,
});
return json({ error: "Insufficient permissions" }, { status: 403 });
}
// Get tenant service and perform deletion
const tenantService = await TenantAdminService.getTenantById(tenantId);
const result = await tenantService.deleteTenant();
log.info("Tenant deletion completed successfully", {
tenantId,
shortName: result.shortName,
deletedUsersCount: result.deletedUsersCount,
updatedGlobalAdminsCount: result.updatedGlobalAdminsCount,
deletedConfigsCount: result.deletedConfigsCount,
requestedBy: locals.user.userId,
});
return json({
message: "Tenant deleted successfully",
result,
});
} catch (error) {
log.error("Error deleting tenant", {
tenantId: params.id,
requestedBy: locals.user?.userId,
error: JSON.stringify(error || "?"),
});
if (error instanceof NotFoundError) {
return json({ error: "Tenant not found" }, { status: 404 });
}
return json({ error: "Internal server error" }, { status: 500 });
}
};
@@ -29,6 +29,18 @@ vi.mock("$lib/logger", () => ({
error: vi.fn(),
})),
},
UniversalLogger: vi.fn().mockImplementation(() => ({
setContext: vi.fn(() => ({
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
})),
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
})),
}));
vi.mock("$lib/server/utils/errors", () => ({
@@ -46,6 +58,25 @@ vi.mock("$lib/server/utils/errors", () => ({
},
}));
vi.mock("$lib/server/auth/authorization-service", () => ({
AuthorizationService: {
requireGlobalAdmin: vi.fn(),
requireRole: vi.fn(),
requireAnyRole: vi.fn(),
requireTenantAccess: vi.fn(),
requireTenantAdmin: vi.fn(),
requireStaffOrAbove: vi.fn(),
canAccessTenant: vi.fn(),
isGlobalAdmin: vi.fn(),
isTenantAdmin: vi.fn(),
isStaff: vi.fn(),
hasRole: vi.fn(),
hasAnyRole: vi.fn(),
getUserTenantId: vi.fn(),
},
withAuthorization: vi.fn(),
}));
describe("Tenant API Routes", () => {
beforeEach(() => {
vi.clearAllMocks();