mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-09-13 20:57:39 +02:00
71 implemented delete tenant route (#73)
* 71 implemented delete tenant route * Fixed test
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user