From 3c116fdbb12355b03e66db8a9f9c15b7242630f6 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 14 Dec 2025 16:17:56 -0500 Subject: [PATCH 1/4] Add admin delete workspace feature with safeguards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add DELETE /api/admin/members/:orgId endpoint - Block deletion of workspaces with payment history - Require type-to-confirm with exact workspace name - Add delete button and confirmation modal to admin UI - Add integration tests for delete functionality 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- server/public/admin-members.html | 195 +++++++++++++++++- server/src/http.ts | 80 +++++++ .../tests/integration/admin-endpoints.test.ts | 131 +++++++++++- 3 files changed, 402 insertions(+), 4 deletions(-) diff --git a/server/public/admin-members.html b/server/public/admin-members.html index aa51a5f661..edb0c0fe2c 100644 --- a/server/public/admin-members.html +++ b/server/public/admin-members.html @@ -230,6 +230,71 @@ .payment-amount.negative { color: #c33; } + .btn-danger { + background: #dc3545; + color: white; + border: 1px solid #dc3545; + } + .btn-danger:hover { + background: #c82333; + border-color: #bd2130; + } + .btn-danger:disabled { + background: #e4606d; + border-color: #e4606d; + cursor: not-allowed; + } + .delete-warning { + background: #fff3cd; + border: 1px solid #ffc107; + border-radius: 6px; + padding: 15px; + margin-bottom: 20px; + } + .delete-warning h4 { + color: #856404; + margin-bottom: 10px; + } + .delete-warning p { + color: #856404; + margin-bottom: 0; + } + .delete-error { + background: #f8d7da; + border: 1px solid #f5c6cb; + border-radius: 6px; + padding: 15px; + margin-bottom: 20px; + color: #721c24; + } + .confirm-input { + width: 100%; + padding: 10px; + border: 1px solid #e0e0e0; + border-radius: 6px; + font-size: 14px; + margin-top: 10px; + margin-bottom: 10px; + } + .confirm-input:focus { + outline: none; + border-color: #dc3545; + } + .workspace-name-highlight { + font-weight: bold; + background: #f8f9fa; + padding: 2px 6px; + border-radius: 4px; + font-family: monospace; + } + .modal-actions { + display: flex; + gap: 10px; + justify-content: flex-end; + margin-top: 20px; + padding-top: 20px; + border-top: 1px solid #e0e0e0; + } @@ -328,6 +393,33 @@

Payment History

+ + + @@ -369,6 +447,19 @@

Loading...

+ + + @@ -578,6 +669,14 @@

Membership Agreement

editBtn.style.display = 'none'; } + // Show Danger Zone for owners only + const dangerZoneCard = document.getElementById('dangerZoneCard'); + if (org.role === 'owner') { + dangerZoneCard.style.display = 'block'; + } else { + dangerZoneCard.style.display = 'none'; + } + // Render Billing Card const billingDetailsDiv = document.getElementById('billingDetails'); billingDetailsDiv.innerHTML = ` @@ -1819,6 +1918,89 @@

Create Your Profile

updatePricingTable(currentOrg); } }); + + // Delete workspace modal functions + function openDeleteWorkspaceModal() { + if (!currentOrg) return; + document.getElementById('deleteWorkspaceName').textContent = currentOrg.name; + document.getElementById('deleteConfirmInput').value = ''; + document.getElementById('deleteWorkspaceError').style.display = 'none'; + document.getElementById('confirmDeleteWorkspaceBtn').disabled = true; + document.getElementById('confirmDeleteWorkspaceBtn').style.opacity = '0.5'; + document.getElementById('deleteWorkspaceModal').classList.add('show'); + document.getElementById('deleteConfirmInput').focus(); + } + + function closeDeleteWorkspaceModal() { + document.getElementById('deleteWorkspaceModal').classList.remove('show'); + document.getElementById('deleteWorkspaceError').style.display = 'none'; + } + + function checkDeleteWorkspaceConfirmation() { + if (!currentOrg) return; + const input = document.getElementById('deleteConfirmInput').value; + const btn = document.getElementById('confirmDeleteWorkspaceBtn'); + if (input === currentOrg.name) { + btn.disabled = false; + btn.style.opacity = '1'; + } else { + btn.disabled = true; + btn.style.opacity = '0.5'; + } + } + + async function confirmDeleteWorkspace() { + if (!currentOrg) return; + + const confirmation = document.getElementById('deleteConfirmInput').value; + const errorEl = document.getElementById('deleteWorkspaceError'); + const btn = document.getElementById('confirmDeleteWorkspaceBtn'); + + // Disable button during request + btn.disabled = true; + btn.textContent = 'Deleting...'; + errorEl.style.display = 'none'; + + try { + const response = await fetch(`/api/organizations/${currentOrg.id}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ confirmation }), + credentials: 'same-origin', + }); + + const data = await response.json(); + + if (!response.ok) { + if (data.has_payments) { + throw new Error('Cannot delete workspace with payment history. Please contact support.'); + } + throw new Error(data.error || data.message || 'Failed to delete workspace'); + } + + // Success - redirect to onboarding to show join/create options + alert('Workspace deleted successfully.'); + localStorage.removeItem('selectedOrgId'); + window.location.href = '/onboarding'; + } catch (error) { + errorEl.textContent = error.message; + errorEl.style.display = 'block'; + btn.disabled = false; + btn.textContent = 'Delete Workspace'; + } + } + + // Handle Enter key in delete confirmation input + document.addEventListener('DOMContentLoaded', () => { + const deleteInput = document.getElementById('deleteConfirmInput'); + if (deleteInput) { + deleteInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter' && !document.getElementById('confirmDeleteWorkspaceBtn').disabled) { + confirmDeleteWorkspace(); + } + }); + } + }); diff --git a/server/src/http.ts b/server/src/http.ts index 4014848f49..146ad17ec8 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -4585,6 +4585,106 @@ export class HTTPServer { } }); + // DELETE /api/organizations/:orgId - Delete own workspace (owner only) + // Cannot delete if workspace has any payment history + this.app.delete('/api/organizations/:orgId', requireAuth, async (req, res) => { + try { + const user = req.user!; + const { orgId } = req.params; + const { confirmation } = req.body; + + // Verify user is owner of this organization + const memberships = await workos!.userManagement.listOrganizationMemberships({ + userId: user.id, + organizationId: orgId, + }); + + const membership = memberships.data[0]; + if (!membership) { + return res.status(403).json({ + error: 'Access denied', + message: 'You are not a member of this organization', + }); + } + + // Only owners can delete + const roleSlug = (membership as any).role?.slug || (membership as any).roleSlug; + if (roleSlug !== 'owner') { + return res.status(403).json({ + error: 'Insufficient permissions', + message: 'Only the organization owner can delete the workspace', + }); + } + + // Get organization from database + const pool = getPool(); + const orgResult = await pool.query( + 'SELECT workos_organization_id, name, stripe_customer_id FROM organizations WHERE workos_organization_id = $1', + [orgId] + ); + + if (orgResult.rows.length === 0) { + return res.status(404).json({ + error: 'Organization not found', + message: 'The specified organization does not exist', + }); + } + + const org = orgResult.rows[0]; + + // Check if organization has any payment history + const revenueResult = await pool.query( + 'SELECT COUNT(*) as count FROM revenue_events WHERE workos_organization_id = $1', + [orgId] + ); + + const hasPayments = parseInt(revenueResult.rows[0].count) > 0; + + if (hasPayments) { + return res.status(400).json({ + error: 'Cannot delete paid workspace', + message: 'This workspace has payment history and cannot be deleted. Please contact support if you need to remove this workspace.', + has_payments: true, + }); + } + + // Require confirmation by typing the organization name + if (!confirmation || confirmation !== org.name) { + return res.status(400).json({ + error: 'Confirmation required', + message: `To delete this workspace, please provide the exact name "${org.name}" in the confirmation field.`, + requires_confirmation: true, + organization_name: org.name, + }); + } + + // Delete from WorkOS + try { + await workos!.organizations.deleteOrganization(orgId); + logger.info({ orgId, name: org.name, userId: user.id }, 'Deleted organization from WorkOS'); + } catch (workosError) { + logger.warn({ err: workosError, orgId }, 'Failed to delete organization from WorkOS - continuing with local deletion'); + } + + // Delete from local database (cascades to related tables) + await pool.query('DELETE FROM organizations WHERE workos_organization_id = $1', [orgId]); + + logger.info({ orgId, name: org.name, userId: user.id, userEmail: user.email }, 'User deleted their own organization'); + + res.json({ + success: true, + message: `Workspace "${org.name}" has been deleted`, + deleted_org_id: orgId, + }); + } catch (error) { + logger.error({ err: error }, 'Delete organization error'); + res.status(500).json({ + error: 'Failed to delete organization', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + // Billing Routes // POST /api/organizations/:orgId/billing/portal - Create Customer Portal session diff --git a/server/tests/integration/self-service-delete.test.ts b/server/tests/integration/self-service-delete.test.ts new file mode 100644 index 0000000000..f6379d25df --- /dev/null +++ b/server/tests/integration/self-service-delete.test.ts @@ -0,0 +1,260 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; +import { HTTPServer } from '../../src/http.js'; +import request from 'supertest'; +import { getPool, initializeDatabase, closeDatabase } from '../../src/db/client.js'; +import { runMigrations } from '../../src/db/migrate.js'; +import type { Pool } from 'pg'; + +const TEST_USER_ID = 'user_self_delete_test'; +const TEST_ORG_ID = 'org_self_delete_test'; + +// Mock auth middleware to bypass authentication in tests +// This simulates a logged-in user who is the owner +vi.mock('../../src/middleware/auth.js', () => ({ + requireAuth: (req: any, res: any, next: any) => { + req.user = { + workos_user_id: TEST_USER_ID, + email: 'owner@test.com', + is_admin: false + }; + next(); + }, + requireAdmin: (req: any, res: any, next: any) => { + return res.status(403).json({ error: 'Admin required' }); + }, +})); + +// Mock WorkOS client to return membership with owner role +vi.mock('@workos-inc/node', () => ({ + WorkOS: vi.fn().mockImplementation(() => ({ + userManagement: { + listOrganizationMemberships: vi.fn().mockImplementation(({ userId, organizationId }) => { + if (organizationId === TEST_ORG_ID) { + return Promise.resolve({ + data: [{ + id: 'om_test', + userId: TEST_USER_ID, + organizationId: TEST_ORG_ID, + role: { slug: 'owner' }, + status: 'active' + }] + }); + } + // Non-owner org + if (organizationId === 'org_member_only') { + return Promise.resolve({ + data: [{ + id: 'om_member', + userId: TEST_USER_ID, + organizationId: 'org_member_only', + role: { slug: 'member' }, + status: 'active' + }] + }); + } + return Promise.resolve({ data: [] }); + }), + }, + organizations: { + deleteOrganization: vi.fn().mockResolvedValue({}), + listOrganizations: vi.fn().mockResolvedValue({ data: [] }), + }, + })), +})); + +describe('Self-Service Delete Workspace', () => { + let server: HTTPServer; + let app: any; + let pool: Pool; + + beforeAll(async () => { + // Initialize test database + pool = initializeDatabase({ + connectionString: process.env.DATABASE_URL || 'postgresql://adcp:localdev@localhost:53198/adcp_test', + }); + + // Run migrations + await runMigrations(); + + // Initialize HTTP server + server = new HTTPServer(); + await server.start(0); + app = server.app; + }); + + afterAll(async () => { + // Clean up any remaining test data + await pool.query('DELETE FROM revenue_events WHERE workos_organization_id LIKE $1', ['org_self_delete%']); + await pool.query('DELETE FROM member_profiles WHERE workos_organization_id LIKE $1', ['org_self_delete%']); + await pool.query('DELETE FROM organizations WHERE workos_organization_id LIKE $1', ['org_self_delete%']); + await pool.query('DELETE FROM organizations WHERE workos_organization_id = $1', ['org_member_only']); + + await server?.stop(); + await closeDatabase(); + }); + + beforeEach(async () => { + // Create fresh test organization before each test + await pool.query( + `INSERT INTO organizations (workos_organization_id, name, created_at, updated_at) + VALUES ($1, $2, NOW(), NOW()) + ON CONFLICT (workos_organization_id) DO UPDATE SET name = $2`, + [TEST_ORG_ID, 'Self Delete Test Org'] + ); + }); + + afterEach(async () => { + // Clean up test data + await pool.query('DELETE FROM revenue_events WHERE workos_organization_id LIKE $1', ['org_self_delete%']); + await pool.query('DELETE FROM member_profiles WHERE workos_organization_id LIKE $1', ['org_self_delete%']); + await pool.query('DELETE FROM organizations WHERE workos_organization_id LIKE $1', ['org_self_delete%']); + await pool.query('DELETE FROM organizations WHERE workos_organization_id = $1', ['org_member_only']); + }); + + describe('DELETE /api/organizations/:orgId', () => { + it('should return 404 for non-existent organization', async () => { + const response = await request(app) + .delete('/api/organizations/org_nonexistent') + .send({ confirmation: 'Some Name' }) + .expect(404); + + expect(response.body.error).toBe('Organization not found'); + }); + + it('should require confirmation to delete', async () => { + const response = await request(app) + .delete(`/api/organizations/${TEST_ORG_ID}`) + .send({}) + .expect(400); + + expect(response.body.error).toBe('Confirmation required'); + expect(response.body.requires_confirmation).toBe(true); + expect(response.body.organization_name).toBe('Self Delete Test Org'); + }); + + it('should reject wrong confirmation name', async () => { + const response = await request(app) + .delete(`/api/organizations/${TEST_ORG_ID}`) + .send({ confirmation: 'Wrong Name' }) + .expect(400); + + expect(response.body.error).toBe('Confirmation required'); + }); + + it('should prevent deletion of organization with payment history', async () => { + // Create org with payment history + const PAID_ORG_ID = 'org_self_delete_paid'; + await pool.query( + `INSERT INTO organizations (workos_organization_id, name, stripe_customer_id, created_at, updated_at) + VALUES ($1, $2, $3, NOW(), NOW()) + ON CONFLICT (workos_organization_id) DO UPDATE SET name = $2, stripe_customer_id = $3`, + [PAID_ORG_ID, 'Paid Org', 'cus_paid'] + ); + + // Add a revenue event + await pool.query( + `INSERT INTO revenue_events (workos_organization_id, revenue_type, amount_paid, currency, paid_at) + VALUES ($1, $2, $3, $4, NOW())`, + [PAID_ORG_ID, 'subscription_initial', 2999, 'usd'] + ); + + // Mock WorkOS to return owner for this org too + const { WorkOS } = await import('@workos-inc/node'); + const mockWorkOS = new WorkOS('test'); + vi.mocked(mockWorkOS.userManagement.listOrganizationMemberships).mockImplementation(({ organizationId }) => { + if (organizationId === PAID_ORG_ID) { + return Promise.resolve({ + data: [{ + id: 'om_paid', + userId: TEST_USER_ID, + organizationId: PAID_ORG_ID, + role: { slug: 'owner' }, + status: 'active' + }] + }); + } + return Promise.resolve({ data: [] }); + }); + + const response = await request(app) + .delete(`/api/organizations/${PAID_ORG_ID}`) + .send({ confirmation: 'Paid Org' }) + .expect(400); + + expect(response.body.error).toBe('Cannot delete paid workspace'); + expect(response.body.has_payments).toBe(true); + + // Verify org still exists + const checkResult = await pool.query( + 'SELECT 1 FROM organizations WHERE workos_organization_id = $1', + [PAID_ORG_ID] + ); + expect(checkResult.rows.length).toBe(1); + }); + + it('should successfully delete unpaid organization with correct confirmation', async () => { + const response = await request(app) + .delete(`/api/organizations/${TEST_ORG_ID}`) + .send({ confirmation: 'Self Delete Test Org' }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.deleted_org_id).toBe(TEST_ORG_ID); + + // Verify org is deleted + const checkResult = await pool.query( + 'SELECT 1 FROM organizations WHERE workos_organization_id = $1', + [TEST_ORG_ID] + ); + expect(checkResult.rows.length).toBe(0); + }); + + it('should cascade delete related member profiles', async () => { + // Create a member profile for the test org + await pool.query( + `INSERT INTO member_profiles (workos_organization_id, display_name, slug, created_at, updated_at) + VALUES ($1, $2, $3, NOW(), NOW()) + ON CONFLICT DO NOTHING`, + [TEST_ORG_ID, 'Test Profile', 'test-profile-self-delete'] + ); + + // Verify profile exists + const beforeResult = await pool.query( + 'SELECT 1 FROM member_profiles WHERE workos_organization_id = $1', + [TEST_ORG_ID] + ); + expect(beforeResult.rows.length).toBe(1); + + // Delete the organization + await request(app) + .delete(`/api/organizations/${TEST_ORG_ID}`) + .send({ confirmation: 'Self Delete Test Org' }) + .expect(200); + + // Verify profile is cascade deleted + const afterResult = await pool.query( + 'SELECT 1 FROM member_profiles WHERE workos_organization_id = $1', + [TEST_ORG_ID] + ); + expect(afterResult.rows.length).toBe(0); + }); + + it('should reject non-owner attempting to delete', async () => { + // Create org where user is only a member + const MEMBER_ORG_ID = 'org_member_only'; + await pool.query( + `INSERT INTO organizations (workos_organization_id, name, created_at, updated_at) + VALUES ($1, $2, NOW(), NOW()) + ON CONFLICT (workos_organization_id) DO UPDATE SET name = $2`, + [MEMBER_ORG_ID, 'Member Only Org'] + ); + + const response = await request(app) + .delete(`/api/organizations/${MEMBER_ORG_ID}`) + .send({ confirmation: 'Member Only Org' }) + .expect(403); + + expect(response.body.error).toBe('Insufficient permissions'); + }); + }); +}); From 54ea6cd03f4bac62548b84866052e5bd52fe568f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 14 Dec 2025 20:14:28 -0500 Subject: [PATCH 3/4] Add empty changeset for delete workspace feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server/dashboard changes don't affect the protocol specification. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .changeset/light-impalas-cough.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .changeset/light-impalas-cough.md diff --git a/.changeset/light-impalas-cough.md b/.changeset/light-impalas-cough.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/light-impalas-cough.md @@ -0,0 +1,2 @@ +--- +--- From a19d59d7c77f0425aec446c54e7037b4e8f85685 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 14 Dec 2025 20:21:24 -0500 Subject: [PATCH 4/4] Address code review feedback for delete workspace feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Block deletion if workspace has active subscription (must cancel first) - Add audit log entries before deleting organizations - Simplify frontend error handling to use API message directly - Add warning comment to docker-compose.yml for ALLOW_INSECURE_COOKIES - Fix test mocks to use correct user.id property name - Add tests for active subscription blocking 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- docker-compose.yml | 1 + server/public/dashboard.html | 6 +- server/src/http.ts | 47 +++++++++++++ .../tests/integration/admin-endpoints.test.ts | 51 +++++++++++++- .../integration/self-service-delete.test.ts | 66 ++++++++++++++++++- 5 files changed, 165 insertions(+), 6 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 12cbe678e1..dabaebfec1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,7 @@ services: - PORT=8080 - DATABASE_URL=postgresql://adcp:localdev@postgres:5432/adcp_registry - RUN_MIGRATIONS=true + # WARNING: Only for local development - DO NOT use in production - ALLOW_INSECURE_COOKIES=true depends_on: postgres: diff --git a/server/public/dashboard.html b/server/public/dashboard.html index 6421d3ef89..6a55b9f984 100644 --- a/server/public/dashboard.html +++ b/server/public/dashboard.html @@ -1972,10 +1972,8 @@

Create Your Profile

const data = await response.json(); if (!response.ok) { - if (data.has_payments) { - throw new Error('Cannot delete workspace with payment history. Please contact support.'); - } - throw new Error(data.error || data.message || 'Failed to delete workspace'); + // Use the API's message which is more specific about the issue + throw new Error(data.message || data.error || 'Failed to delete workspace'); } // Success - redirect to onboarding to show join/create options diff --git a/server/src/http.ts b/server/src/http.ts index 146ad17ec8..b493e02679 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -2561,6 +2561,19 @@ export class HTTPServer { }); } + // Check for active Stripe subscription + if (org.stripe_customer_id) { + const subscriptionInfo = await getSubscriptionInfo(org.stripe_customer_id); + if (subscriptionInfo && (subscriptionInfo.status === 'active' || subscriptionInfo.status === 'past_due')) { + return res.status(400).json({ + error: 'Cannot delete workspace with active subscription', + message: 'This workspace has an active subscription. Please cancel the subscription first before deleting the workspace.', + has_active_subscription: true, + subscription_status: subscriptionInfo.status, + }); + } + } + // Require confirmation by typing the organization name if (!confirmation || confirmation !== org.name) { return res.status(400).json({ @@ -2571,6 +2584,17 @@ export class HTTPServer { }); } + // Record audit log before deletion (while org still exists) + const orgDb = new OrganizationDatabase(); + await orgDb.recordAuditLog({ + workos_organization_id: orgId, + workos_user_id: req.user!.id, + action: 'organization_deleted', + resource_type: 'organization', + resource_id: orgId, + details: { name: org.name, deleted_by: 'admin', admin_email: req.user!.email }, + }); + // Delete from WorkOS if possible if (workos) { try { @@ -4648,6 +4672,19 @@ export class HTTPServer { }); } + // Check for active Stripe subscription + if (org.stripe_customer_id) { + const subscriptionInfo = await getSubscriptionInfo(org.stripe_customer_id); + if (subscriptionInfo && (subscriptionInfo.status === 'active' || subscriptionInfo.status === 'past_due')) { + return res.status(400).json({ + error: 'Cannot delete workspace with active subscription', + message: 'This workspace has an active subscription. Please cancel the subscription first before deleting the workspace.', + has_active_subscription: true, + subscription_status: subscriptionInfo.status, + }); + } + } + // Require confirmation by typing the organization name if (!confirmation || confirmation !== org.name) { return res.status(400).json({ @@ -4658,6 +4695,16 @@ export class HTTPServer { }); } + // Record audit log before deletion (while org still exists) + await orgDb.recordAuditLog({ + workos_organization_id: orgId, + workos_user_id: user.id, + action: 'organization_deleted', + resource_type: 'organization', + resource_id: orgId, + details: { name: org.name, deleted_by: 'self_service', user_email: user.email }, + }); + // Delete from WorkOS try { await workos!.organizations.deleteOrganization(orgId); diff --git a/server/tests/integration/admin-endpoints.test.ts b/server/tests/integration/admin-endpoints.test.ts index 8e81a41561..a5087dc2b2 100644 --- a/server/tests/integration/admin-endpoints.test.ts +++ b/server/tests/integration/admin-endpoints.test.ts @@ -9,7 +9,7 @@ import type { Pool } from 'pg'; vi.mock('../../src/middleware/auth.js', () => ({ requireAuth: (req: any, res: any, next: any) => { req.user = { - workos_user_id: 'user_test_admin', + id: 'user_test_admin', email: 'admin@test.com', is_admin: true }; @@ -18,6 +18,15 @@ vi.mock('../../src/middleware/auth.js', () => ({ requireAdmin: (req: any, res: any, next: any) => next(), })); +// Mock Stripe client to control subscription checks +vi.mock('../../src/billing/stripe-client.js', () => ({ + stripe: null, + getSubscriptionInfo: vi.fn().mockResolvedValue(null), + createStripeCustomer: vi.fn().mockResolvedValue(null), + createCustomerSession: vi.fn().mockResolvedValue(null), + createBillingPortalSession: vi.fn().mockResolvedValue(null), +})); + describe('Admin Endpoints Integration Tests', () => { let server: HTTPServer; let app: any; @@ -369,5 +378,45 @@ describe('Admin Endpoints Integration Tests', () => { ); expect(afterResult.rows.length).toBe(0); }); + + it('should prevent deletion of organization with active subscription', async () => { + // Create org with active subscription + const SUB_ORG_ID = 'org_delete_test_sub'; + await pool.query( + `INSERT INTO organizations (workos_organization_id, name, stripe_customer_id, created_at, updated_at) + VALUES ($1, $2, $3, NOW(), NOW()) + ON CONFLICT (workos_organization_id) DO UPDATE SET name = $2, stripe_customer_id = $3`, + [SUB_ORG_ID, 'Subscribed Test Org', 'cus_sub_admin_test'] + ); + + // Mock getSubscriptionInfo to return active subscription + const { getSubscriptionInfo } = await import('../../src/billing/stripe-client.js'); + vi.mocked(getSubscriptionInfo).mockResolvedValueOnce({ + status: 'active', + product_id: 'prod_test', + product_name: 'Test Product', + current_period_end: Math.floor(Date.now() / 1000) + 86400, + cancel_at_period_end: false, + }); + + const response = await request(app) + .delete(`/api/admin/members/${SUB_ORG_ID}`) + .send({ confirmation: 'Subscribed Test Org' }) + .expect(400); + + expect(response.body.error).toBe('Cannot delete workspace with active subscription'); + expect(response.body.has_active_subscription).toBe(true); + expect(response.body.subscription_status).toBe('active'); + + // Verify org still exists + const checkResult = await pool.query( + 'SELECT 1 FROM organizations WHERE workos_organization_id = $1', + [SUB_ORG_ID] + ); + expect(checkResult.rows.length).toBe(1); + + // Clean up + await pool.query('DELETE FROM organizations WHERE workos_organization_id = $1', [SUB_ORG_ID]); + }); }); }); diff --git a/server/tests/integration/self-service-delete.test.ts b/server/tests/integration/self-service-delete.test.ts index f6379d25df..9841d38032 100644 --- a/server/tests/integration/self-service-delete.test.ts +++ b/server/tests/integration/self-service-delete.test.ts @@ -13,7 +13,7 @@ const TEST_ORG_ID = 'org_self_delete_test'; vi.mock('../../src/middleware/auth.js', () => ({ requireAuth: (req: any, res: any, next: any) => { req.user = { - workos_user_id: TEST_USER_ID, + id: TEST_USER_ID, email: 'owner@test.com', is_admin: false }; @@ -24,6 +24,15 @@ vi.mock('../../src/middleware/auth.js', () => ({ }, })); +// Mock Stripe client to control subscription checks +vi.mock('../../src/billing/stripe-client.js', () => ({ + stripe: null, + getSubscriptionInfo: vi.fn().mockResolvedValue(null), + createStripeCustomer: vi.fn().mockResolvedValue(null), + createCustomerSession: vi.fn().mockResolvedValue(null), + createBillingPortalSession: vi.fn().mockResolvedValue(null), +})); + // Mock WorkOS client to return membership with owner role vi.mock('@workos-inc/node', () => ({ WorkOS: vi.fn().mockImplementation(() => ({ @@ -256,5 +265,60 @@ describe('Self-Service Delete Workspace', () => { expect(response.body.error).toBe('Insufficient permissions'); }); + + it('should prevent deletion of organization with active subscription', async () => { + // Create org with stripe customer + const SUB_ORG_ID = 'org_self_delete_sub'; + await pool.query( + `INSERT INTO organizations (workos_organization_id, name, stripe_customer_id, created_at, updated_at) + VALUES ($1, $2, $3, NOW(), NOW()) + ON CONFLICT (workos_organization_id) DO UPDATE SET name = $2, stripe_customer_id = $3`, + [SUB_ORG_ID, 'Subscribed Org', 'cus_sub_test'] + ); + + // Mock WorkOS to return owner for this org + const { WorkOS } = await import('@workos-inc/node'); + const mockWorkOS = new WorkOS('test'); + vi.mocked(mockWorkOS.userManagement.listOrganizationMemberships).mockImplementation(({ organizationId }) => { + if (organizationId === SUB_ORG_ID) { + return Promise.resolve({ + data: [{ + id: 'om_sub', + userId: TEST_USER_ID, + organizationId: SUB_ORG_ID, + role: { slug: 'owner' }, + status: 'active' + }] + }); + } + return Promise.resolve({ data: [] }); + }); + + // Mock getSubscriptionInfo to return active subscription + const { getSubscriptionInfo } = await import('../../src/billing/stripe-client.js'); + vi.mocked(getSubscriptionInfo).mockResolvedValueOnce({ + status: 'active', + product_id: 'prod_test', + product_name: 'Test Product', + current_period_end: Math.floor(Date.now() / 1000) + 86400, + cancel_at_period_end: false, + }); + + const response = await request(app) + .delete(`/api/organizations/${SUB_ORG_ID}`) + .send({ confirmation: 'Subscribed Org' }) + .expect(400); + + expect(response.body.error).toBe('Cannot delete workspace with active subscription'); + expect(response.body.has_active_subscription).toBe(true); + expect(response.body.subscription_status).toBe('active'); + + // Verify org still exists + const checkResult = await pool.query( + 'SELECT 1 FROM organizations WHERE workos_organization_id = $1', + [SUB_ORG_ID] + ); + expect(checkResult.rows.length).toBe(1); + }); }); });