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 <noreply@anthropic.com>
This commit is contained in:
parent
bcc1e17934
commit
1c96c3a85e
23
.env.example
23
.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=
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
17
Dockerfile
17
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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, string[]> = {
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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<string, string[] | null>) {
|
||||
const resolve4 = dns.resolve4 as unknown as ReturnType<typeof vi.fn>
|
||||
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<typeof vi.fn>
|
||||
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<typeof vi.fn>
|
||||
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<typeof vi.fn>
|
||||
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<typeof vi.fn>
|
||||
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<typeof vi.fn>
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>): { 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' },
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>
|
||||
) {
|
||||
const customerEmail = session.customer_email as string | undefined
|
||||
const customerName = (session.metadata as Record<string, string>)?.customer_name
|
||||
const customerDomain = (session.metadata as Record<string, string>)?.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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, Pending2FASession>()
|
||||
|
||||
// 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<Pending2FASessionData | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<string, RateLimitEntry>()
|
||||
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 }
|
||||
|
|
@ -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()
|
||||
|
|
@ -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: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; background: #ffffff;">
|
||||
<div style="background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); padding: 32px 24px; text-align: center;">
|
||||
<h1 style="color: white; margin: 0; font-size: 24px; font-weight: 700;">Welcome to LetsBe Cloud</h1>
|
||||
</div>
|
||||
<div style="padding: 32px 24px;">
|
||||
<p style="color: #374151; font-size: 16px; line-height: 1.6; margin-top: 0;">
|
||||
Hi${params.customerName ? ` ${params.customerName}` : ''},
|
||||
</p>
|
||||
<p style="color: #374151; font-size: 16px; line-height: 1.6;">
|
||||
Thank you for choosing LetsBe Cloud! Your <strong>${planDisplay}</strong> plan has been activated
|
||||
and we are now setting up your dedicated server.
|
||||
</p>
|
||||
<div style="background: #f3f4f6; border-radius: 8px; padding: 20px; margin: 24px 0;">
|
||||
<p style="color: #374151; font-size: 14px; margin: 0 0 8px 0;"><strong>Plan:</strong> ${planDisplay}</p>
|
||||
<p style="color: #374151; font-size: 14px; margin: 0 0 8px 0;"><strong>Domain:</strong> ${params.domain}</p>
|
||||
<p style="color: #374151; font-size: 14px; margin: 0;"><strong>Status:</strong> Provisioning in progress</p>
|
||||
</div>
|
||||
<h2 style="color: #111827; font-size: 18px; margin-top: 28px;">What happens next?</h2>
|
||||
<ol style="color: #374151; font-size: 14px; line-height: 1.8; padding-left: 20px;">
|
||||
<li>We provision your dedicated server with all your tools</li>
|
||||
<li>SSL certificates are configured for every service</li>
|
||||
<li>Single sign-on is set up across all your tools</li>
|
||||
<li>You receive access credentials once everything is ready</li>
|
||||
</ol>
|
||||
<p style="color: #374151; font-size: 14px; line-height: 1.6;">
|
||||
This process typically takes under 30 minutes. We will send you another email
|
||||
once your server is ready with your login details.
|
||||
</p>
|
||||
<p style="color: #6b7280; font-size: 14px; line-height: 1.6; margin-top: 24px;">
|
||||
If you have any questions, reply to this email or contact us at
|
||||
<a href="mailto:hello@letsbe.cloud" style="color: #2563eb;">hello@letsbe.cloud</a>.
|
||||
</p>
|
||||
</div>
|
||||
<div style="padding: 20px 24px; background: #f9fafb; border-top: 1px solid #e5e7eb; text-align: center;">
|
||||
<p style="color: #9ca3af; margin: 0; font-size: 12px;">
|
||||
LetsBe Cloud — Your business tools, your server, fully managed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
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)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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: `<pre style="font-family: monospace; white-space: pre-wrap;">${body.replace(/</g, '<').replace(/>/g, '>')}</pre>`,
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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<VerifyCodeResult> {
|
||||
// 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<void> {
|
||||
const fromEmail = process.env.RESEND_FROM_EMAIL || 'noreply@letsbe.cloud'
|
||||
const result = await emailService.sendEmail({
|
||||
to: email,
|
||||
subject,
|
||||
html: `<pre style="font-family: monospace; white-space: pre-wrap;">${textBody.replace(/</g, '<').replace(/>/g, '>')}</pre>`,
|
||||
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)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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<Buffer | null> {
|
||||
try {
|
||||
if (!(await this.isConfigured())) {
|
||||
return null
|
||||
}
|
||||
|
||||
const client = await this.getClient()
|
||||
const config = await settingsService.getStorageConfig()
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, string>
|
||||
}): Promise<Stripe.Checkout.Session> {
|
||||
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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue