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
|
# LetsBe Hub Configuration
|
||||||
|
|
||||||
# Database
|
# 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 IN PRODUCTION!)
|
||||||
ADMIN_API_KEY=change-me-in-production
|
ADMIN_API_KEY=change-me-in-production
|
||||||
|
|
@ -11,3 +11,24 @@ DEBUG=false
|
||||||
|
|
||||||
# Telemetry retention (days)
|
# Telemetry retention (days)
|
||||||
TELEMETRY_RETENTION_DAYS=90
|
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.development.local
|
||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
|
deploy/.env
|
||||||
!.env.example
|
!.env.example
|
||||||
!.env.local.example
|
!.env.local.example
|
||||||
|
!deploy/.env.example
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
|
|
||||||
17
Dockerfile
17
Dockerfile
|
|
@ -9,8 +9,9 @@ WORKDIR /app
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
# Generate Prisma Client
|
# Generate Prisma Client (Prisma 7 uses prisma.config.mjs for datasource URL)
|
||||||
COPY prisma ./prisma/
|
COPY prisma ./prisma/
|
||||||
|
COPY prisma.config.mjs ./
|
||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
|
|
||||||
# Rebuild the source code only when needed
|
# 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/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
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 --from=deps /app/node_modules/@prisma ./node_modules/@prisma
|
COPY --from=deps /app/node_modules/@prisma ./node_modules/@prisma
|
||||||
COPY prisma ./prisma/
|
COPY prisma ./prisma/
|
||||||
COPY prisma.config.mjs ./
|
COPY prisma.config.mjs ./
|
||||||
|
|
||||||
# Install Prisma CLI and dotenv globally for migrations
|
# Install Prisma CLI globally for running migrations on startup
|
||||||
RUN npm install -g prisma@7 dotenv
|
# (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
|
USER nextjs
|
||||||
|
|
||||||
|
|
@ -77,4 +84,4 @@ EXPOSE 3000
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV HOSTNAME="0.0.0.0"
|
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
|
CREDENTIAL_ENCRYPTION_KEY: letsbe-hub-credential-encryption-key-dev-only
|
||||||
# Encryption key for settings service (SMTP passwords, tokens, etc.)
|
# Encryption key for settings service (SMTP passwords, tokens, etc.)
|
||||||
SETTINGS_ENCRYPTION_KEY: letsbe-hub-settings-encryption-key-dev-only
|
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)
|
# Host paths for job config files (used when spawning runner containers)
|
||||||
# On Windows with Docker Desktop, use /c/Repos/... format
|
# On Windows with Docker Desktop, use /c/Repos/... format
|
||||||
JOBS_HOST_DIR: /c/Repos/LetsBeV2_NoAISysAdmin/letsbe-hub/jobs
|
JOBS_HOST_DIR: /c/Repos/LetsBeV2_NoAISysAdmin/letsbe-hub/jobs
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,50 @@
|
||||||
import type { NextConfig } from 'next'
|
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 = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
// reactCompiler: true, // Requires babel-plugin-react-compiler - enable later
|
// reactCompiler: true, // Requires babel-plugin-react-compiler - enable later
|
||||||
|
|
@ -31,6 +76,14 @@ const nextConfig: NextConfig = {
|
||||||
},
|
},
|
||||||
// Externalize ssh2 for both Turbopack and Webpack
|
// Externalize ssh2 for both Turbopack and Webpack
|
||||||
serverExternalPackages: ['ssh2'],
|
serverExternalPackages: ['ssh2'],
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/(.*)',
|
||||||
|
headers: securityHeaders,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default nextConfig
|
export default nextConfig
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
"ssh2": "^1.17.0",
|
"ssh2": "^1.17.0",
|
||||||
|
"stripe": "^17.0.0",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"undici": "^7.18.2",
|
"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 {
|
datasource db {
|
||||||
provider = "postgresql"
|
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)
|
// Hub API key (issued after successful registration, used for heartbeats/commands)
|
||||||
hubApiKey String? @unique @map("hub_api_key")
|
hubApiKey String? @unique @map("hub_api_key")
|
||||||
|
hubApiKeyHash String? @unique @map("hub_api_key_hash")
|
||||||
|
|
||||||
// Orchestrator connection info (provided during registration)
|
// Orchestrator connection info (provided during registration)
|
||||||
orchestratorUrl String? @map("orchestrator_url")
|
orchestratorUrl String? @map("orchestrator_url")
|
||||||
|
|
@ -613,11 +614,12 @@ model DetectedError {
|
||||||
model SecurityVerificationCode {
|
model SecurityVerificationCode {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
clientId String @map("client_id")
|
clientId String @map("client_id")
|
||||||
code String // 6-digit code
|
code String // 8-digit code
|
||||||
action String // "WIPE" | "REINSTALL"
|
action String // "WIPE" | "REINSTALL"
|
||||||
targetServerId String @map("target_server_id") // Which server
|
targetServerId String @map("target_server_id") // Which server
|
||||||
expiresAt DateTime @map("expires_at")
|
expiresAt DateTime @map("expires_at")
|
||||||
usedAt DateTime? @map("used_at")
|
usedAt DateTime? @map("used_at")
|
||||||
|
attempts Int @default(0) // Failed verification attempts
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
|
|
@ -705,6 +707,28 @@ model NotificationSetting {
|
||||||
@@map("notification_settings")
|
@@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
|
// 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 { 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) {
|
export async function GET(request: NextRequest) {
|
||||||
// Verify cron secret (for security in production)
|
// Verify cron secret
|
||||||
const cronSecret = process.env.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')
|
const authHeader = request.headers.get('authorization')
|
||||||
|
if (authHeader !== `Bearer ${cronSecret}`) {
|
||||||
if (cronSecret && authHeader !== `Bearer ${cronSecret}`) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
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
|
* 3. Checks container health and detects crashes/OOM kills
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
// Verify cron secret (for security in production)
|
// Verify cron secret
|
||||||
const cronSecret = process.env.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')
|
const authHeader = request.headers.get('authorization')
|
||||||
|
if (authHeader !== `Bearer ${cronSecret}`) {
|
||||||
if (cronSecret && authHeader !== `Bearer ${cronSecret}`) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { auth } from '@/lib/auth'
|
import { requireStaffPermission } from '@/lib/auth-helpers'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { JobStatus, OrderStatus } from '@prisma/client'
|
import { JobStatus, OrderStatus } from '@prisma/client'
|
||||||
import { randomBytes } from 'crypto'
|
import { randomBytes } from 'crypto'
|
||||||
|
|
@ -28,11 +28,8 @@ export async function POST(
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const session = await auth()
|
// Require orders:provision permission (OWNER, ADMIN, MANAGER)
|
||||||
|
await requireStaffPermission('orders:provision')
|
||||||
if (!session || session.user.userType !== 'staff') {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id: orderId } = await params
|
const { id: orderId } = await params
|
||||||
|
|
||||||
|
|
@ -297,7 +294,10 @@ export async function POST(
|
||||||
containerName: spawnResult.containerName,
|
containerName: spawnResult.containerName,
|
||||||
serverConnectionId: serverConnection.id,
|
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)
|
console.error('Error triggering provisioning:', error)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to trigger provisioning' },
|
{ error: 'Failed to trigger provisioning' },
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import { auth } from '@/lib/auth'
|
import { auth } from '@/lib/auth'
|
||||||
|
import { requireStaffPermission } from '@/lib/auth-helpers'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { OrderStatus } from '@prisma/client'
|
import { OrderStatus } from '@prisma/client'
|
||||||
import { processAutomation } from '@/lib/services/automation-worker'
|
import { processAutomation } from '@/lib/services/automation-worker'
|
||||||
|
|
@ -321,11 +322,8 @@ export async function DELETE(
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const session = await auth()
|
// Require orders:delete permission (OWNER, ADMIN only)
|
||||||
|
await requireStaffPermission('orders:delete')
|
||||||
if (!session || session.user.userType !== 'staff') {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id: orderId } = await params
|
const { id: orderId } = await params
|
||||||
|
|
||||||
|
|
@ -371,7 +369,10 @@ export async function DELETE(
|
||||||
success: true,
|
success: true,
|
||||||
message: `Order ${orderId} and all related records deleted`,
|
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)
|
console.error('Error deleting order:', error)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to delete order' },
|
{ error: 'Failed to delete order' },
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { auth } from '@/lib/auth'
|
import { requireStaffPermission } from '@/lib/auth-helpers'
|
||||||
|
|
||||||
interface RouteContext {
|
interface RouteContext {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
|
|
@ -22,19 +22,65 @@ const ALLOWED_COMMAND_TYPES = [
|
||||||
'DOCKER_COMPOSE_RESTART', // Restart stack
|
'DOCKER_COMPOSE_RESTART', // Restart stack
|
||||||
'GET_LOGS', // Get container logs
|
'GET_LOGS', // Get container logs
|
||||||
'GET_STATUS', // Get system status
|
'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 /api/v1/admin/servers/[id]/command
|
||||||
* Get command history for a server
|
* Get command history for a server
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest, context: RouteContext) {
|
export async function GET(request: NextRequest, context: RouteContext) {
|
||||||
try {
|
try {
|
||||||
// Authentication check
|
// Authentication check - require servers:view permission
|
||||||
const session = await auth()
|
await requireStaffPermission('servers:view')
|
||||||
if (!session || session.user.userType !== 'staff') {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id: orderId } = await context.params
|
const { id: orderId } = await context.params
|
||||||
const searchParams = request.nextUrl.searchParams
|
const searchParams = request.nextUrl.searchParams
|
||||||
|
|
@ -75,7 +121,10 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
||||||
hasMore: offset + commands.length < total,
|
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)
|
console.error('Get commands error:', error)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Internal server error' },
|
{ error: 'Internal server error' },
|
||||||
|
|
@ -90,11 +139,8 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest, context: RouteContext) {
|
export async function POST(request: NextRequest, context: RouteContext) {
|
||||||
try {
|
try {
|
||||||
// Authentication check
|
// Authentication check - require servers:power permission for sending commands
|
||||||
const session = await auth()
|
const session = await requireStaffPermission('servers:power')
|
||||||
if (!session || session.user.userType !== 'staff') {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id: orderId } = await context.params
|
const { id: orderId } = await context.params
|
||||||
const body: CommandRequest = await request.json()
|
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
|
// Find server connection by order ID
|
||||||
const serverConnection = await prisma.serverConnection.findUnique({
|
const serverConnection = await prisma.serverConnection.findUnique({
|
||||||
where: { orderId },
|
where: { orderId },
|
||||||
|
|
@ -147,7 +202,7 @@ export async function POST(request: NextRequest, context: RouteContext) {
|
||||||
serverConnectionId: serverConnection.id,
|
serverConnectionId: serverConnection.id,
|
||||||
type: body.type,
|
type: body.type,
|
||||||
payload: (body.payload || {}) as object,
|
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,
|
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)
|
console.error('Queue command error:', error)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Internal server error' },
|
{ error: 'Internal server error' },
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { apiKeyService } from '@/lib/services/api-key-service'
|
||||||
|
|
||||||
interface CommandResultRequest {
|
interface CommandResultRequest {
|
||||||
commandId: string
|
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) {
|
async function validateHubApiKey(request: NextRequest) {
|
||||||
const authHeader = request.headers.get('authorization')
|
const authHeader = request.headers.get('authorization')
|
||||||
|
|
@ -20,11 +21,7 @@ async function validateHubApiKey(request: NextRequest) {
|
||||||
|
|
||||||
const hubApiKey = authHeader.replace('Bearer ', '')
|
const hubApiKey = authHeader.replace('Bearer ', '')
|
||||||
|
|
||||||
const serverConnection = await prisma.serverConnection.findUnique({
|
return apiKeyService.findByApiKey(hubApiKey)
|
||||||
where: { hubApiKey },
|
|
||||||
})
|
|
||||||
|
|
||||||
return serverConnection
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { credentialService } from '@/lib/services/credential-service'
|
import { credentialService } from '@/lib/services/credential-service'
|
||||||
|
import { apiKeyService } from '@/lib/services/api-key-service'
|
||||||
|
|
||||||
interface HeartbeatRequest {
|
interface HeartbeatRequest {
|
||||||
agentVersion?: string
|
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) {
|
async function validateHubApiKey(request: NextRequest) {
|
||||||
const authHeader = request.headers.get('authorization')
|
const authHeader = request.headers.get('authorization')
|
||||||
|
|
@ -31,20 +32,7 @@ async function validateHubApiKey(request: NextRequest) {
|
||||||
|
|
||||||
const hubApiKey = authHeader.replace('Bearer ', '')
|
const hubApiKey = authHeader.replace('Bearer ', '')
|
||||||
|
|
||||||
const serverConnection = await prisma.serverConnection.findUnique({
|
return apiKeyService.findByApiKey(hubApiKey)
|
||||||
where: { hubApiKey },
|
|
||||||
include: {
|
|
||||||
order: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
domain: true,
|
|
||||||
serverIp: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return serverConnection
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { randomBytes } from 'crypto'
|
import { apiKeyService } from '@/lib/services/api-key-service'
|
||||||
|
|
||||||
interface RegisterRequest {
|
interface RegisterRequest {
|
||||||
registrationToken: string
|
registrationToken: string
|
||||||
|
|
@ -56,14 +56,16 @@ export async function POST(request: NextRequest) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate Hub API key for this server
|
// Generate Hub API key for this server (store hash, return plaintext once)
|
||||||
const hubApiKey = `hk_${randomBytes(32).toString('hex')}`
|
const hubApiKey = apiKeyService.generateKey()
|
||||||
|
const hubApiKeyHash = apiKeyService.hashKey(hubApiKey)
|
||||||
|
|
||||||
// Update server connection with registration info
|
// Update server connection with registration info
|
||||||
const updatedConnection = await prisma.serverConnection.update({
|
const updatedConnection = await prisma.serverConnection.update({
|
||||||
where: { id: serverConnection.id },
|
where: { id: serverConnection.id },
|
||||||
data: {
|
data: {
|
||||||
hubApiKey,
|
hubApiKey,
|
||||||
|
hubApiKeyHash,
|
||||||
orchestratorUrl: body.orchestratorUrl || null,
|
orchestratorUrl: body.orchestratorUrl || null,
|
||||||
agentVersion: body.agentVersion || null,
|
agentVersion: body.agentVersion || null,
|
||||||
status: 'REGISTERED',
|
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 { StaffRole, StaffStatus } from '@prisma/client'
|
||||||
import { totpService } from './services/totp-service'
|
import { totpService } from './services/totp-service'
|
||||||
|
|
||||||
// Pending 2FA sessions (in-memory, 5-minute TTL)
|
// Pending 2FA sessions - stored in DB for multi-instance support
|
||||||
// In production, consider using Redis for multi-instance support
|
interface Pending2FASessionData {
|
||||||
interface Pending2FASession {
|
|
||||||
userId: string
|
userId: string
|
||||||
userType: 'customer' | 'staff'
|
userType: 'customer' | 'staff'
|
||||||
email: string
|
email: string
|
||||||
|
|
@ -24,42 +23,61 @@ interface Pending2FASession {
|
||||||
expiresAt: number
|
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 {
|
export async function getPending2FASession(token: string): Promise<Pending2FASessionData | null> {
|
||||||
const session = pending2FASessions.get(token)
|
const session = await prisma.pending2FASession.findUnique({
|
||||||
|
where: { token },
|
||||||
|
})
|
||||||
if (!session) return null
|
if (!session) return null
|
||||||
if (session.expiresAt < Date.now()) {
|
if (session.expiresAt < new Date()) {
|
||||||
pending2FASessions.delete(token)
|
await prisma.pending2FASession.delete({ where: { token } }).catch(() => {})
|
||||||
return null
|
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
|
* Clear a pending 2FA session after successful verification
|
||||||
*/
|
*/
|
||||||
export function clearPending2FASession(token: string): void {
|
export async function clearPending2FASession(token: string): Promise<void> {
|
||||||
pending2FASessions.delete(token)
|
await prisma.pending2FASession.delete({ where: { token } }).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
session: {
|
session: {
|
||||||
strategy: 'jwt',
|
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: {
|
pages: {
|
||||||
signIn: '/login',
|
signIn: '/login',
|
||||||
|
|
@ -81,7 +99,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
const pendingToken = credentials.pendingToken as string
|
const pendingToken = credentials.pendingToken as string
|
||||||
const twoFactorToken = credentials.twoFactorToken as string
|
const twoFactorToken = credentials.twoFactorToken as string
|
||||||
|
|
||||||
const pending = getPending2FASession(pendingToken)
|
const pending = await getPending2FASession(pendingToken)
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
throw new Error('Session expired. Please log in again.')
|
throw new Error('Session expired. Please log in again.')
|
||||||
}
|
}
|
||||||
|
|
@ -139,7 +157,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear pending session
|
// Clear pending session
|
||||||
clearPending2FASession(pendingToken)
|
await clearPending2FASession(pendingToken)
|
||||||
|
|
||||||
// Return the user data from the pending session
|
// Return the user data from the pending session
|
||||||
if (pending.userType === 'staff') {
|
if (pending.userType === 'staff') {
|
||||||
|
|
@ -198,10 +216,10 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
|
|
||||||
// Check if 2FA is enabled
|
// Check if 2FA is enabled
|
||||||
if (user.twoFactorEnabled) {
|
if (user.twoFactorEnabled) {
|
||||||
// Create pending 2FA session
|
// Create pending 2FA session (stored in DB)
|
||||||
const pendingToken = crypto.randomBytes(32).toString('hex')
|
const pendingToken = crypto.randomBytes(32).toString('hex')
|
||||||
const subscription = user.subscriptions[0]
|
const subscription = user.subscriptions[0]
|
||||||
pending2FASessions.set(pendingToken, {
|
await storePending2FASession(pendingToken, {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
userType: 'customer',
|
userType: 'customer',
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
|
@ -247,9 +265,9 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
|
|
||||||
// Check if 2FA is enabled
|
// Check if 2FA is enabled
|
||||||
if (staff.twoFactorEnabled) {
|
if (staff.twoFactorEnabled) {
|
||||||
// Create pending 2FA session
|
// Create pending 2FA session (stored in DB)
|
||||||
const pendingToken = crypto.randomBytes(32).toString('hex')
|
const pendingToken = crypto.randomBytes(32).toString('hex')
|
||||||
pending2FASessions.set(pendingToken, {
|
await storePending2FASession(pendingToken, {
|
||||||
userId: staff.id,
|
userId: staff.id,
|
||||||
userType: 'staff',
|
userType: 'staff',
|
||||||
email: staff.email,
|
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)
|
* Clear cached transporter (useful after config changes)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { emailService } from './email-service'
|
||||||
import type { DetectedError, ErrorDetectionRule, ContainerEvent, NotificationSetting } from '@prisma/client'
|
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(
|
private async sendToRecipients(
|
||||||
recipients: string[],
|
recipients: string[],
|
||||||
subject: string,
|
subject: string,
|
||||||
body: string
|
body: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const resendApiKey = process.env.RESEND_API_KEY
|
const isConfigured = await emailService.isConfigured()
|
||||||
|
|
||||||
if (resendApiKey) {
|
if (!isConfigured) {
|
||||||
await this.sendViaResend(recipients, subject, body, resendApiKey)
|
console.log('[Notification] Email not configured, logging notification:')
|
||||||
} else {
|
console.log(`To: ${recipients.join(', ')} | Subject: ${subject}`)
|
||||||
// Development mode - just log
|
console.log(body)
|
||||||
console.log('No email service configured, logging notification:')
|
return
|
||||||
this.logEmail(recipients, subject, body)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const result = await emailService.sendEmail({
|
||||||
* Send email via Resend API
|
to: recipients,
|
||||||
*/
|
|
||||||
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'
|
|
||||||
|
|
||||||
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,
|
subject,
|
||||||
text,
|
html: `<pre style="font-family: monospace; white-space: pre-wrap;">${body.replace(/</g, '<').replace(/>/g, '>')}</pre>`,
|
||||||
}),
|
text: body,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (result.success) {
|
||||||
const error = await response.text()
|
console.log(`[Notification] Email sent to ${recipients.length} recipient(s)`)
|
||||||
throw new Error(`Resend API error: ${error}`)
|
} else {
|
||||||
}
|
console.error('[Notification] Failed to send email:', result.error)
|
||||||
|
|
||||||
console.log(`[Notification] Email sent to ${to.length} recipient(s)`)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Notification] Failed to send email:', error)
|
|
||||||
throw new Error('Failed to send notification email')
|
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()
|
export const notificationService = NotificationService.getInstance()
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { emailService } from './email-service'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -26,7 +27,8 @@ export interface VerifyCodeResult {
|
||||||
class SecurityVerificationService {
|
class SecurityVerificationService {
|
||||||
private static instance: SecurityVerificationService
|
private static instance: SecurityVerificationService
|
||||||
private readonly CODE_EXPIRY_MINUTES = 15
|
private readonly CODE_EXPIRY_MINUTES = 15
|
||||||
private readonly CODE_LENGTH = 6
|
private readonly CODE_LENGTH = 8
|
||||||
|
private readonly MAX_ATTEMPTS = 5
|
||||||
|
|
||||||
static getInstance(): SecurityVerificationService {
|
static getInstance(): SecurityVerificationService {
|
||||||
if (!SecurityVerificationService.instance) {
|
if (!SecurityVerificationService.instance) {
|
||||||
|
|
@ -36,12 +38,12 @@ class SecurityVerificationService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a 6-digit verification code
|
* Generate an 8-digit verification code
|
||||||
*/
|
*/
|
||||||
private generateCode(): string {
|
private generateCode(): string {
|
||||||
// Generate a cryptographically secure 6-digit code
|
// Generate a cryptographically secure 8-digit code
|
||||||
const buffer = crypto.randomBytes(4)
|
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')
|
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(
|
async verifyCode(
|
||||||
clientId: string,
|
clientId: string,
|
||||||
code: string
|
code: string
|
||||||
): Promise<VerifyCodeResult> {
|
): Promise<VerifyCodeResult> {
|
||||||
|
// Find the most recent active code for this client
|
||||||
const verificationCode = await prisma.securityVerificationCode.findFirst({
|
const verificationCode = await prisma.securityVerificationCode.findFirst({
|
||||||
where: {
|
where: {
|
||||||
clientId,
|
clientId,
|
||||||
code,
|
|
||||||
usedAt: null,
|
usedAt: null,
|
||||||
expiresAt: { gt: new Date() }
|
expiresAt: { gt: new Date() }
|
||||||
}
|
},
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!verificationCode) {
|
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
|
// Mark code as used
|
||||||
await prisma.securityVerificationCode.update({
|
await prisma.securityVerificationCode.update({
|
||||||
where: { id: verificationCode.id },
|
where: { id: verificationCode.id },
|
||||||
|
|
@ -155,8 +188,7 @@ class SecurityVerificationService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send verification email
|
* Send verification email via the centralized email service
|
||||||
* Currently logs to console; can be replaced with Resend/SMTP
|
|
||||||
*/
|
*/
|
||||||
private async sendVerificationEmail(
|
private async sendVerificationEmail(
|
||||||
email: string,
|
email: string,
|
||||||
|
|
@ -168,7 +200,7 @@ class SecurityVerificationService {
|
||||||
const actionText = action === 'WIPE' ? 'wipe' : 'reinstall'
|
const actionText = action === 'WIPE' ? 'wipe' : 'reinstall'
|
||||||
const subject = `[LetsBe] Verification Code for Server ${actionText.charAt(0).toUpperCase() + actionText.slice(1)}`
|
const subject = `[LetsBe] Verification Code for Server ${actionText.charAt(0).toUpperCase() + actionText.slice(1)}`
|
||||||
|
|
||||||
const body = `
|
const textBody = `
|
||||||
Hello,
|
Hello,
|
||||||
|
|
||||||
A request has been made to ${actionText} the server "${serverName}" for ${clientName}.
|
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
|
LetsBe Cloud Platform
|
||||||
`.trim()
|
`.trim()
|
||||||
|
|
||||||
// Check if we have email configuration
|
const isConfigured = await emailService.isConfigured()
|
||||||
const resendApiKey = process.env.RESEND_API_KEY
|
|
||||||
const smtpHost = process.env.SMTP_HOST
|
|
||||||
|
|
||||||
if (resendApiKey) {
|
if (!isConfigured) {
|
||||||
await this.sendViaResend(email, subject, body, resendApiKey)
|
console.log('Email not configured, logging verification code:')
|
||||||
} else if (smtpHost) {
|
console.log(`To: ${email} | Subject: ${subject}`)
|
||||||
// SMTP implementation would go here
|
console.log(textBody)
|
||||||
console.log('SMTP email sending not yet implemented')
|
return
|
||||||
this.logEmail(email, subject, body)
|
|
||||||
} else {
|
|
||||||
// Development mode - just log
|
|
||||||
console.log('No email service configured, logging email:')
|
|
||||||
this.logEmail(email, subject, body)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const result = await emailService.sendEmail({
|
||||||
* Send email via Resend API
|
to: email,
|
||||||
*/
|
|
||||||
private async sendViaResend(
|
|
||||||
to: string,
|
|
||||||
subject: string,
|
|
||||||
text: string,
|
|
||||||
apiKey: string
|
|
||||||
): Promise<void> {
|
|
||||||
const fromEmail = process.env.RESEND_FROM_EMAIL || 'noreply@letsbe.cloud'
|
|
||||||
|
|
||||||
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,
|
subject,
|
||||||
text
|
html: `<pre style="font-family: monospace; white-space: pre-wrap;">${textBody.replace(/</g, '<').replace(/>/g, '>')}</pre>`,
|
||||||
})
|
text: textBody,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (result.success) {
|
||||||
const error = await response.text()
|
console.log(`Verification email sent to ${this.maskEmail(email)}`)
|
||||||
throw new Error(`Resend API error: ${error}`)
|
} else {
|
||||||
}
|
console.error('Failed to send verification email:', result.error)
|
||||||
|
|
||||||
console.log(`Verification email sent to ${this.maskEmail(to)}`)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to send verification email:', error)
|
|
||||||
throw new Error('Failed to send verification email')
|
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)
|
* Mask email for display (e.g., j***@example.com)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,15 @@ export class StorageService {
|
||||||
contentType: string
|
contentType: string
|
||||||
): Promise<{ success: boolean; key: string; url: string | null; error?: string }> {
|
): Promise<{ success: boolean; key: string; url: string | null; error?: string }> {
|
||||||
try {
|
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 client = await this.getClient()
|
||||||
const config = await settingsService.getStorageConfig()
|
const config = await settingsService.getStorageConfig()
|
||||||
|
|
||||||
|
|
@ -206,6 +215,10 @@ export class StorageService {
|
||||||
*/
|
*/
|
||||||
async deleteFile(key: string): Promise<{ success: boolean; error?: string }> {
|
async deleteFile(key: string): Promise<{ success: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
|
if (!(await this.isConfigured())) {
|
||||||
|
return { success: false, error: 'Storage not configured' }
|
||||||
|
}
|
||||||
|
|
||||||
const client = await this.getClient()
|
const client = await this.getClient()
|
||||||
const config = await settingsService.getStorageConfig()
|
const config = await settingsService.getStorageConfig()
|
||||||
|
|
||||||
|
|
@ -231,6 +244,10 @@ export class StorageService {
|
||||||
*/
|
*/
|
||||||
async getFile(key: string): Promise<Buffer | null> {
|
async getFile(key: string): Promise<Buffer | null> {
|
||||||
try {
|
try {
|
||||||
|
if (!(await this.isConfigured())) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const client = await this.getClient()
|
const client = await this.getClient()
|
||||||
const config = await settingsService.getStorageConfig()
|
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({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
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: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
globals: true,
|
globals: true,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue