From 1c96c3a85e3c455c8c77ed510e1dff15f0919629 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 7 Feb 2026 08:02:33 +0100 Subject: [PATCH] feat: Audit remediation + Stripe webhook + test suites - Apply 3 Prisma schema changes (Pending2FASession, hubApiKeyHash, SecurityVerificationCode attempts) - Add Stripe webhook handler (checkout.session.completed -> User + Subscription + Order) - Add stripe-service, api-key-service, rate-limit middleware - Add security headers (CSP, HSTS, X-Frame-Options) in next.config.ts - Harden auth routes, require ADMIN_API_KEY for orchestrator endpoints - Add Docker auto-migration via startup.sh - Add 7 unit test suites (api-key, dns, config-generator, automation-worker, permission, security-verification, auth-helpers) - Fix Prisma 7 compatibility with adapter-pg mock for vitest Co-Authored-By: Claude Opus 4.6 --- .env.example | 23 +- .gitignore | 2 + Dockerfile | 17 +- docker-compose.yml | 7 + next.config.ts | 53 ++ package.json | 1 + .../migration.sql | 34 ++ prisma/schema.prisma | 28 +- src/__tests__/mocks/prisma-adapter-pg.ts | 10 + src/__tests__/unit/lib/auth-helpers.test.ts | 308 ++++++++++++ .../unit/lib/services/api-key-service.test.ts | 193 ++++++++ .../lib/services/automation-worker.test.ts | 458 ++++++++++++++++++ .../lib/services/config-generator.test.ts | 237 +++++++++ .../unit/lib/services/dns-service.test.ts | 456 +++++++++++++++++ .../lib/services/permission-service.test.ts | 233 +++++++++ .../security-verification-service.test.ts | 296 +++++++++++ src/app/api/auth/[...nextauth]/route.ts | 31 +- src/app/api/cron/cleanup-stats/route.ts | 9 +- src/app/api/cron/collect-stats/route.ts | 9 +- .../v1/admin/orders/[id]/provision/route.ts | 14 +- src/app/api/v1/admin/orders/[id]/route.ts | 13 +- .../v1/admin/servers/[id]/command/route.ts | 86 +++- src/app/api/v1/orchestrator/commands/route.ts | 9 +- .../api/v1/orchestrator/heartbeat/route.ts | 18 +- src/app/api/v1/orchestrator/register/route.ts | 8 +- src/app/api/v1/webhooks/stripe/route.ts | 211 ++++++++ src/lib/auth.ts | 78 +-- src/lib/rate-limit.ts | 79 +++ src/lib/services/api-key-service.ts | 82 ++++ src/lib/services/email-service.ts | 78 +++ src/lib/services/notification-service.ts | 75 +-- .../services/security-verification-service.ts | 129 +++-- src/lib/services/storage-service.ts | 17 + src/lib/services/stripe-service.ts | 161 ++++++ startup.sh | 10 + vitest.config.ts | 6 + 36 files changed, 3255 insertions(+), 224 deletions(-) create mode 100644 prisma/migrations/20260207001800_audit_remediation_schema_changes/migration.sql create mode 100644 src/__tests__/mocks/prisma-adapter-pg.ts create mode 100644 src/__tests__/unit/lib/auth-helpers.test.ts create mode 100644 src/__tests__/unit/lib/services/api-key-service.test.ts create mode 100644 src/__tests__/unit/lib/services/automation-worker.test.ts create mode 100644 src/__tests__/unit/lib/services/config-generator.test.ts create mode 100644 src/__tests__/unit/lib/services/dns-service.test.ts create mode 100644 src/__tests__/unit/lib/services/permission-service.test.ts create mode 100644 src/__tests__/unit/lib/services/security-verification-service.test.ts create mode 100644 src/app/api/v1/webhooks/stripe/route.ts create mode 100644 src/lib/rate-limit.ts create mode 100644 src/lib/services/api-key-service.ts create mode 100644 src/lib/services/stripe-service.ts create mode 100644 startup.sh diff --git a/.env.example b/.env.example index 249c937..97eb687 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ # LetsBe Hub Configuration # Database -DATABASE_URL=postgresql+asyncpg://hub:hub@db:5432/hub +DATABASE_URL=postgresql://hub:hub@db:5432/hub # Admin API Key (CHANGE IN PRODUCTION!) ADMIN_API_KEY=change-me-in-production @@ -11,3 +11,24 @@ DEBUG=false # Telemetry retention (days) TELEMETRY_RETENTION_DAYS=90 + +# ============================================================================= +# Email (Resend) +# ============================================================================= +# API key from https://resend.com +# RESEND_API_KEY=re_xxxxxxxxxx +# Sender email address (must be verified in Resend) +# RESEND_FROM_EMAIL=noreply@yourdomain.com + +# ============================================================================= +# Cron / Scheduled Tasks +# ============================================================================= +# Secret used to authenticate cron job requests +# Generate with: openssl rand -hex 32 +# CRON_SECRET= + +# ============================================================================= +# Public API +# ============================================================================= +# API key exposed to client-side code (non-sensitive, for rate limiting etc.) +# PUBLIC_API_KEY= diff --git a/.gitignore b/.gitignore index 17973e9..fac49b7 100644 --- a/.gitignore +++ b/.gitignore @@ -18,8 +18,10 @@ coverage/ .env.development.local .env.test.local .env.production.local +deploy/.env !.env.example !.env.local.example +!deploy/.env.example # IDE .idea/ diff --git a/Dockerfile b/Dockerfile index 0fcaf71..2c53c79 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,8 +9,9 @@ WORKDIR /app COPY package.json package-lock.json* ./ RUN npm install -# Generate Prisma Client +# Generate Prisma Client (Prisma 7 uses prisma.config.mjs for datasource URL) COPY prisma ./prisma/ +COPY prisma.config.mjs ./ RUN npx prisma generate # Rebuild the source code only when needed @@ -61,14 +62,20 @@ RUN chown nextjs:nodejs .next COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static -# Copy Prisma (client, schema, and config for migrations) +# Copy Prisma client and schema (for runtime + migrations) COPY --from=deps /app/node_modules/.prisma ./node_modules/.prisma COPY --from=deps /app/node_modules/@prisma ./node_modules/@prisma COPY prisma ./prisma/ COPY prisma.config.mjs ./ -# Install Prisma CLI and dotenv globally for migrations -RUN npm install -g prisma@7 dotenv +# Install Prisma CLI globally for running migrations on startup +# (copying just node_modules/prisma misses transitive deps like valibot) +RUN npm install -g prisma@7 + +# Copy startup script (runs migrations before starting app) +# Use tr to strip Windows CRLF line endings (more reliable than sed on Alpine) +COPY startup.sh /tmp/startup.sh +RUN tr -d '\r' < /tmp/startup.sh > startup.sh && chmod +x startup.sh && rm /tmp/startup.sh USER nextjs @@ -77,4 +84,4 @@ EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" -CMD ["node", "server.js"] +CMD ["./startup.sh"] diff --git a/docker-compose.yml b/docker-compose.yml index b909be5..893fa7d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,13 @@ services: CREDENTIAL_ENCRYPTION_KEY: letsbe-hub-credential-encryption-key-dev-only # Encryption key for settings service (SMTP passwords, tokens, etc.) SETTINGS_ENCRYPTION_KEY: letsbe-hub-settings-encryption-key-dev-only + # Email sending via Resend (optional in dev) + # RESEND_API_KEY: "" + # RESEND_FROM_EMAIL: "" + # Cron job secret for scheduled tasks + # CRON_SECRET: "" + # Public API key for client-side usage + # PUBLIC_API_KEY: "" # Host paths for job config files (used when spawning runner containers) # On Windows with Docker Desktop, use /c/Repos/... format JOBS_HOST_DIR: /c/Repos/LetsBeV2_NoAISysAdmin/letsbe-hub/jobs diff --git a/next.config.ts b/next.config.ts index e28ecae..37cb91e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,5 +1,50 @@ import type { NextConfig } from 'next' +const securityHeaders = [ + { + key: 'X-DNS-Prefetch-Control', + value: 'on', + }, + { + key: 'Strict-Transport-Security', + value: 'max-age=63072000; includeSubDomains; preload', + }, + { + key: 'X-Content-Type-Options', + value: 'nosniff', + }, + { + key: 'X-Frame-Options', + value: 'SAMEORIGIN', + }, + { + key: 'X-XSS-Protection', + value: '1; mode=block', + }, + { + key: 'Referrer-Policy', + value: 'strict-origin-when-cross-origin', + }, + { + key: 'Permissions-Policy', + value: 'camera=(), microphone=(), geolocation=()', + }, + { + key: 'Content-Security-Policy', + value: [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob: https://*.letsbe.solutions", + "font-src 'self' data:", + "connect-src 'self' https://*.letsbe.solutions", + "frame-ancestors 'self'", + "base-uri 'self'", + "form-action 'self'", + ].join('; '), + }, +] + const nextConfig: NextConfig = { output: 'standalone', // reactCompiler: true, // Requires babel-plugin-react-compiler - enable later @@ -31,6 +76,14 @@ const nextConfig: NextConfig = { }, // Externalize ssh2 for both Turbopack and Webpack serverExternalPackages: ['ssh2'], + async headers() { + return [ + { + source: '/(.*)', + headers: securityHeaders, + }, + ] + }, } export default nextConfig diff --git a/package.json b/package.json index 64e108b..31a0382 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "react-hook-form": "^7.54.2", "recharts": "^3.6.0", "ssh2": "^1.17.0", + "stripe": "^17.0.0", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "undici": "^7.18.2", diff --git a/prisma/migrations/20260207001800_audit_remediation_schema_changes/migration.sql b/prisma/migrations/20260207001800_audit_remediation_schema_changes/migration.sql new file mode 100644 index 0000000..5ee831c --- /dev/null +++ b/prisma/migrations/20260207001800_audit_remediation_schema_changes/migration.sql @@ -0,0 +1,34 @@ +-- AlterTable: Add brute-force attempt tracking to security verification codes +ALTER TABLE "security_verification_codes" ADD COLUMN "attempts" INTEGER NOT NULL DEFAULT 0; + +-- AlterTable: Add hash-based API key lookup to server connections +ALTER TABLE "server_connections" ADD COLUMN "hub_api_key_hash" TEXT; + +-- CreateTable: DB-backed 2FA sessions (replacing in-memory Map) +CREATE TABLE "pending_2fa_sessions" ( + "id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "user_type" TEXT NOT NULL, + "email" TEXT NOT NULL, + "name" TEXT, + "role" TEXT, + "company" TEXT, + "subscription" JSONB, + "expires_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "pending_2fa_sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "pending_2fa_sessions_token_key" ON "pending_2fa_sessions"("token"); + +-- CreateIndex +CREATE INDEX "pending_2fa_sessions_token_idx" ON "pending_2fa_sessions"("token"); + +-- CreateIndex +CREATE INDEX "pending_2fa_sessions_expires_at_idx" ON "pending_2fa_sessions"("expires_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "server_connections_hub_api_key_hash_key" ON "server_connections"("hub_api_key_hash"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 29a4614..b98c682 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7,7 +7,7 @@ generator client { datasource db { provider = "postgresql" - // url configured in prisma.config.ts (Prisma 7+) + // url configured in prisma.config.mjs (Prisma 7+) } // ============================================================================ @@ -417,6 +417,7 @@ model ServerConnection { // Hub API key (issued after successful registration, used for heartbeats/commands) hubApiKey String? @unique @map("hub_api_key") + hubApiKeyHash String? @unique @map("hub_api_key_hash") // Orchestrator connection info (provided during registration) orchestratorUrl String? @map("orchestrator_url") @@ -613,11 +614,12 @@ model DetectedError { model SecurityVerificationCode { id String @id @default(cuid()) clientId String @map("client_id") - code String // 6-digit code + code String // 8-digit code action String // "WIPE" | "REINSTALL" targetServerId String @map("target_server_id") // Which server expiresAt DateTime @map("expires_at") usedAt DateTime? @map("used_at") + attempts Int @default(0) // Failed verification attempts createdAt DateTime @default(now()) @map("created_at") // Relations @@ -705,6 +707,28 @@ model NotificationSetting { @@map("notification_settings") } +// ============================================================================ +// PENDING 2FA SESSIONS +// ============================================================================ + +model Pending2FASession { + id String @id @default(cuid()) + token String @unique + userId String @map("user_id") + userType String @map("user_type") // 'customer' | 'staff' + email String + name String? + role String? // StaffRole for staff users + company String? + subscription Json? // Subscription data for customer users + expiresAt DateTime @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + + @@index([token]) + @@index([expiresAt]) + @@map("pending_2fa_sessions") +} + // ============================================================================ // SYSTEM-WIDE NOTIFICATION COOLDOWN // ============================================================================ diff --git a/src/__tests__/mocks/prisma-adapter-pg.ts b/src/__tests__/mocks/prisma-adapter-pg.ts new file mode 100644 index 0000000..1fe051e --- /dev/null +++ b/src/__tests__/mocks/prisma-adapter-pg.ts @@ -0,0 +1,10 @@ +/** + * Mock for @prisma/adapter-pg (Prisma 7 driver adapter). + * The actual adapter isn't available locally on Node 23, + * but tests mock the entire prisma module anyway. + */ +export class PrismaPg { + constructor(_opts: { connectionString: string }) { + // no-op mock + } +} diff --git a/src/__tests__/unit/lib/auth-helpers.test.ts b/src/__tests__/unit/lib/auth-helpers.test.ts new file mode 100644 index 0000000..8ae42c2 --- /dev/null +++ b/src/__tests__/unit/lib/auth-helpers.test.ts @@ -0,0 +1,308 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { StaffRole } from '@prisma/client' + +// Mock the auth module +const mockAuth = vi.fn() +vi.mock('@/lib/auth', () => ({ + auth: mockAuth, +})) + +// Mock the permission service +vi.mock('@/lib/services/permission-service', () => ({ + hasPermission: vi.fn((role: string, permission: string) => { + // Simplified permission check for testing + const ROLE_PERMISSIONS: Record = { + OWNER: [ + 'dashboard:view', 'orders:view', 'orders:create', 'orders:edit', + 'orders:provision', 'orders:delete', 'customers:view', 'customers:create', + 'customers:edit', 'customers:delete', 'servers:view', 'servers:power', + 'servers:snapshots', 'servers:rescue', 'staff:view', 'staff:invite', + 'staff:manage', 'staff:delete', 'settings:view', 'settings:edit', + 'enterprise:view', 'enterprise:manage', + ], + ADMIN: [ + 'dashboard:view', 'orders:view', 'orders:create', 'orders:edit', + 'orders:provision', 'orders:delete', 'customers:view', 'customers:create', + 'customers:edit', 'customers:delete', 'servers:view', 'servers:power', + 'servers:snapshots', 'servers:rescue', 'staff:view', 'staff:invite', + 'staff:manage', 'settings:view', 'settings:edit', + 'enterprise:view', 'enterprise:manage', + ], + MANAGER: [ + 'dashboard:view', 'orders:view', 'orders:create', 'orders:edit', + 'orders:provision', 'customers:view', 'customers:create', 'customers:edit', + 'servers:view', 'servers:power', 'servers:snapshots', 'servers:rescue', + 'enterprise:view', 'enterprise:manage', + ], + SUPPORT: [ + 'dashboard:view', 'orders:view', 'customers:view', 'servers:view', + 'enterprise:view', + ], + } + return ROLE_PERMISSIONS[role]?.includes(permission) ?? false + }), +})) + +import { requireStaffPermission, requireStaffSession } from '@/lib/auth-helpers' + +describe('Auth Helpers', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('requireStaffPermission', () => { + it('should throw 401 when no session exists', async () => { + mockAuth.mockResolvedValue(null) + + try { + await requireStaffPermission('orders:view') + expect.fail('Should have thrown') + } catch (error: any) { + expect(error.status).toBe(401) + expect(error.message).toBe('Unauthorized') + } + }) + + it('should throw 401 when session has no user', async () => { + mockAuth.mockResolvedValue({ user: null }) + + try { + await requireStaffPermission('orders:view') + expect.fail('Should have thrown') + } catch (error: any) { + expect(error.status).toBe(401) + expect(error.message).toBe('Unauthorized') + } + }) + + it('should throw 403 when user is not staff', async () => { + mockAuth.mockResolvedValue({ + user: { + id: 'user-123', + email: 'customer@example.com', + name: 'Customer', + userType: 'customer', + }, + }) + + try { + await requireStaffPermission('orders:view') + expect.fail('Should have thrown') + } catch (error: any) { + expect(error.status).toBe(403) + expect(error.message).toBe('Staff access required') + } + }) + + it('should throw 403 when staff lacks required permission', async () => { + mockAuth.mockResolvedValue({ + user: { + id: 'staff-123', + email: 'support@letsbe.cloud', + name: 'Support Staff', + userType: 'staff', + role: 'SUPPORT', + }, + }) + + try { + await requireStaffPermission('staff:delete') + expect.fail('Should have thrown') + } catch (error: any) { + expect(error.status).toBe(403) + expect(error.message).toBe('Insufficient permissions') + } + }) + + it('should return session for OWNER with any permission', async () => { + mockAuth.mockResolvedValue({ + user: { + id: 'staff-owner', + email: 'owner@letsbe.cloud', + name: 'Owner', + userType: 'staff', + role: 'OWNER', + }, + }) + + const session = await requireStaffPermission('staff:delete') + + expect(session.user.id).toBe('staff-owner') + expect(session.user.role).toBe('OWNER') + expect(session.user.userType).toBe('staff') + }) + + it('should return session for ADMIN with orders:view permission', async () => { + mockAuth.mockResolvedValue({ + user: { + id: 'staff-admin', + email: 'admin@letsbe.cloud', + name: 'Admin', + userType: 'staff', + role: 'ADMIN', + }, + }) + + const session = await requireStaffPermission('orders:view') + + expect(session.user.id).toBe('staff-admin') + expect(session.user.role).toBe('ADMIN') + }) + + it('should deny ADMIN staff:delete permission', async () => { + mockAuth.mockResolvedValue({ + user: { + id: 'staff-admin', + email: 'admin@letsbe.cloud', + name: 'Admin', + userType: 'staff', + role: 'ADMIN', + }, + }) + + try { + await requireStaffPermission('staff:delete') + expect.fail('Should have thrown') + } catch (error: any) { + expect(error.status).toBe(403) + expect(error.message).toBe('Insufficient permissions') + } + }) + + it('should allow MANAGER orders:create permission', async () => { + mockAuth.mockResolvedValue({ + user: { + id: 'staff-manager', + email: 'manager@letsbe.cloud', + name: 'Manager', + userType: 'staff', + role: 'MANAGER', + }, + }) + + const session = await requireStaffPermission('orders:create') + + expect(session.user.role).toBe('MANAGER') + }) + + it('should deny MANAGER settings:edit permission', async () => { + mockAuth.mockResolvedValue({ + user: { + id: 'staff-manager', + email: 'manager@letsbe.cloud', + name: 'Manager', + userType: 'staff', + role: 'MANAGER', + }, + }) + + try { + await requireStaffPermission('settings:edit') + expect.fail('Should have thrown') + } catch (error: any) { + expect(error.status).toBe(403) + } + }) + + it('should deny SUPPORT orders:create permission', async () => { + mockAuth.mockResolvedValue({ + user: { + id: 'staff-support', + email: 'support@letsbe.cloud', + name: 'Support', + userType: 'staff', + role: 'SUPPORT', + }, + }) + + try { + await requireStaffPermission('orders:create') + expect.fail('Should have thrown') + } catch (error: any) { + expect(error.status).toBe(403) + } + }) + + it('should allow SUPPORT orders:view permission', async () => { + mockAuth.mockResolvedValue({ + user: { + id: 'staff-support', + email: 'support@letsbe.cloud', + name: 'Support', + userType: 'staff', + role: 'SUPPORT', + }, + }) + + const session = await requireStaffPermission('orders:view') + + expect(session.user.role).toBe('SUPPORT') + }) + + it('should set email to empty string when not provided', async () => { + mockAuth.mockResolvedValue({ + user: { + id: 'staff-123', + email: null, + name: 'No Email', + userType: 'staff', + role: 'OWNER', + }, + }) + + const session = await requireStaffPermission('dashboard:view') + + expect(session.user.email).toBe('') + }) + }) + + describe('requireStaffSession', () => { + it('should throw 401 when no session exists', async () => { + mockAuth.mockResolvedValue(null) + + try { + await requireStaffSession() + expect.fail('Should have thrown') + } catch (error: any) { + expect(error.status).toBe(401) + expect(error.message).toBe('Unauthorized') + } + }) + + it('should throw 403 when user is not staff', async () => { + mockAuth.mockResolvedValue({ + user: { + id: 'customer-123', + email: 'customer@example.com', + userType: 'customer', + }, + }) + + try { + await requireStaffSession() + expect.fail('Should have thrown') + } catch (error: any) { + expect(error.status).toBe(403) + expect(error.message).toBe('Staff access required') + } + }) + + it('should return session for any staff role without permission check', async () => { + mockAuth.mockResolvedValue({ + user: { + id: 'staff-support', + email: 'support@letsbe.cloud', + name: 'Support', + userType: 'staff', + role: 'SUPPORT', + }, + }) + + const session = await requireStaffSession() + + expect(session.user.id).toBe('staff-support') + expect(session.user.userType).toBe('staff') + expect(session.user.role).toBe('SUPPORT') + }) + }) +}) diff --git a/src/__tests__/unit/lib/services/api-key-service.test.ts b/src/__tests__/unit/lib/services/api-key-service.test.ts new file mode 100644 index 0000000..02d190c --- /dev/null +++ b/src/__tests__/unit/lib/services/api-key-service.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { prismaMock, resetPrismaMock } from '../../../mocks/prisma' +import crypto from 'crypto' + +// Import after prisma mock is set up +import { apiKeyService } from '@/lib/services/api-key-service' + +describe('ApiKeyService', () => { + beforeEach(() => { + resetPrismaMock() + }) + + describe('generateKey', () => { + it('should generate a key with hk_ prefix', () => { + const key = apiKeyService.generateKey() + + expect(key).toMatch(/^hk_/) + }) + + it('should generate a 68-character key (hk_ + 64 hex chars)', () => { + const key = apiKeyService.generateKey() + + // "hk_" (3 chars) + 32 bytes hex (64 chars) = 67 chars + expect(key).toHaveLength(67) + }) + + it('should generate unique keys each time', () => { + const key1 = apiKeyService.generateKey() + const key2 = apiKeyService.generateKey() + + expect(key1).not.toBe(key2) + }) + + it('should generate key with valid hex after prefix', () => { + const key = apiKeyService.generateKey() + const hexPart = key.slice(3) + + expect(hexPart).toMatch(/^[0-9a-f]+$/) + }) + }) + + describe('hashKey', () => { + it('should return a SHA-256 hex hash', () => { + const key = 'hk_test-api-key' + const hash = apiKeyService.hashKey(key) + + // SHA-256 produces 64 hex chars + expect(hash).toHaveLength(64) + expect(hash).toMatch(/^[0-9a-f]+$/) + }) + + it('should produce consistent hashes for the same input', () => { + const key = 'hk_same-key' + + const hash1 = apiKeyService.hashKey(key) + const hash2 = apiKeyService.hashKey(key) + + expect(hash1).toBe(hash2) + }) + + it('should produce different hashes for different inputs', () => { + const hash1 = apiKeyService.hashKey('hk_key-one') + const hash2 = apiKeyService.hashKey('hk_key-two') + + expect(hash1).not.toBe(hash2) + }) + + it('should match Node.js crypto SHA-256 output', () => { + const key = 'hk_verification-key' + const expectedHash = crypto.createHash('sha256').update(key).digest('hex') + + const hash = apiKeyService.hashKey(key) + + expect(hash).toBe(expectedHash) + }) + }) + + describe('findByApiKey', () => { + const mockConnection = { + id: 'conn-123', + hubApiKey: 'hk_plain-key', + hubApiKeyHash: null, + orderId: 'order-456', + order: { + id: 'order-456', + domain: 'test.letsbe.cloud', + serverIp: '192.168.1.100', + }, + } + + it('should find connection by hash (preferred path)', async () => { + const apiKey = 'hk_test-key' + const hash = crypto.createHash('sha256').update(apiKey).digest('hex') + + prismaMock.serverConnection.findUnique.mockResolvedValueOnce({ + ...mockConnection, + hubApiKeyHash: hash, + } as any) + + const result = await apiKeyService.findByApiKey(apiKey) + + expect(result).toBeDefined() + // First call should be hash lookup + expect(prismaMock.serverConnection.findUnique).toHaveBeenCalledWith({ + where: { hubApiKeyHash: hash }, + include: { + order: { + select: { + id: true, + domain: true, + serverIp: true, + }, + }, + }, + }) + }) + + it('should fallback to plaintext lookup when hash not found', async () => { + const apiKey = 'hk_legacy-key' + + // First call (hash lookup) returns null + prismaMock.serverConnection.findUnique.mockResolvedValueOnce(null) + // Second call (plaintext lookup) returns the connection + prismaMock.serverConnection.findUnique.mockResolvedValueOnce({ + ...mockConnection, + hubApiKey: apiKey, + hubApiKeyHash: null, + } as any) + prismaMock.serverConnection.update.mockResolvedValue({} as any) + + const result = await apiKeyService.findByApiKey(apiKey) + + expect(result).toBeDefined() + // Should have been called twice: hash lookup, then plaintext + expect(prismaMock.serverConnection.findUnique).toHaveBeenCalledTimes(2) + expect(prismaMock.serverConnection.findUnique).toHaveBeenNthCalledWith(2, { + where: { hubApiKey: apiKey }, + include: expect.any(Object), + }) + }) + + it('should migrate plaintext key to hash when found via fallback', async () => { + const apiKey = 'hk_migrate-me' + const expectedHash = crypto.createHash('sha256').update(apiKey).digest('hex') + + // Hash lookup fails + prismaMock.serverConnection.findUnique.mockResolvedValueOnce(null) + // Plaintext lookup succeeds with no hash + prismaMock.serverConnection.findUnique.mockResolvedValueOnce({ + ...mockConnection, + id: 'conn-migrate', + hubApiKey: apiKey, + hubApiKeyHash: null, + } as any) + prismaMock.serverConnection.update.mockResolvedValue({} as any) + + await apiKeyService.findByApiKey(apiKey) + + // Should have triggered migration update + expect(prismaMock.serverConnection.update).toHaveBeenCalledWith({ + where: { id: 'conn-migrate' }, + data: { hubApiKeyHash: expectedHash }, + }) + }) + + it('should not migrate if hash already exists', async () => { + const apiKey = 'hk_already-migrated' + const hash = crypto.createHash('sha256').update(apiKey).digest('hex') + + // Hash lookup fails + prismaMock.serverConnection.findUnique.mockResolvedValueOnce(null) + // Plaintext lookup succeeds but hash already set + prismaMock.serverConnection.findUnique.mockResolvedValueOnce({ + ...mockConnection, + hubApiKey: apiKey, + hubApiKeyHash: hash, + } as any) + + await apiKeyService.findByApiKey(apiKey) + + // Should NOT have triggered migration + expect(prismaMock.serverConnection.update).not.toHaveBeenCalled() + }) + + it('should return null when key not found by either method', async () => { + prismaMock.serverConnection.findUnique.mockResolvedValue(null) + + const result = await apiKeyService.findByApiKey('hk_nonexistent') + + expect(result).toBeNull() + }) + }) +}) diff --git a/src/__tests__/unit/lib/services/automation-worker.test.ts b/src/__tests__/unit/lib/services/automation-worker.test.ts new file mode 100644 index 0000000..3e20d5a --- /dev/null +++ b/src/__tests__/unit/lib/services/automation-worker.test.ts @@ -0,0 +1,458 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { prismaMock, resetPrismaMock } from '../../../mocks/prisma' +import { OrderStatus, AutomationMode, LogLevel } from '@prisma/client' + +import { + processAutomation, + setAutomationMode, + resumeAutomation, + pauseAutomation, + takeManualControl, + enableAutoMode, +} from '@/lib/services/automation-worker' + +describe('Automation Worker', () => { + beforeEach(() => { + resetPrismaMock() + // Suppress console.error from logAutomation error handling + vi.spyOn(console, 'error').mockImplementation(() => {}) + // Mock provisioningLog.create for all tests (used by logAutomation) + prismaMock.provisioningLog.create.mockResolvedValue({} as any) + }) + + describe('processAutomation', () => { + it('should return not triggered when order not found', async () => { + prismaMock.order.findUnique.mockResolvedValue(null) + + const result = await processAutomation('invalid-id') + + expect(result.triggered).toBe(false) + expect(result.error).toBe('Order not found') + }) + + it('should skip processing when mode is MANUAL', async () => { + prismaMock.order.findUnique.mockResolvedValue({ + id: 'order-123', + automationMode: AutomationMode.MANUAL, + status: OrderStatus.SERVER_READY, + } as any) + + const result = await processAutomation('order-123') + + expect(result.triggered).toBe(false) + expect(result.action).toContain('MANUAL') + }) + + it('should skip processing when mode is PAUSED', async () => { + prismaMock.order.findUnique.mockResolvedValue({ + id: 'order-123', + automationMode: AutomationMode.PAUSED, + status: OrderStatus.SERVER_READY, + } as any) + + const result = await processAutomation('order-123') + + expect(result.triggered).toBe(false) + expect(result.action).toContain('PAUSED') + }) + + it('should wait for server credentials in AWAITING_SERVER status', async () => { + prismaMock.order.findUnique.mockResolvedValue({ + id: 'order-123', + automationMode: AutomationMode.AUTO, + status: OrderStatus.AWAITING_SERVER, + serverIp: null, + serverPasswordEncrypted: null, + } as any) + + const result = await processAutomation('order-123') + + expect(result.triggered).toBe(false) + expect(result.action).toContain('Waiting for server credentials') + }) + + it('should auto-transition AWAITING_SERVER to SERVER_READY when credentials present', async () => { + // First call: AWAITING_SERVER with credentials + prismaMock.order.findUnique.mockResolvedValueOnce({ + id: 'order-123', + automationMode: AutomationMode.AUTO, + status: OrderStatus.AWAITING_SERVER, + serverIp: '10.0.0.1', + serverPasswordEncrypted: 'enc-password', + dnsVerification: null, + serverConnection: null, + } as any) + prismaMock.order.update.mockResolvedValue({} as any) + + // Recursive call after transition: now SERVER_READY + prismaMock.order.findUnique.mockResolvedValueOnce({ + id: 'order-123', + automationMode: AutomationMode.AUTO, + status: OrderStatus.SERVER_READY, + serverIp: '10.0.0.1', + serverPasswordEncrypted: 'enc-password', + dnsVerification: null, + serverConnection: null, + } as any) + + await processAutomation('order-123') + + // Should have updated to SERVER_READY + expect(prismaMock.order.update).toHaveBeenCalledWith({ + where: { id: 'order-123' }, + data: expect.objectContaining({ + status: OrderStatus.SERVER_READY, + serverReadyAt: expect.any(Date), + }), + }) + }) + + it('should auto-transition SERVER_READY to DNS_PENDING', async () => { + prismaMock.order.findUnique.mockResolvedValue({ + id: 'order-123', + automationMode: AutomationMode.AUTO, + status: OrderStatus.SERVER_READY, + dnsVerification: null, + serverConnection: null, + } as any) + prismaMock.order.update.mockResolvedValue({} as any) + + const result = await processAutomation('order-123') + + expect(result.triggered).toBe(true) + expect(result.action).toContain('DNS_PENDING') + expect(prismaMock.order.update).toHaveBeenCalledWith({ + where: { id: 'order-123' }, + data: { status: OrderStatus.DNS_PENDING }, + }) + }) + + it('should wait for DNS in DNS_PENDING status when not verified', async () => { + prismaMock.order.findUnique.mockResolvedValue({ + id: 'order-123', + automationMode: AutomationMode.AUTO, + status: OrderStatus.DNS_PENDING, + dnsVerification: { allPassed: false, manualOverride: false }, + serverConnection: null, + } as any) + + const result = await processAutomation('order-123') + + expect(result.triggered).toBe(false) + expect(result.action).toContain('Waiting for DNS verification') + }) + + it('should auto-transition DNS_PENDING to DNS_READY when verified', async () => { + // First call: DNS_PENDING with allPassed + prismaMock.order.findUnique.mockResolvedValueOnce({ + id: 'order-123', + automationMode: AutomationMode.AUTO, + status: OrderStatus.DNS_PENDING, + dnsVerification: { allPassed: true, manualOverride: false }, + serverConnection: null, + } as any) + prismaMock.order.update.mockResolvedValue({} as any) + + // Recursive call: DNS_READY + prismaMock.order.findUnique.mockResolvedValueOnce({ + id: 'order-123', + automationMode: AutomationMode.AUTO, + status: OrderStatus.DNS_READY, + dnsVerification: { allPassed: true, manualOverride: false }, + serverConnection: null, + } as any) + + await processAutomation('order-123') + + expect(prismaMock.order.update).toHaveBeenCalledWith({ + where: { id: 'order-123' }, + data: expect.objectContaining({ + status: OrderStatus.DNS_READY, + dnsVerifiedAt: expect.any(Date), + }), + }) + }) + + it('should auto-transition DNS_PENDING to DNS_READY when manually overridden', async () => { + prismaMock.order.findUnique.mockResolvedValueOnce({ + id: 'order-123', + automationMode: AutomationMode.AUTO, + status: OrderStatus.DNS_PENDING, + dnsVerification: { allPassed: false, manualOverride: true }, + serverConnection: null, + } as any) + prismaMock.order.update.mockResolvedValue({} as any) + + // Recursive call + prismaMock.order.findUnique.mockResolvedValueOnce({ + id: 'order-123', + automationMode: AutomationMode.AUTO, + status: OrderStatus.DNS_READY, + dnsVerification: { allPassed: false, manualOverride: true }, + serverConnection: null, + } as any) + + await processAutomation('order-123') + + expect(prismaMock.order.update).toHaveBeenCalledWith({ + where: { id: 'order-123' }, + data: expect.objectContaining({ + status: OrderStatus.DNS_READY, + }), + }) + }) + + it('should signal ready for provisioning in DNS_READY status', async () => { + prismaMock.order.findUnique.mockResolvedValue({ + id: 'order-123', + automationMode: AutomationMode.AUTO, + status: OrderStatus.DNS_READY, + dnsVerification: { allPassed: true }, + serverConnection: null, + } as any) + + const result = await processAutomation('order-123') + + expect(result.triggered).toBe(true) + expect(result.action).toContain('provision') + }) + + it('should report provisioning in progress', async () => { + prismaMock.order.findUnique.mockResolvedValue({ + id: 'order-123', + automationMode: AutomationMode.AUTO, + status: OrderStatus.PROVISIONING, + dnsVerification: null, + serverConnection: null, + } as any) + + const result = await processAutomation('order-123') + + expect(result.triggered).toBe(false) + expect(result.action).toContain('Provisioning in progress') + }) + + it('should report order fulfilled', async () => { + prismaMock.order.findUnique.mockResolvedValue({ + id: 'order-123', + automationMode: AutomationMode.AUTO, + status: OrderStatus.FULFILLED, + dnsVerification: null, + serverConnection: null, + } as any) + + const result = await processAutomation('order-123') + + expect(result.triggered).toBe(false) + expect(result.action).toContain('fulfilled') + }) + + it('should auto-pause on failure', async () => { + prismaMock.order.findUnique.mockResolvedValue({ + id: 'order-123', + automationMode: AutomationMode.AUTO, + status: OrderStatus.FAILED, + dnsVerification: null, + serverConnection: null, + } as any) + prismaMock.order.update.mockResolvedValue({} as any) + + const result = await processAutomation('order-123') + + expect(result.triggered).toBe(true) + expect(result.action).toContain('Paused') + expect(prismaMock.order.update).toHaveBeenCalledWith({ + where: { id: 'order-123' }, + data: expect.objectContaining({ + automationMode: AutomationMode.PAUSED, + automationPausedAt: expect.any(Date), + }), + }) + }) + }) + + describe('setAutomationMode', () => { + it('should update order to PAUSED with reason', async () => { + prismaMock.order.update.mockResolvedValue({} as any) + prismaMock.order.findUnique.mockResolvedValue(null) // For processAutomation if called + + await setAutomationMode('order-123', AutomationMode.PAUSED, 'Test pause reason') + + expect(prismaMock.order.update).toHaveBeenCalledWith({ + where: { id: 'order-123' }, + data: expect.objectContaining({ + automationMode: AutomationMode.PAUSED, + automationPausedAt: expect.any(Date), + automationPausedReason: 'Test pause reason', + }), + }) + }) + + it('should clear pause info when switching to AUTO', async () => { + prismaMock.order.update.mockResolvedValue({} as any) + // processAutomation is called after switching to AUTO + prismaMock.order.findUnique.mockResolvedValue({ + id: 'order-123', + automationMode: AutomationMode.AUTO, + status: OrderStatus.FULFILLED, + dnsVerification: null, + serverConnection: null, + } as any) + + await setAutomationMode('order-123', AutomationMode.AUTO) + + expect(prismaMock.order.update).toHaveBeenCalledWith({ + where: { id: 'order-123' }, + data: expect.objectContaining({ + automationMode: AutomationMode.AUTO, + automationPausedAt: null, + automationPausedReason: null, + }), + }) + }) + + it('should clear pause info when switching to MANUAL', async () => { + prismaMock.order.update.mockResolvedValue({} as any) + + await setAutomationMode('order-123', AutomationMode.MANUAL) + + expect(prismaMock.order.update).toHaveBeenCalledWith({ + where: { id: 'order-123' }, + data: expect.objectContaining({ + automationMode: AutomationMode.MANUAL, + automationPausedAt: null, + automationPausedReason: null, + }), + }) + }) + + it('should trigger processAutomation when switching to AUTO', async () => { + prismaMock.order.update.mockResolvedValue({} as any) + prismaMock.order.findUnique.mockResolvedValue({ + id: 'order-123', + automationMode: AutomationMode.AUTO, + status: OrderStatus.SERVER_READY, + dnsVerification: null, + serverConnection: null, + } as any) + + await setAutomationMode('order-123', AutomationMode.AUTO) + + // processAutomation should have been called (it reads order) + expect(prismaMock.order.findUnique).toHaveBeenCalled() + }) + }) + + describe('resumeAutomation', () => { + it('should return error when order not found', async () => { + prismaMock.order.findUnique.mockResolvedValue(null) + + const result = await resumeAutomation('invalid-id') + + expect(result.triggered).toBe(false) + expect(result.error).toBe('Order not found') + }) + + it('should return error when order is not paused', async () => { + prismaMock.order.findUnique.mockResolvedValue({ + id: 'order-123', + automationMode: AutomationMode.MANUAL, + } as any) + + const result = await resumeAutomation('order-123') + + expect(result.triggered).toBe(false) + expect(result.error).toBe('Order is not paused') + }) + + it('should resume paused order to AUTO mode', async () => { + // First call: check if paused + prismaMock.order.findUnique.mockResolvedValueOnce({ + id: 'order-123', + automationMode: AutomationMode.PAUSED, + } as any) + prismaMock.order.update.mockResolvedValue({} as any) + + // Second call from processAutomation after setAutomationMode + prismaMock.order.findUnique.mockResolvedValue({ + id: 'order-123', + automationMode: AutomationMode.AUTO, + status: OrderStatus.FULFILLED, + dnsVerification: null, + serverConnection: null, + } as any) + + const result = await resumeAutomation('order-123') + + expect(result.triggered).toBe(true) + expect(result.action).toBe('Automation resumed') + }) + }) + + describe('pauseAutomation', () => { + it('should set mode to PAUSED with reason', async () => { + prismaMock.order.update.mockResolvedValue({} as any) + + await pauseAutomation('order-123', 'Need to investigate') + + expect(prismaMock.order.update).toHaveBeenCalledWith({ + where: { id: 'order-123' }, + data: expect.objectContaining({ + automationMode: AutomationMode.PAUSED, + automationPausedReason: 'Need to investigate', + }), + }) + }) + + it('should use default reason when none provided', async () => { + prismaMock.order.update.mockResolvedValue({} as any) + + await pauseAutomation('order-123') + + expect(prismaMock.order.update).toHaveBeenCalledWith({ + where: { id: 'order-123' }, + data: expect.objectContaining({ + automationPausedReason: 'Manually paused by staff', + }), + }) + }) + }) + + describe('takeManualControl', () => { + it('should set mode to MANUAL', async () => { + prismaMock.order.update.mockResolvedValue({} as any) + + await takeManualControl('order-123') + + expect(prismaMock.order.update).toHaveBeenCalledWith({ + where: { id: 'order-123' }, + data: expect.objectContaining({ + automationMode: AutomationMode.MANUAL, + }), + }) + }) + }) + + describe('enableAutoMode', () => { + it('should set mode to AUTO and trigger processing', async () => { + prismaMock.order.update.mockResolvedValue({} as any) + prismaMock.order.findUnique.mockResolvedValue({ + id: 'order-123', + automationMode: AutomationMode.AUTO, + status: OrderStatus.DNS_READY, + dnsVerification: null, + serverConnection: null, + } as any) + + const result = await enableAutoMode('order-123') + + expect(result.triggered).toBe(true) + expect(prismaMock.order.update).toHaveBeenCalledWith({ + where: { id: 'order-123' }, + data: expect.objectContaining({ + automationMode: AutomationMode.AUTO, + }), + }) + }) + }) +}) diff --git a/src/__tests__/unit/lib/services/config-generator.test.ts b/src/__tests__/unit/lib/services/config-generator.test.ts new file mode 100644 index 0000000..328051f --- /dev/null +++ b/src/__tests__/unit/lib/services/config-generator.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { SubscriptionTier } from '@prisma/client' +import crypto from 'crypto' + +// Mock the credential service +vi.mock('@/lib/services/credential-service', () => ({ + credentialService: { + decrypt: vi.fn().mockReturnValue('decrypted-password'), + decryptLegacy: vi.fn().mockReturnValue('legacy-password'), + }, +})) + +import { + generateJobConfig, + generateRunnerToken, + hashRunnerToken, + verifyRunnerToken, + decryptPassword, +} from '@/lib/services/config-generator' +import { credentialService } from '@/lib/services/credential-service' + +describe('Config Generator', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('generateJobConfig', () => { + const validOrder = { + id: 'order-123', + serverIp: '192.168.1.100', + sshPort: 22, + domain: 'test.letsbe.cloud', + customer: 'Test Customer', + companyName: 'Test Company', + licenseKey: 'lb_inst_abc123', + tier: SubscriptionTier.HUB_DASHBOARD, + tools: ['orchestrator', 'sysadmin-agent', 'nextcloud'], + } as any + + it('should generate valid config from order', () => { + const config = generateJobConfig(validOrder, 'my-password') + + expect(config.server.ip).toBe('192.168.1.100') + expect(config.server.port).toBe(22) + expect(config.server.rootPassword).toBe('my-password') + expect(config.customer).toBe('Test Customer') + expect(config.domain).toBe('test.letsbe.cloud') + expect(config.companyName).toBe('Test Company') + expect(config.licenseKey).toBe('lb_inst_abc123') + expect(config.tools).toEqual(['orchestrator', 'sysadmin-agent', 'nextcloud']) + }) + + it('should map HUB_DASHBOARD tier correctly', () => { + const config = generateJobConfig(validOrder, 'password') + + expect(config.dashboardTier).toBe('HUB_DASHBOARD') + }) + + it('should map ADVANCED tier correctly', () => { + const order = { ...validOrder, tier: SubscriptionTier.ADVANCED } + const config = generateJobConfig(order, 'password') + + expect(config.dashboardTier).toBe('ADVANCED') + }) + + it('should default SSH port to 22 when not set', () => { + const order = { ...validOrder, sshPort: null } + const config = generateJobConfig(order, 'password') + + expect(config.server.port).toBe(22) + }) + + it('should set hubUrl from HUB_URL env var', () => { + const original = process.env.HUB_URL + process.env.HUB_URL = 'https://hub.letsbe.cloud' + + const config = generateJobConfig(validOrder, 'password') + + expect(config.hubUrl).toBe('https://hub.letsbe.cloud') + process.env.HUB_URL = original + }) + + it('should set hubTelemetryEnabled to true', () => { + const config = generateJobConfig(validOrder, 'password') + + expect(config.hubTelemetryEnabled).toBe(true) + }) + + it('should throw if server IP is missing', () => { + const order = { ...validOrder, serverIp: null } + + expect(() => generateJobConfig(order, 'password')).toThrow('missing server IP') + }) + + it('should throw if customer is missing', () => { + const order = { ...validOrder, customer: null } + + expect(() => generateJobConfig(order, 'password')).toThrow('missing customer identifier') + }) + + it('should throw if company name is missing', () => { + const order = { ...validOrder, companyName: null } + + expect(() => generateJobConfig(order, 'password')).toThrow('missing company name') + }) + + it('should throw if license key is missing', () => { + const order = { ...validOrder, licenseKey: null } + + expect(() => generateJobConfig(order, 'password')).toThrow('missing license key') + }) + + it('should include Docker Hub credentials when provided', () => { + const dockerHub = { + username: 'docker-user', + token: 'docker-token', + registry: 'https://registry.example.com', + } + + const config = generateJobConfig(validOrder, 'password', dockerHub) + + expect(config.dockerHub).toBeDefined() + expect(config.dockerHub?.username).toBe('docker-user') + expect(config.dockerHub?.token).toBe('docker-token') + expect(config.dockerHub?.registry).toBe('https://registry.example.com') + }) + + it('should not include Docker Hub when credentials are empty', () => { + const dockerHub = { username: '', token: '' } + + const config = generateJobConfig(validOrder, 'password', dockerHub) + + expect(config.dockerHub).toBeUndefined() + }) + + it('should include Gitea credentials when provided', () => { + const gitea = { + registry: 'https://code.letsbe.solutions', + username: 'gitea-user', + token: 'gitea-token', + } + + const config = generateJobConfig(validOrder, 'password', undefined, gitea) + + expect(config.gitea).toBeDefined() + expect(config.gitea?.registry).toBe('https://code.letsbe.solutions') + expect(config.gitea?.username).toBe('gitea-user') + expect(config.gitea?.token).toBe('gitea-token') + }) + + it('should not include Gitea when credentials are incomplete', () => { + const gitea = { registry: 'https://code.letsbe.solutions', username: '', token: '' } + + const config = generateJobConfig(validOrder, 'password', undefined, gitea) + + expect(config.gitea).toBeUndefined() + }) + }) + + describe('decryptPassword', () => { + it('should use new credential service decrypt by default', () => { + const result = decryptPassword('encrypted-value') + + expect(credentialService.decrypt).toHaveBeenCalledWith('encrypted-value') + expect(result).toBe('decrypted-password') + }) + + it('should fall back to legacy decrypt when new format fails', () => { + vi.mocked(credentialService.decrypt).mockImplementation(() => { + throw new Error('Invalid format') + }) + + const result = decryptPassword('legacy-encrypted-value') + + expect(credentialService.decryptLegacy).toHaveBeenCalledWith('legacy-encrypted-value') + expect(result).toBe('legacy-password') + }) + }) + + describe('generateRunnerToken', () => { + it('should generate a 64-character hex string', () => { + const token = generateRunnerToken() + + expect(token).toHaveLength(64) + expect(token).toMatch(/^[0-9a-f]+$/) + }) + + it('should generate unique tokens', () => { + const token1 = generateRunnerToken() + const token2 = generateRunnerToken() + + expect(token1).not.toBe(token2) + }) + }) + + describe('hashRunnerToken', () => { + it('should return SHA-256 hash', () => { + const token = 'test-token' + const expectedHash = crypto.createHash('sha256').update(token).digest('hex') + + const hash = hashRunnerToken(token) + + expect(hash).toBe(expectedHash) + expect(hash).toHaveLength(64) + }) + + it('should produce consistent hashes', () => { + const token = 'consistent-token' + + expect(hashRunnerToken(token)).toBe(hashRunnerToken(token)) + }) + }) + + describe('verifyRunnerToken', () => { + it('should return true for matching token', () => { + const token = 'valid-token' + const hash = hashRunnerToken(token) + + expect(verifyRunnerToken(token, hash)).toBe(true) + }) + + it('should return false for non-matching token', () => { + const hash = hashRunnerToken('correct-token') + + expect(verifyRunnerToken('wrong-token', hash)).toBe(false) + }) + + it('should use timing-safe comparison', () => { + // Verify it uses crypto.timingSafeEqual by checking it handles + // matching tokens correctly (if it wasn't timing-safe, this would still work) + const token = generateRunnerToken() + const hash = hashRunnerToken(token) + + expect(verifyRunnerToken(token, hash)).toBe(true) + }) + }) +}) diff --git a/src/__tests__/unit/lib/services/dns-service.test.ts b/src/__tests__/unit/lib/services/dns-service.test.ts new file mode 100644 index 0000000..13a4d14 --- /dev/null +++ b/src/__tests__/unit/lib/services/dns-service.test.ts @@ -0,0 +1,456 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { prismaMock, resetPrismaMock } from '../../../mocks/prisma' +import { DnsRecordStatus, OrderStatus, LogLevel } from '@prisma/client' + +// Mock the dns module +vi.mock('dns', () => { + const mockResolve4 = vi.fn() + return { + default: { + resolve4: mockResolve4, + }, + resolve4: mockResolve4, + } +}) + +// Import after mocking +import dns from 'dns' +import { + getSubdomainsForTools, + verifyDns, + runDnsVerification, + skipDnsVerification, + getDnsStatus, +} from '@/lib/services/dns-service' + +// Helper to make dns.resolve4 work as expected with promisify +function mockDnsResolve(mapping: Record) { + const resolve4 = dns.resolve4 as unknown as ReturnType + resolve4.mockImplementation((domain: string, callback: (err: Error | null, addresses?: string[]) => void) => { + const result = mapping[domain] + if (result === null || result === undefined) { + callback(new Error('ENOTFOUND')) + } else { + callback(null, result) + } + }) +} + +describe('DNS Service', () => { + beforeEach(() => { + resetPrismaMock() + vi.clearAllMocks() + }) + + describe('getSubdomainsForTools', () => { + it('should return empty array for core tools with no subdomains', () => { + const subdomains = getSubdomainsForTools(['core', 'portainer', 'orchestrator']) + + expect(subdomains).toEqual([]) + }) + + it('should return correct subdomains for poste', () => { + const subdomains = getSubdomainsForTools(['poste']) + + expect(subdomains).toContain('mail') + }) + + it('should return correct subdomains for chatwoot', () => { + const subdomains = getSubdomainsForTools(['chatwoot']) + + expect(subdomains).toContain('support') + expect(subdomains).toContain('helpdesk') + }) + + it('should return correct subdomains for nextcloud', () => { + const subdomains = getSubdomainsForTools(['nextcloud']) + + expect(subdomains).toContain('cloud') + expect(subdomains).toContain('collabora') + expect(subdomains).toContain('whiteboard') + }) + + it('should deduplicate subdomains across tools', () => { + const subdomains = getSubdomainsForTools(['nextcloud', 'nextcloud']) + + // Should not have duplicates + const uniqueSubdomains = [...new Set(subdomains)] + expect(subdomains.length).toBe(uniqueSubdomains.length) + }) + + it('should combine subdomains from multiple tools', () => { + const subdomains = getSubdomainsForTools(['poste', 'keycloak', 'n8n']) + + expect(subdomains).toContain('mail') + expect(subdomains).toContain('auth') + expect(subdomains).toContain('n8n') + }) + + it('should return sorted subdomains', () => { + const subdomains = getSubdomainsForTools(['nextcloud', 'poste', 'keycloak']) + const sorted = [...subdomains].sort() + + expect(subdomains).toEqual(sorted) + }) + + it('should handle unknown tool names gracefully', () => { + const subdomains = getSubdomainsForTools(['unknown-tool', 'poste']) + + // Should still include known tool subdomains + expect(subdomains).toContain('mail') + }) + + it('should be case-insensitive for tool names', () => { + const subdomains = getSubdomainsForTools(['POSTE', 'Keycloak']) + + expect(subdomains).toContain('mail') + expect(subdomains).toContain('auth') + }) + + it('should return correct subdomains for minio', () => { + const subdomains = getSubdomainsForTools(['minio']) + + expect(subdomains).toContain('minio') + expect(subdomains).toContain('s3') + }) + + it('should return correct subdomains for calcom', () => { + const subdomains = getSubdomainsForTools(['calcom']) + + expect(subdomains).toContain('bookings') + }) + + it('should return empty for tools using root domain (ghost, wordpress)', () => { + const subdomains = getSubdomainsForTools(['ghost', 'wordpress']) + + expect(subdomains).toEqual([]) + }) + }) + + describe('verifyDns', () => { + it('should mark all subdomains as passed via wildcard when wildcard resolves', async () => { + // Mock wildcard check - any subdomain resolves to expected IP + mockDnsResolve({ + // The wildcard test uses a random subdomain pattern + }) + + // Make all DNS lookups resolve to the target IP + const resolve4 = dns.resolve4 as unknown as ReturnType + resolve4.mockImplementation((_domain: string, callback: (err: Error | null, addresses?: string[]) => void) => { + callback(null, ['10.0.0.1']) + }) + + const result = await verifyDns('example.com', '10.0.0.1', ['poste', 'keycloak']) + + expect(result.wildcardPassed).toBe(true) + expect(result.allPassed).toBe(true) + expect(result.records.every((r) => r.status === DnsRecordStatus.SKIPPED)).toBe(true) + }) + + it('should check individual subdomains when wildcard fails', async () => { + let callCount = 0 + const resolve4 = dns.resolve4 as unknown as ReturnType + resolve4.mockImplementation((domain: string, callback: (err: Error | null, addresses?: string[]) => void) => { + callCount++ + if (callCount === 1) { + // First call is wildcard check - fail it + callback(new Error('ENOTFOUND')) + } else if (domain === 'mail.example.com') { + callback(null, ['10.0.0.1']) + } else if (domain === 'auth.example.com') { + callback(null, ['10.0.0.1']) + } else { + callback(new Error('ENOTFOUND')) + } + }) + + const result = await verifyDns('example.com', '10.0.0.1', ['poste', 'keycloak']) + + expect(result.wildcardPassed).toBe(false) + expect(result.allPassed).toBe(true) + expect(result.passedCount).toBe(2) + }) + + it('should detect IP mismatch', async () => { + let callCount = 0 + const resolve4 = dns.resolve4 as unknown as ReturnType + resolve4.mockImplementation((_domain: string, callback: (err: Error | null, addresses?: string[]) => void) => { + callCount++ + if (callCount === 1) { + // Wildcard check fails + callback(new Error('ENOTFOUND')) + } else { + // Subdomain resolves to wrong IP + callback(null, ['10.0.0.99']) + } + }) + + const result = await verifyDns('example.com', '10.0.0.1', ['poste']) + + expect(result.allPassed).toBe(false) + const mailRecord = result.records.find((r) => r.subdomain === 'mail') + expect(mailRecord?.status).toBe(DnsRecordStatus.MISMATCH) + expect(mailRecord?.resolvedIp).toBe('10.0.0.99') + }) + + it('should handle NOT_FOUND when subdomain has no A record', async () => { + let callCount = 0 + const resolve4 = dns.resolve4 as unknown as ReturnType + resolve4.mockImplementation((_domain: string, callback: (err: Error | null, addresses?: string[]) => void) => { + callCount++ + if (callCount === 1) { + // Wildcard check fails + callback(new Error('ENOTFOUND')) + } else { + callback(new Error('ENOTFOUND')) + } + }) + + const result = await verifyDns('example.com', '10.0.0.1', ['poste']) + + expect(result.allPassed).toBe(false) + const mailRecord = result.records.find((r) => r.subdomain === 'mail') + expect(mailRecord?.status).toBe(DnsRecordStatus.NOT_FOUND) + }) + + it('should return correct counts', async () => { + let callCount = 0 + const resolve4 = dns.resolve4 as unknown as ReturnType + resolve4.mockImplementation((domain: string, callback: (err: Error | null, addresses?: string[]) => void) => { + callCount++ + if (callCount === 1) { + callback(new Error('ENOTFOUND')) // wildcard + } else if (domain.startsWith('mail')) { + callback(null, ['10.0.0.1']) // mail passes + } else { + callback(new Error('ENOTFOUND')) // others fail + } + }) + + const result = await verifyDns('example.com', '10.0.0.1', ['poste', 'keycloak']) + + expect(result.totalSubdomains).toBe(2) + expect(result.passedCount).toBe(1) // only mail passes + }) + + it('should return empty records for tools with no subdomains', async () => { + const result = await verifyDns('example.com', '10.0.0.1', ['core', 'portainer']) + + expect(result.totalSubdomains).toBe(0) + expect(result.allPassed).toBe(true) + expect(result.records).toEqual([]) + }) + }) + + describe('runDnsVerification', () => { + it('should throw if order not found', async () => { + prismaMock.order.findUnique.mockResolvedValue(null) + + await expect(runDnsVerification('invalid-id')).rejects.toThrow('Order not found') + }) + + it('should throw if server IP not configured', async () => { + prismaMock.order.findUnique.mockResolvedValue({ + id: 'order-123', + serverIp: null, + domain: 'test.com', + tools: ['poste'], + dnsVerification: null, + } as any) + + await expect(runDnsVerification('order-123')).rejects.toThrow('Server IP not configured') + }) + }) + + describe('skipDnsVerification', () => { + it('should throw if order not found', async () => { + prismaMock.order.findUnique.mockResolvedValue(null) + + await expect(skipDnsVerification('invalid-id')).rejects.toThrow('Order not found') + }) + + it('should create new dns verification with manual override', async () => { + prismaMock.order.findUnique.mockResolvedValue({ + id: 'order-123', + status: OrderStatus.DNS_PENDING, + dnsVerification: null, + } as any) + prismaMock.dnsVerification.create.mockResolvedValue({} as any) + prismaMock.provisioningLog.create.mockResolvedValue({} as any) + prismaMock.order.update.mockResolvedValue({} as any) + + await skipDnsVerification('order-123') + + expect(prismaMock.dnsVerification.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + orderId: 'order-123', + manualOverride: true, + allPassed: true, + verifiedAt: expect.any(Date), + }), + }) + }) + + it('should update existing dns verification when one exists', async () => { + prismaMock.order.findUnique.mockResolvedValue({ + id: 'order-123', + status: OrderStatus.DNS_PENDING, + dnsVerification: { + id: 'dns-456', + }, + } as any) + prismaMock.dnsVerification.update.mockResolvedValue({} as any) + prismaMock.provisioningLog.create.mockResolvedValue({} as any) + prismaMock.order.update.mockResolvedValue({} as any) + + await skipDnsVerification('order-123') + + expect(prismaMock.dnsVerification.update).toHaveBeenCalledWith({ + where: { id: 'dns-456' }, + data: expect.objectContaining({ + manualOverride: true, + allPassed: true, + }), + }) + }) + + it('should update order status to DNS_READY when currently DNS_PENDING', async () => { + prismaMock.order.findUnique.mockResolvedValue({ + id: 'order-123', + status: OrderStatus.DNS_PENDING, + dnsVerification: null, + } as any) + prismaMock.dnsVerification.create.mockResolvedValue({} as any) + prismaMock.provisioningLog.create.mockResolvedValue({} as any) + prismaMock.order.update.mockResolvedValue({} as any) + + await skipDnsVerification('order-123') + + expect(prismaMock.order.update).toHaveBeenCalledWith({ + where: { id: 'order-123' }, + data: expect.objectContaining({ + status: OrderStatus.DNS_READY, + dnsVerifiedAt: expect.any(Date), + }), + }) + }) + + it('should update order status to DNS_READY when currently SERVER_READY', async () => { + prismaMock.order.findUnique.mockResolvedValue({ + id: 'order-123', + status: OrderStatus.SERVER_READY, + dnsVerification: null, + } as any) + prismaMock.dnsVerification.create.mockResolvedValue({} as any) + prismaMock.provisioningLog.create.mockResolvedValue({} as any) + prismaMock.order.update.mockResolvedValue({} as any) + + await skipDnsVerification('order-123') + + expect(prismaMock.order.update).toHaveBeenCalledWith({ + where: { id: 'order-123' }, + data: expect.objectContaining({ + status: OrderStatus.DNS_READY, + }), + }) + }) + + it('should not update order status when not in DNS_PENDING or SERVER_READY', async () => { + prismaMock.order.findUnique.mockResolvedValue({ + id: 'order-123', + status: OrderStatus.FULFILLED, + dnsVerification: null, + } as any) + prismaMock.dnsVerification.create.mockResolvedValue({} as any) + prismaMock.provisioningLog.create.mockResolvedValue({} as any) + + await skipDnsVerification('order-123') + + expect(prismaMock.order.update).not.toHaveBeenCalled() + }) + + it('should log the manual override', async () => { + prismaMock.order.findUnique.mockResolvedValue({ + id: 'order-123', + status: OrderStatus.DNS_PENDING, + dnsVerification: null, + } as any) + prismaMock.dnsVerification.create.mockResolvedValue({} as any) + prismaMock.provisioningLog.create.mockResolvedValue({} as any) + prismaMock.order.update.mockResolvedValue({} as any) + + await skipDnsVerification('order-123') + + expect(prismaMock.provisioningLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + orderId: 'order-123', + level: LogLevel.WARN, + message: 'DNS verification skipped via manual override', + step: 'dns', + }), + }) + }) + }) + + describe('getDnsStatus', () => { + it('should throw if order not found', async () => { + prismaMock.order.findUnique.mockResolvedValue(null) + + await expect(getDnsStatus('invalid-id')).rejects.toThrow('Order not found') + }) + + it('should return status with null verification when none exists', async () => { + prismaMock.order.findUnique.mockResolvedValue({ + id: 'order-123', + domain: 'test.letsbe.cloud', + serverIp: '10.0.0.1', + status: OrderStatus.DNS_PENDING, + tools: ['poste'], + dnsVerification: null, + } as any) + + const result = await getDnsStatus('order-123') + + expect(result.domain).toBe('test.letsbe.cloud') + expect(result.serverIp).toBe('10.0.0.1') + expect(result.verification).toBeNull() + expect(result.requiredSubdomains).toContain('mail') + }) + + it('should return status with verification data when it exists', async () => { + const lastChecked = new Date() + prismaMock.order.findUnique.mockResolvedValue({ + id: 'order-123', + domain: 'test.letsbe.cloud', + serverIp: '10.0.0.1', + status: OrderStatus.DNS_READY, + tools: ['poste'], + dnsVerification: { + id: 'dns-456', + wildcardPassed: false, + manualOverride: false, + allPassed: true, + totalSubdomains: 1, + passedCount: 1, + lastCheckedAt: lastChecked, + verifiedAt: lastChecked, + records: [ + { + subdomain: 'mail', + fullDomain: 'mail.test.letsbe.cloud', + expectedIp: '10.0.0.1', + resolvedIp: '10.0.0.1', + status: DnsRecordStatus.VERIFIED, + }, + ], + }, + } as any) + + const result = await getDnsStatus('order-123') + + expect(result.verification).not.toBeNull() + expect(result.verification?.allPassed).toBe(true) + expect(result.verification?.records).toHaveLength(1) + }) + }) +}) diff --git a/src/__tests__/unit/lib/services/permission-service.test.ts b/src/__tests__/unit/lib/services/permission-service.test.ts new file mode 100644 index 0000000..507c1c8 --- /dev/null +++ b/src/__tests__/unit/lib/services/permission-service.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect } from 'vitest' +import { StaffRole } from '@prisma/client' + +import { + hasPermission, + hasAllPermissions, + hasAnyPermission, + getPermissions, + getRoleDisplayName, + getRoleDescription, + canManageRole, + canDeleteRole, + getAssignableRoles, +} from '@/lib/services/permission-service' + +describe('Permission Service', () => { + describe('hasPermission', () => { + it('should grant OWNER all permissions', () => { + expect(hasPermission('OWNER' as StaffRole, 'dashboard:view')).toBe(true) + expect(hasPermission('OWNER' as StaffRole, 'staff:delete')).toBe(true) + expect(hasPermission('OWNER' as StaffRole, 'settings:edit')).toBe(true) + expect(hasPermission('OWNER' as StaffRole, 'enterprise:manage')).toBe(true) + }) + + it('should grant ADMIN most permissions but not staff:delete', () => { + expect(hasPermission('ADMIN' as StaffRole, 'orders:view')).toBe(true) + expect(hasPermission('ADMIN' as StaffRole, 'orders:create')).toBe(true) + expect(hasPermission('ADMIN' as StaffRole, 'staff:manage')).toBe(true) + expect(hasPermission('ADMIN' as StaffRole, 'settings:edit')).toBe(true) + expect(hasPermission('ADMIN' as StaffRole, 'staff:delete')).toBe(false) + }) + + it('should grant MANAGER operational permissions but not staff or settings', () => { + expect(hasPermission('MANAGER' as StaffRole, 'orders:view')).toBe(true) + expect(hasPermission('MANAGER' as StaffRole, 'orders:create')).toBe(true) + expect(hasPermission('MANAGER' as StaffRole, 'orders:provision')).toBe(true) + expect(hasPermission('MANAGER' as StaffRole, 'servers:power')).toBe(true) + expect(hasPermission('MANAGER' as StaffRole, 'enterprise:manage')).toBe(true) + // Should NOT have staff or settings access + expect(hasPermission('MANAGER' as StaffRole, 'staff:view')).toBe(false) + expect(hasPermission('MANAGER' as StaffRole, 'staff:invite')).toBe(false) + expect(hasPermission('MANAGER' as StaffRole, 'settings:view')).toBe(false) + expect(hasPermission('MANAGER' as StaffRole, 'settings:edit')).toBe(false) + // Should NOT have delete permissions + expect(hasPermission('MANAGER' as StaffRole, 'orders:delete')).toBe(false) + expect(hasPermission('MANAGER' as StaffRole, 'customers:delete')).toBe(false) + }) + + it('should grant SUPPORT only view permissions', () => { + expect(hasPermission('SUPPORT' as StaffRole, 'dashboard:view')).toBe(true) + expect(hasPermission('SUPPORT' as StaffRole, 'orders:view')).toBe(true) + expect(hasPermission('SUPPORT' as StaffRole, 'customers:view')).toBe(true) + expect(hasPermission('SUPPORT' as StaffRole, 'servers:view')).toBe(true) + expect(hasPermission('SUPPORT' as StaffRole, 'enterprise:view')).toBe(true) + // Should NOT have any action permissions + expect(hasPermission('SUPPORT' as StaffRole, 'orders:create')).toBe(false) + expect(hasPermission('SUPPORT' as StaffRole, 'orders:edit')).toBe(false) + expect(hasPermission('SUPPORT' as StaffRole, 'servers:power')).toBe(false) + expect(hasPermission('SUPPORT' as StaffRole, 'staff:view')).toBe(false) + expect(hasPermission('SUPPORT' as StaffRole, 'settings:view')).toBe(false) + }) + + it('should return false for unknown role', () => { + expect(hasPermission('UNKNOWN' as StaffRole, 'dashboard:view')).toBe(false) + }) + }) + + describe('hasAllPermissions', () => { + it('should return true when role has all listed permissions', () => { + expect( + hasAllPermissions('OWNER' as StaffRole, ['orders:view', 'orders:create', 'staff:delete']) + ).toBe(true) + }) + + it('should return false when role lacks any permission', () => { + expect( + hasAllPermissions('SUPPORT' as StaffRole, ['orders:view', 'orders:create']) + ).toBe(false) + }) + + it('should return true for empty permissions list', () => { + expect(hasAllPermissions('SUPPORT' as StaffRole, [])).toBe(true) + }) + }) + + describe('hasAnyPermission', () => { + it('should return true when role has at least one permission', () => { + expect( + hasAnyPermission('SUPPORT' as StaffRole, ['orders:create', 'orders:view']) + ).toBe(true) + }) + + it('should return false when role has none of the permissions', () => { + expect( + hasAnyPermission('SUPPORT' as StaffRole, ['staff:delete', 'settings:edit']) + ).toBe(false) + }) + + it('should return false for empty permissions list', () => { + expect(hasAnyPermission('OWNER' as StaffRole, [])).toBe(false) + }) + }) + + describe('getPermissions', () => { + it('should return all permissions for OWNER', () => { + const perms = getPermissions('OWNER' as StaffRole) + + expect(perms).toContain('staff:delete') + expect(perms).toContain('settings:edit') + expect(perms).toContain('enterprise:manage') + expect(perms.length).toBeGreaterThan(15) + }) + + it('should return fewer permissions for SUPPORT than OWNER', () => { + const ownerPerms = getPermissions('OWNER' as StaffRole) + const supportPerms = getPermissions('SUPPORT' as StaffRole) + + expect(supportPerms.length).toBeLessThan(ownerPerms.length) + }) + + it('should return empty array for unknown role', () => { + expect(getPermissions('UNKNOWN' as StaffRole)).toEqual([]) + }) + }) + + describe('getRoleDisplayName', () => { + it('should return correct display names', () => { + expect(getRoleDisplayName('OWNER' as StaffRole)).toBe('Owner') + expect(getRoleDisplayName('ADMIN' as StaffRole)).toBe('Administrator') + expect(getRoleDisplayName('MANAGER' as StaffRole)).toBe('Manager') + expect(getRoleDisplayName('SUPPORT' as StaffRole)).toBe('Support') + }) + + it('should return raw role for unknown role', () => { + expect(getRoleDisplayName('UNKNOWN' as StaffRole)).toBe('UNKNOWN') + }) + }) + + describe('getRoleDescription', () => { + it('should return non-empty description for each role', () => { + expect(getRoleDescription('OWNER' as StaffRole).length).toBeGreaterThan(0) + expect(getRoleDescription('ADMIN' as StaffRole).length).toBeGreaterThan(0) + expect(getRoleDescription('MANAGER' as StaffRole).length).toBeGreaterThan(0) + expect(getRoleDescription('SUPPORT' as StaffRole).length).toBeGreaterThan(0) + }) + + it('should mention key restrictions in descriptions', () => { + expect(getRoleDescription('OWNER' as StaffRole)).toContain('Cannot be deleted') + expect(getRoleDescription('ADMIN' as StaffRole)).toContain('not delete') + expect(getRoleDescription('SUPPORT' as StaffRole)).toContain('View-only') + }) + + it('should return empty string for unknown role', () => { + expect(getRoleDescription('UNKNOWN' as StaffRole)).toBe('') + }) + }) + + describe('canManageRole', () => { + it('should allow OWNER to manage any role', () => { + expect(canManageRole('OWNER' as StaffRole, 'ADMIN' as StaffRole)).toBe(true) + expect(canManageRole('OWNER' as StaffRole, 'MANAGER' as StaffRole)).toBe(true) + expect(canManageRole('OWNER' as StaffRole, 'SUPPORT' as StaffRole)).toBe(true) + expect(canManageRole('OWNER' as StaffRole, 'OWNER' as StaffRole)).toBe(true) + }) + + it('should allow ADMIN to manage MANAGER and SUPPORT', () => { + expect(canManageRole('ADMIN' as StaffRole, 'MANAGER' as StaffRole)).toBe(true) + expect(canManageRole('ADMIN' as StaffRole, 'SUPPORT' as StaffRole)).toBe(true) + }) + + it('should deny ADMIN managing ADMIN or OWNER', () => { + expect(canManageRole('ADMIN' as StaffRole, 'ADMIN' as StaffRole)).toBe(false) + expect(canManageRole('ADMIN' as StaffRole, 'OWNER' as StaffRole)).toBe(false) + }) + + it('should deny MANAGER managing anyone', () => { + expect(canManageRole('MANAGER' as StaffRole, 'SUPPORT' as StaffRole)).toBe(false) + expect(canManageRole('MANAGER' as StaffRole, 'MANAGER' as StaffRole)).toBe(false) + }) + + it('should deny SUPPORT managing anyone', () => { + expect(canManageRole('SUPPORT' as StaffRole, 'SUPPORT' as StaffRole)).toBe(false) + }) + }) + + describe('canDeleteRole', () => { + it('should allow OWNER to delete ADMIN, MANAGER, SUPPORT', () => { + expect(canDeleteRole('OWNER' as StaffRole, 'ADMIN' as StaffRole)).toBe(true) + expect(canDeleteRole('OWNER' as StaffRole, 'MANAGER' as StaffRole)).toBe(true) + expect(canDeleteRole('OWNER' as StaffRole, 'SUPPORT' as StaffRole)).toBe(true) + }) + + it('should deny OWNER deleting another OWNER', () => { + expect(canDeleteRole('OWNER' as StaffRole, 'OWNER' as StaffRole)).toBe(false) + }) + + it('should deny ADMIN deleting anyone', () => { + expect(canDeleteRole('ADMIN' as StaffRole, 'MANAGER' as StaffRole)).toBe(false) + expect(canDeleteRole('ADMIN' as StaffRole, 'SUPPORT' as StaffRole)).toBe(false) + }) + + it('should deny MANAGER deleting anyone', () => { + expect(canDeleteRole('MANAGER' as StaffRole, 'SUPPORT' as StaffRole)).toBe(false) + }) + + it('should deny SUPPORT deleting anyone', () => { + expect(canDeleteRole('SUPPORT' as StaffRole, 'SUPPORT' as StaffRole)).toBe(false) + }) + }) + + describe('getAssignableRoles', () => { + it('should return ADMIN, MANAGER, SUPPORT for OWNER', () => { + const roles = getAssignableRoles('OWNER' as StaffRole) + + expect(roles).toEqual(['ADMIN', 'MANAGER', 'SUPPORT']) + expect(roles).not.toContain('OWNER') + }) + + it('should return ADMIN, MANAGER, SUPPORT for ADMIN', () => { + const roles = getAssignableRoles('ADMIN' as StaffRole) + + expect(roles).toEqual(['ADMIN', 'MANAGER', 'SUPPORT']) + }) + + it('should return empty array for MANAGER', () => { + expect(getAssignableRoles('MANAGER' as StaffRole)).toEqual([]) + }) + + it('should return empty array for SUPPORT', () => { + expect(getAssignableRoles('SUPPORT' as StaffRole)).toEqual([]) + }) + }) +}) diff --git a/src/__tests__/unit/lib/services/security-verification-service.test.ts b/src/__tests__/unit/lib/services/security-verification-service.test.ts new file mode 100644 index 0000000..982352e --- /dev/null +++ b/src/__tests__/unit/lib/services/security-verification-service.test.ts @@ -0,0 +1,296 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { prismaMock, resetPrismaMock } from '../../../mocks/prisma' + +// Mock the email service +vi.mock('@/lib/services/email-service', () => ({ + emailService: { + isConfigured: vi.fn().mockResolvedValue(false), + sendEmail: vi.fn().mockResolvedValue({ success: true }), + }, +})) + +import { securityVerificationService } from '@/lib/services/security-verification-service' + +describe('SecurityVerificationService', () => { + beforeEach(() => { + resetPrismaMock() + vi.clearAllMocks() + // Suppress console.log from email fallback logging + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + describe('requestVerificationCode', () => { + const mockClient = { + id: 'client-123', + name: 'Test Client', + contactEmail: 'admin@testclient.com', + } + + const mockServer = { + id: 'server-456', + nickname: 'Production Server', + netcupServerId: 'ns-789', + } + + beforeEach(() => { + prismaMock.enterpriseClient.findUnique.mockResolvedValue(mockClient as any) + prismaMock.enterpriseServer.findFirst.mockResolvedValue(mockServer as any) + prismaMock.securityVerificationCode.updateMany.mockResolvedValue({ count: 0 } as any) + prismaMock.securityVerificationCode.create.mockResolvedValue({} as any) + }) + + it('should throw when client not found', async () => { + prismaMock.enterpriseClient.findUnique.mockResolvedValue(null) + + await expect( + securityVerificationService.requestVerificationCode('bad-id', 'server-456', 'WIPE') + ).rejects.toThrow('Client not found') + }) + + it('should throw when server not found', async () => { + prismaMock.enterpriseServer.findFirst.mockResolvedValue(null) + + await expect( + securityVerificationService.requestVerificationCode('client-123', 'bad-id', 'WIPE') + ).rejects.toThrow('Server not found or does not belong to this client') + }) + + it('should invalidate existing unused codes before creating new one', async () => { + const result = await securityVerificationService.requestVerificationCode( + 'client-123', + 'server-456', + 'WIPE' + ) + + expect(prismaMock.securityVerificationCode.updateMany).toHaveBeenCalledWith({ + where: { + clientId: 'client-123', + targetServerId: 'server-456', + action: 'WIPE', + usedAt: null, + }, + data: { + usedAt: expect.any(Date), + }, + }) + }) + + it('should create a new verification code', async () => { + await securityVerificationService.requestVerificationCode( + 'client-123', + 'server-456', + 'REINSTALL' + ) + + expect(prismaMock.securityVerificationCode.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + clientId: 'client-123', + action: 'REINSTALL', + targetServerId: 'server-456', + code: expect.any(String), + expiresAt: expect.any(Date), + }), + }) + }) + + it('should generate 8-digit code', async () => { + await securityVerificationService.requestVerificationCode( + 'client-123', + 'server-456', + 'WIPE' + ) + + const createCall = prismaMock.securityVerificationCode.create.mock.calls[0][0] + const code = createCall.data.code as string + + expect(code).toHaveLength(8) + expect(code).toMatch(/^\d{8}$/) + }) + + it('should set expiry 15 minutes in the future', async () => { + const before = Date.now() + + await securityVerificationService.requestVerificationCode( + 'client-123', + 'server-456', + 'WIPE' + ) + + const createCall = prismaMock.securityVerificationCode.create.mock.calls[0][0] + const expiresAt = createCall.data.expiresAt as Date + + const after = Date.now() + const fifteenMinMs = 15 * 60 * 1000 + + // expiresAt should be approximately 15 minutes from now + expect(expiresAt.getTime()).toBeGreaterThanOrEqual(before + fifteenMinMs - 1000) + expect(expiresAt.getTime()).toBeLessThanOrEqual(after + fifteenMinMs + 1000) + }) + + it('should return masked email and expiry', async () => { + const result = await securityVerificationService.requestVerificationCode( + 'client-123', + 'server-456', + 'WIPE' + ) + + expect(result.expiresAt).toBeInstanceOf(Date) + // Email should be masked (a***n@testclient.com pattern) + expect(result.email).toContain('@testclient.com') + expect(result.email).toContain('*') + }) + }) + + describe('verifyCode', () => { + it('should return invalid when no active code exists', async () => { + prismaMock.securityVerificationCode.findFirst.mockResolvedValue(null) + + const result = await securityVerificationService.verifyCode('client-123', '12345678') + + expect(result.valid).toBe(false) + expect(result.errorMessage).toBe('Invalid or expired verification code') + }) + + it('should return invalid when max attempts exceeded', async () => { + prismaMock.securityVerificationCode.findFirst.mockResolvedValue({ + id: 'code-123', + code: '12345678', + attempts: 5, + clientId: 'client-123', + action: 'WIPE', + targetServerId: 'server-456', + } as any) + prismaMock.securityVerificationCode.update.mockResolvedValue({} as any) + + const result = await securityVerificationService.verifyCode('client-123', '12345678') + + expect(result.valid).toBe(false) + expect(result.errorMessage).toContain('Too many failed attempts') + + // Should invalidate the code + expect(prismaMock.securityVerificationCode.update).toHaveBeenCalledWith({ + where: { id: 'code-123' }, + data: { usedAt: expect.any(Date) }, + }) + }) + + it('should increment attempt counter on wrong code', async () => { + prismaMock.securityVerificationCode.findFirst.mockResolvedValue({ + id: 'code-123', + code: '12345678', + attempts: 2, + clientId: 'client-123', + action: 'WIPE', + targetServerId: 'server-456', + } as any) + prismaMock.securityVerificationCode.update.mockResolvedValue({} as any) + + const result = await securityVerificationService.verifyCode('client-123', '00000000') + + expect(result.valid).toBe(false) + expect(result.errorMessage).toContain('attempt(s) remaining') + + expect(prismaMock.securityVerificationCode.update).toHaveBeenCalledWith({ + where: { id: 'code-123' }, + data: { attempts: { increment: 1 } }, + }) + }) + + it('should show remaining attempts in error message', async () => { + prismaMock.securityVerificationCode.findFirst.mockResolvedValue({ + id: 'code-123', + code: '12345678', + attempts: 3, + clientId: 'client-123', + action: 'WIPE', + targetServerId: 'server-456', + } as any) + prismaMock.securityVerificationCode.update.mockResolvedValue({} as any) + + const result = await securityVerificationService.verifyCode('client-123', '00000000') + + // 5 max - 3 current - 1 this attempt = 1 remaining + expect(result.errorMessage).toContain('1 attempt(s) remaining') + }) + + it('should show too many attempts when at last attempt', async () => { + prismaMock.securityVerificationCode.findFirst.mockResolvedValue({ + id: 'code-123', + code: '12345678', + attempts: 4, + clientId: 'client-123', + action: 'WIPE', + targetServerId: 'server-456', + } as any) + prismaMock.securityVerificationCode.update.mockResolvedValue({} as any) + + const result = await securityVerificationService.verifyCode('client-123', '00000000') + + expect(result.errorMessage).toContain('Too many failed attempts') + }) + + it('should return valid with action and serverId on correct code', async () => { + prismaMock.securityVerificationCode.findFirst.mockResolvedValue({ + id: 'code-123', + code: '12345678', + attempts: 0, + clientId: 'client-123', + action: 'WIPE', + targetServerId: 'server-456', + } as any) + prismaMock.securityVerificationCode.update.mockResolvedValue({} as any) + + const result = await securityVerificationService.verifyCode('client-123', '12345678') + + expect(result.valid).toBe(true) + expect(result.action).toBe('WIPE') + expect(result.serverId).toBe('server-456') + }) + + it('should mark code as used after successful verification', async () => { + prismaMock.securityVerificationCode.findFirst.mockResolvedValue({ + id: 'code-123', + code: '12345678', + attempts: 0, + clientId: 'client-123', + action: 'REINSTALL', + targetServerId: 'server-456', + } as any) + prismaMock.securityVerificationCode.update.mockResolvedValue({} as any) + + await securityVerificationService.verifyCode('client-123', '12345678') + + expect(prismaMock.securityVerificationCode.update).toHaveBeenCalledWith({ + where: { id: 'code-123' }, + data: { usedAt: expect.any(Date) }, + }) + }) + }) + + describe('cleanupExpiredCodes', () => { + it('should delete expired and used codes older than 24h', async () => { + prismaMock.securityVerificationCode.deleteMany.mockResolvedValue({ count: 5 } as any) + + const result = await securityVerificationService.cleanupExpiredCodes() + + expect(result).toBe(5) + expect(prismaMock.securityVerificationCode.deleteMany).toHaveBeenCalledWith({ + where: { + OR: [ + { expiresAt: { lt: expect.any(Date) } }, + { usedAt: { not: null } }, + ], + createdAt: { lt: expect.any(Date) }, + }, + }) + }) + + it('should return 0 when no codes to clean up', async () => { + prismaMock.securityVerificationCode.deleteMany.mockResolvedValue({ count: 0 } as any) + + const result = await securityVerificationService.cleanupExpiredCodes() + + expect(result).toBe(0) + }) + }) +}) diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts index b2ad247..203543e 100644 --- a/src/app/api/auth/[...nextauth]/route.ts +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -1,3 +1,32 @@ +import { NextRequest, NextResponse } from 'next/server' import { handlers } from '@/lib/auth' +import { loginLimiter } from '@/lib/rate-limit' -export const { GET, POST } = handlers +export const GET = handlers.GET + +/** + * POST handler with rate limiting for login attempts. + * NextAuth credential auth goes through POST /api/auth/callback/credentials + */ +export async function POST(request: NextRequest) { + // Apply rate limiting to credential login attempts + const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown' + const url = request.nextUrl.pathname + + if (url.includes('/callback/credentials')) { + const result = loginLimiter.check(`login:${ip}`) + if (result.limited) { + return NextResponse.json( + { error: 'Too many login attempts. Please try again later.' }, + { + status: 429, + headers: { + 'Retry-After': String(result.retryAfter || 60), + }, + } + ) + } + } + + return handlers.POST(request) +} diff --git a/src/app/api/cron/cleanup-stats/route.ts b/src/app/api/cron/cleanup-stats/route.ts index eb78e3e..4060fb2 100644 --- a/src/app/api/cron/cleanup-stats/route.ts +++ b/src/app/api/cron/cleanup-stats/route.ts @@ -17,11 +17,14 @@ import { statsCollectionService } from '@/lib/services/stats-collection-service' * } */ export async function GET(request: NextRequest) { - // Verify cron secret (for security in production) + // Verify cron secret const cronSecret = process.env.CRON_SECRET + if (!cronSecret) { + console.error('[Cron] CRON_SECRET environment variable is not set') + return NextResponse.json({ error: 'Cron not configured' }, { status: 500 }) + } const authHeader = request.headers.get('authorization') - - if (cronSecret && authHeader !== `Bearer ${cronSecret}`) { + if (authHeader !== `Bearer ${cronSecret}`) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/src/app/api/cron/collect-stats/route.ts b/src/app/api/cron/collect-stats/route.ts index d4b0b80..19be3b5 100644 --- a/src/app/api/cron/collect-stats/route.ts +++ b/src/app/api/cron/collect-stats/route.ts @@ -24,11 +24,14 @@ import { containerHealthService } from '@/lib/services/container-health-service' * 3. Checks container health and detects crashes/OOM kills */ export async function GET(request: NextRequest) { - // Verify cron secret (for security in production) + // Verify cron secret const cronSecret = process.env.CRON_SECRET + if (!cronSecret) { + console.error('[Cron] CRON_SECRET environment variable is not set') + return NextResponse.json({ error: 'Cron not configured' }, { status: 500 }) + } const authHeader = request.headers.get('authorization') - - if (cronSecret && authHeader !== `Bearer ${cronSecret}`) { + if (authHeader !== `Bearer ${cronSecret}`) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/src/app/api/v1/admin/orders/[id]/provision/route.ts b/src/app/api/v1/admin/orders/[id]/provision/route.ts index 6a7cbbd..973a7f0 100644 --- a/src/app/api/v1/admin/orders/[id]/provision/route.ts +++ b/src/app/api/v1/admin/orders/[id]/provision/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { auth } from '@/lib/auth' +import { requireStaffPermission } from '@/lib/auth-helpers' import { prisma } from '@/lib/prisma' import { JobStatus, OrderStatus } from '@prisma/client' import { randomBytes } from 'crypto' @@ -28,11 +28,8 @@ export async function POST( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth() - - if (!session || session.user.userType !== 'staff') { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + // Require orders:provision permission (OWNER, ADMIN, MANAGER) + await requireStaffPermission('orders:provision') const { id: orderId } = await params @@ -297,7 +294,10 @@ export async function POST( containerName: spawnResult.containerName, serverConnectionId: serverConnection.id, }) - } catch (error) { + } catch (error: any) { + if (error?.status) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } console.error('Error triggering provisioning:', error) return NextResponse.json( { error: 'Failed to trigger provisioning' }, diff --git a/src/app/api/v1/admin/orders/[id]/route.ts b/src/app/api/v1/admin/orders/[id]/route.ts index 8d17036..088021b 100644 --- a/src/app/api/v1/admin/orders/[id]/route.ts +++ b/src/app/api/v1/admin/orders/[id]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import crypto from 'crypto' import { auth } from '@/lib/auth' +import { requireStaffPermission } from '@/lib/auth-helpers' import { prisma } from '@/lib/prisma' import { OrderStatus } from '@prisma/client' import { processAutomation } from '@/lib/services/automation-worker' @@ -321,11 +322,8 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth() - - if (!session || session.user.userType !== 'staff') { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + // Require orders:delete permission (OWNER, ADMIN only) + await requireStaffPermission('orders:delete') const { id: orderId } = await params @@ -371,7 +369,10 @@ export async function DELETE( success: true, message: `Order ${orderId} and all related records deleted`, }) - } catch (error) { + } catch (error: any) { + if (error?.status) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } console.error('Error deleting order:', error) return NextResponse.json( { error: 'Failed to delete order' }, diff --git a/src/app/api/v1/admin/servers/[id]/command/route.ts b/src/app/api/v1/admin/servers/[id]/command/route.ts index a22cd26..a0247e7 100644 --- a/src/app/api/v1/admin/servers/[id]/command/route.ts +++ b/src/app/api/v1/admin/servers/[id]/command/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' -import { auth } from '@/lib/auth' +import { requireStaffPermission } from '@/lib/auth-helpers' interface RouteContext { params: Promise<{ id: string }> @@ -22,19 +22,65 @@ const ALLOWED_COMMAND_TYPES = [ 'DOCKER_COMPOSE_RESTART', // Restart stack 'GET_LOGS', // Get container logs 'GET_STATUS', // Get system status + 'DOCKER_RELOAD', // Reload Docker stack + 'FILE_WRITE', // Write a file + 'ENV_UPDATE', // Update .env file + 'ENV_INSPECT', // Inspect .env file + 'FILE_INSPECT', // Inspect a file ] +function validatePayload(type: string, payload: Record): { valid: boolean; error?: string } { + switch (type) { + case 'DOCKER_RELOAD': { + const composePath = payload.compose_path as string + if (!composePath || !composePath.startsWith('/opt/letsbe/stacks/')) { + return { valid: false, error: 'compose_path must start with /opt/letsbe/stacks/' } + } + if (composePath.includes('..')) { + return { valid: false, error: 'Path traversal not allowed' } + } + return { valid: true } + } + case 'SHELL': { + return { valid: false, error: 'SHELL commands are not allowed via remote command API' } + } + case 'FILE_WRITE': { + const path = payload.path as string + if (!path || !path.startsWith('/opt/letsbe/')) { + return { valid: false, error: 'path must start with /opt/letsbe/' } + } + if (path.includes('..')) { + return { valid: false, error: 'Path traversal not allowed' } + } + return { valid: true } + } + case 'ENV_UPDATE': { + const envPath = payload.path as string + if (!envPath || !envPath.startsWith('/opt/letsbe/env/')) { + return { valid: false, error: 'path must start with /opt/letsbe/env/' } + } + if (envPath.includes('..')) { + return { valid: false, error: 'Path traversal not allowed' } + } + return { valid: true } + } + case 'ECHO': + case 'ENV_INSPECT': + case 'FILE_INSPECT': + return { valid: true } + default: + return { valid: false, error: `Unknown command type: ${type}` } + } +} + /** * GET /api/v1/admin/servers/[id]/command * Get command history for a server */ export async function GET(request: NextRequest, context: RouteContext) { try { - // Authentication check - const session = await auth() - if (!session || session.user.userType !== 'staff') { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + // Authentication check - require servers:view permission + await requireStaffPermission('servers:view') const { id: orderId } = await context.params const searchParams = request.nextUrl.searchParams @@ -75,7 +121,10 @@ export async function GET(request: NextRequest, context: RouteContext) { hasMore: offset + commands.length < total, }, }) - } catch (error) { + } catch (error: any) { + if (error?.status) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } console.error('Get commands error:', error) return NextResponse.json( { error: 'Internal server error' }, @@ -90,11 +139,8 @@ export async function GET(request: NextRequest, context: RouteContext) { */ export async function POST(request: NextRequest, context: RouteContext) { try { - // Authentication check - const session = await auth() - if (!session || session.user.userType !== 'staff') { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + // Authentication check - require servers:power permission for sending commands + const session = await requireStaffPermission('servers:power') const { id: orderId } = await context.params const body: CommandRequest = await request.json() @@ -116,6 +162,15 @@ export async function POST(request: NextRequest, context: RouteContext) { ) } + // Validate payload based on command type + const validation = validatePayload(body.type, body.payload || {}) + if (!validation.valid) { + return NextResponse.json( + { error: validation.error }, + { status: 400 } + ) + } + // Find server connection by order ID const serverConnection = await prisma.serverConnection.findUnique({ where: { orderId }, @@ -147,7 +202,7 @@ export async function POST(request: NextRequest, context: RouteContext) { serverConnectionId: serverConnection.id, type: body.type, payload: (body.payload || {}) as object, - initiatedBy: session?.user?.email || 'unknown', + initiatedBy: session.user.email || 'unknown', }, }) @@ -166,7 +221,10 @@ export async function POST(request: NextRequest, context: RouteContext) { lastHeartbeat: serverConnection.lastHeartbeat, }, }) - } catch (error) { + } catch (error: any) { + if (error?.status) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } console.error('Queue command error:', error) return NextResponse.json( { error: 'Internal server error' }, diff --git a/src/app/api/v1/orchestrator/commands/route.ts b/src/app/api/v1/orchestrator/commands/route.ts index 348428b..43a539c 100644 --- a/src/app/api/v1/orchestrator/commands/route.ts +++ b/src/app/api/v1/orchestrator/commands/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' +import { apiKeyService } from '@/lib/services/api-key-service' interface CommandResultRequest { commandId: string @@ -9,7 +10,7 @@ interface CommandResultRequest { } /** - * Validate Hub API key from Authorization header + * Validate Hub API key from Authorization header (uses hash-based lookup) */ async function validateHubApiKey(request: NextRequest) { const authHeader = request.headers.get('authorization') @@ -20,11 +21,7 @@ async function validateHubApiKey(request: NextRequest) { const hubApiKey = authHeader.replace('Bearer ', '') - const serverConnection = await prisma.serverConnection.findUnique({ - where: { hubApiKey }, - }) - - return serverConnection + return apiKeyService.findByApiKey(hubApiKey) } /** diff --git a/src/app/api/v1/orchestrator/heartbeat/route.ts b/src/app/api/v1/orchestrator/heartbeat/route.ts index 14ebb64..bd8feff 100644 --- a/src/app/api/v1/orchestrator/heartbeat/route.ts +++ b/src/app/api/v1/orchestrator/heartbeat/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' import { credentialService } from '@/lib/services/credential-service' +import { apiKeyService } from '@/lib/services/api-key-service' interface HeartbeatRequest { agentVersion?: string @@ -20,7 +21,7 @@ interface HeartbeatRequest { } /** - * Validate Hub API key from Authorization header + * Validate Hub API key from Authorization header (uses hash-based lookup) */ async function validateHubApiKey(request: NextRequest) { const authHeader = request.headers.get('authorization') @@ -31,20 +32,7 @@ async function validateHubApiKey(request: NextRequest) { const hubApiKey = authHeader.replace('Bearer ', '') - const serverConnection = await prisma.serverConnection.findUnique({ - where: { hubApiKey }, - include: { - order: { - select: { - id: true, - domain: true, - serverIp: true, - }, - }, - }, - }) - - return serverConnection + return apiKeyService.findByApiKey(hubApiKey) } /** diff --git a/src/app/api/v1/orchestrator/register/route.ts b/src/app/api/v1/orchestrator/register/route.ts index a260534..c3edec3 100644 --- a/src/app/api/v1/orchestrator/register/route.ts +++ b/src/app/api/v1/orchestrator/register/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' -import { randomBytes } from 'crypto' +import { apiKeyService } from '@/lib/services/api-key-service' interface RegisterRequest { registrationToken: string @@ -56,14 +56,16 @@ export async function POST(request: NextRequest) { ) } - // Generate Hub API key for this server - const hubApiKey = `hk_${randomBytes(32).toString('hex')}` + // Generate Hub API key for this server (store hash, return plaintext once) + const hubApiKey = apiKeyService.generateKey() + const hubApiKeyHash = apiKeyService.hashKey(hubApiKey) // Update server connection with registration info const updatedConnection = await prisma.serverConnection.update({ where: { id: serverConnection.id }, data: { hubApiKey, + hubApiKeyHash, orchestratorUrl: body.orchestratorUrl || null, agentVersion: body.agentVersion || null, status: 'REGISTERED', diff --git a/src/app/api/v1/webhooks/stripe/route.ts b/src/app/api/v1/webhooks/stripe/route.ts new file mode 100644 index 0000000..2922d04 --- /dev/null +++ b/src/app/api/v1/webhooks/stripe/route.ts @@ -0,0 +1,211 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { stripeService } from '@/lib/services/stripe-service' +import { + OrderStatus, + AutomationMode, + SubscriptionStatus, + UserStatus, +} from '@prisma/client' +import { emailService } from '@/lib/services/email-service' +import { randomBytes } from 'crypto' +import bcrypt from 'bcryptjs' + +/** + * Generate a customer ID slug from company/user name + * Only lowercase letters allowed (env_setup.sh requires ^[a-z]+$) + */ +function slugifyCustomer(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z]/g, '') + .substring(0, 32) +} + +/** + * Generate a unique license key for the order + */ +function generateLicenseKey(): string { + const hex = randomBytes(16).toString('hex') + return `lb_inst_${hex}` +} + +/** + * POST /api/v1/webhooks/stripe + * + * Handles Stripe webhook events. The primary event is checkout.session.completed, + * which creates a Customer, Order, and Subscription in the Hub database. + * + * Important: The raw body must be passed to constructEvent for signature verification. + * Next.js App Router provides the raw body via request.text(). + */ +export async function POST(request: NextRequest) { + if (!stripeService.isConfigured()) { + console.error('Stripe webhook received but Stripe is not configured') + return NextResponse.json( + { error: 'Stripe is not configured' }, + { status: 500 } + ) + } + + // Get the raw body as text for signature verification + const rawBody = await request.text() + + // Get the Stripe signature header + const signature = request.headers.get('stripe-signature') + if (!signature) { + return NextResponse.json( + { error: 'Missing stripe-signature header' }, + { status: 400 } + ) + } + + // Verify webhook signature and construct event + let event + try { + event = stripeService.constructWebhookEvent(rawBody, signature) + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + console.error(`Stripe webhook signature verification failed: ${message}`) + return NextResponse.json( + { error: `Webhook signature verification failed: ${message}` }, + { status: 400 } + ) + } + + // Handle the event + try { + switch (event.type) { + case 'checkout.session.completed': { + await handleCheckoutSessionCompleted(event.data.object) + break + } + + default: + // Acknowledge other events without processing + console.log(`Unhandled Stripe event type: ${event.type}`) + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + console.error(`Error handling Stripe event ${event.type}: ${message}`) + return NextResponse.json( + { error: `Failed to handle event: ${message}` }, + { status: 500 } + ) + } + + return NextResponse.json({ received: true }) +} + +/** + * Handle checkout.session.completed event + * + * 1. Find or create User by email + * 2. Create Subscription with Stripe IDs + * 3. Create Order with PAYMENT_CONFIRMED status + */ +async function handleCheckoutSessionCompleted( + session: Record +) { + const customerEmail = session.customer_email as string | undefined + const customerName = (session.metadata as Record)?.customer_name + const customerDomain = (session.metadata as Record)?.domain + const stripeCustomerId = session.customer as string | undefined + const stripeSubscriptionId = session.subscription as string | undefined + + if (!customerEmail) { + throw new Error('checkout.session.completed missing customer_email') + } + + // Extract plan info from session metadata + const planMapping = stripeService.extractPlanFromSession( + session as unknown as import('stripe').Stripe.Checkout.Session + ) + if (!planMapping) { + throw new Error( + 'Could not determine plan from checkout session. ' + + 'Ensure metadata includes price_id or plan.' + ) + } + + // 1. Find or create User + let user = await prisma.user.findUnique({ + where: { email: customerEmail }, + }) + + if (!user) { + const randomPassword = randomBytes(16).toString('hex') + const passwordHash = await bcrypt.hash(randomPassword, 10) + + user = await prisma.user.create({ + data: { + email: customerEmail, + name: customerName || null, + passwordHash, + status: UserStatus.ACTIVE, + }, + }) + } + + // 2. Create Subscription + await prisma.subscription.create({ + data: { + userId: user.id, + plan: planMapping.plan, + tier: planMapping.tier, + tokenLimit: planMapping.tokenLimit, + status: SubscriptionStatus.ACTIVE, + stripeCustomerId: stripeCustomerId || null, + stripeSubscriptionId: stripeSubscriptionId || null, + }, + }) + + // 3. Create Order + const displayName = customerName || user.company || user.name || 'customer' + const customerSlug = slugifyCustomer(displayName) || 'customer' + const domain = customerDomain || `${customerSlug}.letsbe.cloud` + const licenseKey = generateLicenseKey() + + await prisma.order.create({ + data: { + userId: user.id, + domain, + tier: planMapping.tier, + tools: planMapping.tools, + status: OrderStatus.PAYMENT_CONFIRMED, + automationMode: AutomationMode.AUTO, + source: 'stripe', + customer: customerSlug, + companyName: displayName, + licenseKey, + configJson: { + tools: planMapping.tools, + tier: planMapping.tier, + domain, + stripeSessionId: session.id, + stripeCustomerId, + stripeSubscriptionId, + }, + }, + }) + + console.log( + `Stripe checkout completed: created order for ${customerEmail} ` + + `(plan: ${planMapping.plan}, domain: ${domain})` + ) + + // 4. Send welcome email (non-blocking - don't fail webhook on email error) + try { + const emailResult = await emailService.sendWelcomeEmail({ + to: customerEmail, + customerName: customerName || user.name || '', + plan: planMapping.plan, + domain, + }) + if (!emailResult.success) { + console.warn(`Welcome email failed for ${customerEmail}: ${emailResult.error}`) + } + } catch (emailErr) { + console.warn('Failed to send welcome email:', emailErr) + } +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index b9c245e..d08fd1d 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -6,9 +6,8 @@ import { prisma } from './prisma' import { StaffRole, StaffStatus } from '@prisma/client' import { totpService } from './services/totp-service' -// Pending 2FA sessions (in-memory, 5-minute TTL) -// In production, consider using Redis for multi-instance support -interface Pending2FASession { +// Pending 2FA sessions - stored in DB for multi-instance support +interface Pending2FASessionData { userId: string userType: 'customer' | 'staff' email: string @@ -24,42 +23,61 @@ interface Pending2FASession { expiresAt: number } -const pending2FASessions = new Map() - -// Clean up expired sessions every minute -setInterval(() => { - const now = Date.now() - for (const [token, session] of pending2FASessions) { - if (session.expiresAt < now) { - pending2FASessions.delete(token) - } - } -}, 60000) - /** - * Get a pending 2FA session by token + * Get a pending 2FA session by token (from DB) */ -export function getPending2FASession(token: string): Pending2FASession | null { - const session = pending2FASessions.get(token) +export async function getPending2FASession(token: string): Promise { + const session = await prisma.pending2FASession.findUnique({ + where: { token }, + }) if (!session) return null - if (session.expiresAt < Date.now()) { - pending2FASessions.delete(token) + if (session.expiresAt < new Date()) { + await prisma.pending2FASession.delete({ where: { token } }).catch(() => {}) return null } - return session + return { + userId: session.userId, + userType: session.userType as 'customer' | 'staff', + email: session.email, + name: session.name, + role: session.role as StaffRole | undefined, + company: session.company, + subscription: session.subscription as Pending2FASessionData['subscription'], + expiresAt: session.expiresAt.getTime(), + } +} + +/** + * Store a pending 2FA session in DB + */ +async function storePending2FASession(token: string, data: Pending2FASessionData): Promise { + await prisma.pending2FASession.create({ + data: { + token, + userId: data.userId, + userType: data.userType, + email: data.email, + name: data.name, + role: data.role ?? null, + company: data.company ?? null, + subscription: data.subscription ?? undefined, + expiresAt: new Date(data.expiresAt), + }, + }) } /** * Clear a pending 2FA session after successful verification */ -export function clearPending2FASession(token: string): void { - pending2FASessions.delete(token) +export async function clearPending2FASession(token: string): Promise { + await prisma.pending2FASession.delete({ where: { token } }).catch(() => {}) } export const { handlers, auth, signIn, signOut } = NextAuth({ session: { strategy: 'jwt', - maxAge: 30 * 24 * 60 * 60, // 30 days + maxAge: 7 * 24 * 60 * 60, // 7 days + updateAge: 24 * 60 * 60, // Refresh token every 24 hours }, pages: { signIn: '/login', @@ -81,7 +99,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ const pendingToken = credentials.pendingToken as string const twoFactorToken = credentials.twoFactorToken as string - const pending = getPending2FASession(pendingToken) + const pending = await getPending2FASession(pendingToken) if (!pending) { throw new Error('Session expired. Please log in again.') } @@ -139,7 +157,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ } // Clear pending session - clearPending2FASession(pendingToken) + await clearPending2FASession(pendingToken) // Return the user data from the pending session if (pending.userType === 'staff') { @@ -198,10 +216,10 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ // Check if 2FA is enabled if (user.twoFactorEnabled) { - // Create pending 2FA session + // Create pending 2FA session (stored in DB) const pendingToken = crypto.randomBytes(32).toString('hex') const subscription = user.subscriptions[0] - pending2FASessions.set(pendingToken, { + await storePending2FASession(pendingToken, { userId: user.id, userType: 'customer', email: user.email, @@ -247,9 +265,9 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ // Check if 2FA is enabled if (staff.twoFactorEnabled) { - // Create pending 2FA session + // Create pending 2FA session (stored in DB) const pendingToken = crypto.randomBytes(32).toString('hex') - pending2FASessions.set(pendingToken, { + await storePending2FASession(pendingToken, { userId: staff.id, userType: 'staff', email: staff.email, diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..6c036ff --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,79 @@ +/** + * Simple in-memory rate limiter for API endpoints. + * Uses a sliding window approach with configurable limits. + * + * For production multi-instance deployments, consider Redis-backed rate limiting. + */ + +interface RateLimitEntry { + count: number + resetAt: number +} + +interface RateLimiterOptions { + /** Maximum number of requests in the window */ + maxRequests: number + /** Window duration in seconds */ + windowSeconds: number +} + +class RateLimiter { + private store = new Map() + private readonly maxRequests: number + private readonly windowMs: number + + constructor(options: RateLimiterOptions) { + this.maxRequests = options.maxRequests + this.windowMs = options.windowSeconds * 1000 + + // Cleanup expired entries every 60 seconds + setInterval(() => { + const now = Date.now() + for (const [key, entry] of this.store) { + if (entry.resetAt < now) { + this.store.delete(key) + } + } + }, 60000) + } + + /** + * Check if a request should be rate limited. + * Returns { limited: false } if allowed, or { limited: true, retryAfter } if blocked. + */ + check(key: string): { limited: boolean; retryAfter?: number; remaining?: number } { + const now = Date.now() + const entry = this.store.get(key) + + if (!entry || entry.resetAt < now) { + // First request or window expired + this.store.set(key, { count: 1, resetAt: now + this.windowMs }) + return { limited: false, remaining: this.maxRequests - 1 } + } + + if (entry.count >= this.maxRequests) { + const retryAfter = Math.ceil((entry.resetAt - now) / 1000) + return { limited: true, retryAfter } + } + + entry.count++ + return { limited: false, remaining: this.maxRequests - entry.count } + } +} + +// Pre-configured rate limiters for common use cases + +/** Login attempts: 5 per 15 minutes per IP */ +export const loginLimiter = new RateLimiter({ maxRequests: 5, windowSeconds: 900 }) + +/** 2FA verification attempts: 5 per 5 minutes per token */ +export const twoFactorLimiter = new RateLimiter({ maxRequests: 5, windowSeconds: 300 }) + +/** Registration: 3 per hour per IP */ +export const registrationLimiter = new RateLimiter({ maxRequests: 3, windowSeconds: 3600 }) + +/** General API: 100 per minute per IP */ +export const apiLimiter = new RateLimiter({ maxRequests: 100, windowSeconds: 60 }) + +export { RateLimiter } +export type { RateLimiterOptions } diff --git a/src/lib/services/api-key-service.ts b/src/lib/services/api-key-service.ts new file mode 100644 index 0000000..0bee4cd --- /dev/null +++ b/src/lib/services/api-key-service.ts @@ -0,0 +1,82 @@ +import crypto from 'crypto' +import { prisma } from '@/lib/prisma' + +/** + * Service for managing Hub API keys with SHA-256 hashing. + * API keys are stored as hashes to prevent plaintext exposure in the database. + */ +class ApiKeyService { + private static instance: ApiKeyService + + static getInstance(): ApiKeyService { + if (!ApiKeyService.instance) { + ApiKeyService.instance = new ApiKeyService() + } + return ApiKeyService.instance + } + + /** + * Generate a new Hub API key with prefix + */ + generateKey(): string { + return `hk_${crypto.randomBytes(32).toString('hex')}` + } + + /** + * Hash an API key using SHA-256 + */ + hashKey(key: string): string { + return crypto.createHash('sha256').update(key).digest('hex') + } + + /** + * Find a server connection by API key (checks both plaintext and hash) + * During migration, we check both fields. Once all keys are migrated, + * the plaintext hubApiKey column can be removed. + */ + async findByApiKey(apiKey: string) { + const hash = this.hashKey(apiKey) + + // First try hash lookup (preferred) + let connection = await prisma.serverConnection.findUnique({ + where: { hubApiKeyHash: hash }, + include: { + order: { + select: { + id: true, + domain: true, + serverIp: true, + }, + }, + }, + }) + + if (connection) return connection + + // Fallback to plaintext lookup during migration + connection = await prisma.serverConnection.findUnique({ + where: { hubApiKey: apiKey }, + include: { + order: { + select: { + id: true, + domain: true, + serverIp: true, + }, + }, + }, + }) + + // If found via plaintext, migrate to hash + if (connection && !connection.hubApiKeyHash) { + await prisma.serverConnection.update({ + where: { id: connection.id }, + data: { hubApiKeyHash: hash }, + }) + } + + return connection + } +} + +export const apiKeyService = ApiKeyService.getInstance() diff --git a/src/lib/services/email-service.ts b/src/lib/services/email-service.ts index 04af508..d86d8e9 100644 --- a/src/lib/services/email-service.ts +++ b/src/lib/services/email-service.ts @@ -183,6 +183,84 @@ export class EmailService { }) } + /** + * Send a welcome email after Stripe checkout completion + */ + async sendWelcomeEmail(params: { + to: string + customerName: string + plan: string + domain: string + }): Promise<{ success: boolean; error?: string; messageId?: string }> { + const planDisplay = params.plan === 'PRO' ? 'Professional' : 'Starter' + return this.sendEmail({ + to: params.to, + subject: `Welcome to LetsBe Cloud - Your ${planDisplay} plan is being set up`, + html: ` +
+
+

Welcome to LetsBe Cloud

+
+
+

+ Hi${params.customerName ? ` ${params.customerName}` : ''}, +

+

+ Thank you for choosing LetsBe Cloud! Your ${planDisplay} plan has been activated + and we are now setting up your dedicated server. +

+
+

Plan: ${planDisplay}

+

Domain: ${params.domain}

+

Status: Provisioning in progress

+
+

What happens next?

+
    +
  1. We provision your dedicated server with all your tools
  2. +
  3. SSL certificates are configured for every service
  4. +
  5. Single sign-on is set up across all your tools
  6. +
  7. You receive access credentials once everything is ready
  8. +
+

+ This process typically takes under 30 minutes. We will send you another email + once your server is ready with your login details. +

+

+ If you have any questions, reply to this email or contact us at + hello@letsbe.cloud. +

+
+
+

+ LetsBe Cloud — Your business tools, your server, fully managed. +

+
+
+ `, + text: [ + `Welcome to LetsBe Cloud!`, + ``, + `Hi${params.customerName ? ` ${params.customerName}` : ''},`, + ``, + `Thank you for choosing LetsBe Cloud! Your ${planDisplay} plan has been activated and we are now setting up your dedicated server.`, + ``, + `Plan: ${planDisplay}`, + `Domain: ${params.domain}`, + `Status: Provisioning in progress`, + ``, + `What happens next?`, + `1. We provision your dedicated server with all your tools`, + `2. SSL certificates are configured for every service`, + `3. Single sign-on is set up across all your tools`, + `4. You receive access credentials once everything is ready`, + ``, + `This process typically takes under 30 minutes. We will send you another email once your server is ready.`, + ``, + `Questions? Contact us at hello@letsbe.cloud`, + ].join('\n'), + }) + } + /** * Clear cached transporter (useful after config changes) */ diff --git a/src/lib/services/notification-service.ts b/src/lib/services/notification-service.ts index 6726d25..3bf9f66 100644 --- a/src/lib/services/notification-service.ts +++ b/src/lib/services/notification-service.ts @@ -1,4 +1,5 @@ import { prisma } from '@/lib/prisma' +import { emailService } from './email-service' import type { DetectedError, ErrorDetectionRule, ContainerEvent, NotificationSetting } from '@prisma/client' // ============================================================================ @@ -326,76 +327,36 @@ LetsBe Cloud Platform // -------------------------------------------------------------------------- /** - * Send email to all recipients + * Send email to all recipients via the centralized email service */ private async sendToRecipients( recipients: string[], subject: string, body: string ): Promise { - const resendApiKey = process.env.RESEND_API_KEY + const isConfigured = await emailService.isConfigured() - if (resendApiKey) { - await this.sendViaResend(recipients, subject, body, resendApiKey) - } else { - // Development mode - just log - console.log('No email service configured, logging notification:') - this.logEmail(recipients, subject, body) + if (!isConfigured) { + console.log('[Notification] Email not configured, logging notification:') + console.log(`To: ${recipients.join(', ')} | Subject: ${subject}`) + console.log(body) + return } - } - /** - * Send email via Resend API - */ - private async sendViaResend( - to: string[], - subject: string, - text: string, - apiKey: string - ): Promise { - const fromEmail = process.env.RESEND_FROM_EMAIL || 'alerts@letsbe.cloud' - const fromName = process.env.RESEND_FROM_NAME || 'LetsBe Alerts' + const result = await emailService.sendEmail({ + to: recipients, + subject, + html: `
${body.replace(//g, '>')}
`, + text: body, + }) - try { - const response = await fetch('https://api.resend.com/emails', { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - from: `${fromName} <${fromEmail}>`, - to, - subject, - text, - }), - }) - - if (!response.ok) { - const error = await response.text() - throw new Error(`Resend API error: ${error}`) - } - - console.log(`[Notification] Email sent to ${to.length} recipient(s)`) - } catch (error) { - console.error('[Notification] Failed to send email:', error) + if (result.success) { + console.log(`[Notification] Email sent to ${recipients.length} recipient(s)`) + } else { + console.error('[Notification] Failed to send email:', result.error) throw new Error('Failed to send notification email') } } - - /** - * Log email for development - */ - private logEmail(to: string[], subject: string, body: string): void { - console.log('='.repeat(60)) - console.log('EMAIL NOTIFICATION') - console.log('='.repeat(60)) - console.log(`To: ${to.join(', ')}`) - console.log(`Subject: ${subject}`) - console.log('-'.repeat(60)) - console.log(body) - console.log('='.repeat(60)) - } } export const notificationService = NotificationService.getInstance() diff --git a/src/lib/services/security-verification-service.ts b/src/lib/services/security-verification-service.ts index ebe9d02..ca6c21d 100644 --- a/src/lib/services/security-verification-service.ts +++ b/src/lib/services/security-verification-service.ts @@ -1,4 +1,5 @@ import { prisma } from '@/lib/prisma' +import { emailService } from './email-service' import crypto from 'crypto' // ============================================================================ @@ -26,7 +27,8 @@ export interface VerifyCodeResult { class SecurityVerificationService { private static instance: SecurityVerificationService private readonly CODE_EXPIRY_MINUTES = 15 - private readonly CODE_LENGTH = 6 + private readonly CODE_LENGTH = 8 + private readonly MAX_ATTEMPTS = 5 static getInstance(): SecurityVerificationService { if (!SecurityVerificationService.instance) { @@ -36,12 +38,12 @@ class SecurityVerificationService { } /** - * Generate a 6-digit verification code + * Generate an 8-digit verification code */ private generateCode(): string { - // Generate a cryptographically secure 6-digit code + // Generate a cryptographically secure 8-digit code const buffer = crypto.randomBytes(4) - const number = buffer.readUInt32BE(0) % 1000000 + const number = buffer.readUInt32BE(0) % 100000000 return number.toString().padStart(this.CODE_LENGTH, '0') } @@ -119,19 +121,21 @@ class SecurityVerificationService { } /** - * Verify a code and return the associated action/server if valid + * Verify a code and return the associated action/server if valid. + * Enforces attempt limits to prevent brute-force attacks. */ async verifyCode( clientId: string, code: string ): Promise { + // Find the most recent active code for this client const verificationCode = await prisma.securityVerificationCode.findFirst({ where: { clientId, - code, usedAt: null, expiresAt: { gt: new Date() } - } + }, + orderBy: { createdAt: 'desc' } }) if (!verificationCode) { @@ -141,6 +145,35 @@ class SecurityVerificationService { } } + // Check if max attempts exceeded + if (verificationCode.attempts >= this.MAX_ATTEMPTS) { + // Invalidate the code + await prisma.securityVerificationCode.update({ + where: { id: verificationCode.id }, + data: { usedAt: new Date() } + }) + return { + valid: false, + errorMessage: 'Too many failed attempts. Please request a new code.' + } + } + + // Check if code matches + if (verificationCode.code !== code) { + // Increment attempt counter + await prisma.securityVerificationCode.update({ + where: { id: verificationCode.id }, + data: { attempts: { increment: 1 } } + }) + const remaining = this.MAX_ATTEMPTS - verificationCode.attempts - 1 + return { + valid: false, + errorMessage: remaining > 0 + ? `Invalid verification code. ${remaining} attempt(s) remaining.` + : 'Too many failed attempts. Please request a new code.' + } + } + // Mark code as used await prisma.securityVerificationCode.update({ where: { id: verificationCode.id }, @@ -155,8 +188,7 @@ class SecurityVerificationService { } /** - * Send verification email - * Currently logs to console; can be replaced with Resend/SMTP + * Send verification email via the centralized email service */ private async sendVerificationEmail( email: string, @@ -168,7 +200,7 @@ class SecurityVerificationService { const actionText = action === 'WIPE' ? 'wipe' : 'reinstall' const subject = `[LetsBe] Verification Code for Server ${actionText.charAt(0).toUpperCase() + actionText.slice(1)}` - const body = ` + const textBody = ` Hello, A request has been made to ${actionText} the server "${serverName}" for ${clientName}. @@ -185,75 +217,30 @@ If you did not request this action, please ignore this email and contact support LetsBe Cloud Platform `.trim() - // Check if we have email configuration - const resendApiKey = process.env.RESEND_API_KEY - const smtpHost = process.env.SMTP_HOST + const isConfigured = await emailService.isConfigured() - if (resendApiKey) { - await this.sendViaResend(email, subject, body, resendApiKey) - } else if (smtpHost) { - // SMTP implementation would go here - console.log('SMTP email sending not yet implemented') - this.logEmail(email, subject, body) - } else { - // Development mode - just log - console.log('No email service configured, logging email:') - this.logEmail(email, subject, body) + if (!isConfigured) { + console.log('Email not configured, logging verification code:') + console.log(`To: ${email} | Subject: ${subject}`) + console.log(textBody) + return } - } - /** - * Send email via Resend API - */ - private async sendViaResend( - to: string, - subject: string, - text: string, - apiKey: string - ): Promise { - const fromEmail = process.env.RESEND_FROM_EMAIL || 'noreply@letsbe.cloud' + const result = await emailService.sendEmail({ + to: email, + subject, + html: `
${textBody.replace(//g, '>')}
`, + text: textBody, + }) - try { - const response = await fetch('https://api.resend.com/emails', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${apiKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - from: fromEmail, - to: [to], - subject, - text - }) - }) - - if (!response.ok) { - const error = await response.text() - throw new Error(`Resend API error: ${error}`) - } - - console.log(`Verification email sent to ${this.maskEmail(to)}`) - } catch (error) { - console.error('Failed to send verification email:', error) + if (result.success) { + console.log(`Verification email sent to ${this.maskEmail(email)}`) + } else { + console.error('Failed to send verification email:', result.error) throw new Error('Failed to send verification email') } } - /** - * Log email for development - */ - private logEmail(to: string, subject: string, body: string): void { - console.log('='.repeat(60)) - console.log('EMAIL NOTIFICATION') - console.log('='.repeat(60)) - console.log(`To: ${to}`) - console.log(`Subject: ${subject}`) - console.log('-'.repeat(60)) - console.log(body) - console.log('='.repeat(60)) - } - /** * Mask email for display (e.g., j***@example.com) */ diff --git a/src/lib/services/storage-service.ts b/src/lib/services/storage-service.ts index 380e86a..77f568c 100644 --- a/src/lib/services/storage-service.ts +++ b/src/lib/services/storage-service.ts @@ -171,6 +171,15 @@ export class StorageService { contentType: string ): Promise<{ success: boolean; key: string; url: string | null; error?: string }> { try { + if (!(await this.isConfigured())) { + return { + success: false, + key, + url: null, + error: 'Storage not configured. Please configure S3/MinIO settings in Admin Settings.', + } + } + const client = await this.getClient() const config = await settingsService.getStorageConfig() @@ -206,6 +215,10 @@ export class StorageService { */ async deleteFile(key: string): Promise<{ success: boolean; error?: string }> { try { + if (!(await this.isConfigured())) { + return { success: false, error: 'Storage not configured' } + } + const client = await this.getClient() const config = await settingsService.getStorageConfig() @@ -231,6 +244,10 @@ export class StorageService { */ async getFile(key: string): Promise { try { + if (!(await this.isConfigured())) { + return null + } + const client = await this.getClient() const config = await settingsService.getStorageConfig() diff --git a/src/lib/services/stripe-service.ts b/src/lib/services/stripe-service.ts new file mode 100644 index 0000000..c3d308b --- /dev/null +++ b/src/lib/services/stripe-service.ts @@ -0,0 +1,161 @@ +import Stripe from 'stripe' +import { SubscriptionPlan, SubscriptionTier } from '@prisma/client' + +if (!process.env.STRIPE_SECRET_KEY) { + console.warn('STRIPE_SECRET_KEY not configured - Stripe integration disabled') +} + +const stripe = process.env.STRIPE_SECRET_KEY + ? new Stripe(process.env.STRIPE_SECRET_KEY) + : null + +export interface PlanMapping { + plan: SubscriptionPlan + tier: SubscriptionTier + tokenLimit: number + tools: string[] +} + +const STARTER_TOOLS = [ + 'nextcloud', 'keycloak', 'chatwoot', 'poste', 'n8n', + 'calcom', 'umami', 'uptime-kuma', 'vaultwarden', 'portainer', +] + +const PRO_TOOLS = [ + ...STARTER_TOOLS, + 'ghost', 'nocodb', 'listmonk', 'gitea', 'minio', + 'penpot', 'typebot', 'windmill', 'redash', 'glitchtip', +] + +/** + * Map a Stripe Price ID to a LetsBe plan + */ +function mapPriceToplan(priceId: string): PlanMapping | null { + const starterPriceId = process.env.STRIPE_STARTER_PRICE_ID + const proPriceId = process.env.STRIPE_PRO_PRICE_ID + + if (priceId === starterPriceId) { + return { + plan: SubscriptionPlan.STARTER, + tier: SubscriptionTier.ADVANCED, + tokenLimit: 10000, + tools: STARTER_TOOLS, + } + } + + if (priceId === proPriceId) { + return { + plan: SubscriptionPlan.PRO, + tier: SubscriptionTier.HUB_DASHBOARD, + tokenLimit: 50000, + tools: PRO_TOOLS, + } + } + + return null +} + +class StripeService { + private static instance: StripeService + + static getInstance(): StripeService { + if (!StripeService.instance) { + StripeService.instance = new StripeService() + } + return StripeService.instance + } + + /** + * Check if Stripe is configured and available + */ + isConfigured(): boolean { + return stripe !== null + } + + /** + * Verify a webhook signature and construct the event + */ + constructWebhookEvent(rawBody: string | Buffer, signature: string): Stripe.Event { + if (!stripe) { + throw new Error('Stripe is not configured') + } + + const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET + if (!webhookSecret) { + throw new Error('STRIPE_WEBHOOK_SECRET is not configured') + } + + return stripe.webhooks.constructEvent(rawBody, signature, webhookSecret) + } + + /** + * Create a Stripe Checkout Session for a given price + */ + async createCheckoutSession(params: { + priceId: string + customerEmail?: string + successUrl: string + cancelUrl: string + metadata?: Record + }): Promise { + if (!stripe) { + throw new Error('Stripe is not configured') + } + + return stripe.checkout.sessions.create({ + mode: 'subscription', + payment_method_types: ['card'], + line_items: [ + { + price: params.priceId, + quantity: 1, + }, + ], + customer_email: params.customerEmail, + success_url: params.successUrl, + cancel_url: params.cancelUrl, + metadata: params.metadata, + }) + } + + /** + * Map a Stripe price ID to a LetsBe plan configuration + */ + mapPriceToPlan(priceId: string): PlanMapping | null { + return mapPriceToplan(priceId) + } + + /** + * Extract plan info from a checkout session + */ + extractPlanFromSession(session: Stripe.Checkout.Session): PlanMapping | null { + // Get the price ID from the line items (available via expand or metadata) + const priceId = session.metadata?.price_id + if (priceId) { + return this.mapPriceToPlan(priceId) + } + + // Fallback: check metadata for plan name + const planName = session.metadata?.plan + if (planName === 'starter') { + return { + plan: SubscriptionPlan.STARTER, + tier: SubscriptionTier.ADVANCED, + tokenLimit: 10000, + tools: STARTER_TOOLS, + } + } + if (planName === 'professional' || planName === 'pro') { + return { + plan: SubscriptionPlan.PRO, + tier: SubscriptionTier.HUB_DASHBOARD, + tokenLimit: 50000, + tools: PRO_TOOLS, + } + } + + return null + } +} + +export const stripeService = StripeService.getInstance() diff --git a/startup.sh b/startup.sh new file mode 100644 index 0000000..05081c2 --- /dev/null +++ b/startup.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -e + +echo "Running database migrations..." +prisma migrate deploy --schema=./prisma/schema.prisma 2>&1 || { + echo "WARNING: Migration failed. Starting app anyway (migrations may already be applied)." +} + +echo "Starting application..." +exec node server.js diff --git a/vitest.config.ts b/vitest.config.ts index 37004ce..22702f2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,12 @@ import path from 'path' export default defineConfig({ plugins: [react()], + resolve: { + alias: { + // Mock @prisma/adapter-pg (Prisma 7 adapter not available locally on Node 23) + '@prisma/adapter-pg': path.resolve(__dirname, './src/__tests__/mocks/prisma-adapter-pg.ts'), + }, + }, test: { environment: 'jsdom', globals: true,