diff --git a/apps/api/src/features.ts b/apps/api/src/features.ts index 4f0c59e..fc7ead7 100644 --- a/apps/api/src/features.ts +++ b/apps/api/src/features.ts @@ -90,15 +90,6 @@ export enum Plan { SOVEREIGN = 'sovereign', } -// Legacy plan names for backward compatibility -export type LegacyPlan = 'free' | 'solo' | 'enterprise'; - -export const LEGACY_PLAN_MIGRATIONS: Record = { - free: Plan.COMMUNITY, - solo: Plan.PRO, - enterprise: Plan.SOVEREIGN, -}; - // ============================================================================= // Feature Access Matrix by Plan // ============================================================================= @@ -533,31 +524,18 @@ export function isUnlimited(limit: number): boolean { } /** - * Normalize a plan name, converting legacy names to v4.0 names + * Normalize a plan name to a valid v4.0 plan type */ export function normalizePlan(plan: string): Plan { const lower = plan.toLowerCase(); - // Check v4.0 plan names if (Object.values(Plan).includes(lower as Plan)) { return lower as Plan; } - // Check legacy plan names - if (lower in LEGACY_PLAN_MIGRATIONS) { - return LEGACY_PLAN_MIGRATIONS[lower as LegacyPlan]; - } - return Plan.COMMUNITY; // Default to community for unknown plans } -/** - * Check if a plan name is a legacy plan - */ -export function isLegacyPlan(plan: string): boolean { - return plan.toLowerCase() in LEGACY_PLAN_MIGRATIONS; -} - /** * Get all new features */ @@ -641,16 +619,11 @@ export function getFeatureFlags(plan: Plan): FeatureFlagsType { // Plan Comparison // ============================================================================= -export const PLAN_RANK: Record = { - // v4.0 plans +export const PLAN_RANK: Record = { [Plan.COMMUNITY]: 0, [Plan.PRO]: 1, [Plan.TEAM]: 2, [Plan.SOVEREIGN]: 3, - // Legacy plans - free: 0, - solo: 1, - enterprise: 3, }; export function isPlanUpgrade(from: string, to: string): boolean { diff --git a/apps/api/src/handlers/billing.ts b/apps/api/src/handlers/billing.ts index ca1be2e..c693b02 100644 --- a/apps/api/src/handlers/billing.ts +++ b/apps/api/src/handlers/billing.ts @@ -18,7 +18,7 @@ import { sql } from 'drizzle-orm'; // ============================================================================ interface CreateCheckoutRequest { - plan: 'solo' | 'pro' | 'team'; + plan: 'pro' | 'team' | 'sovereign'; email: string; success_url: string; cancel_url: string; @@ -136,7 +136,7 @@ export async function handleCreateCheckout( // Validate plan const priceId = PLAN_TO_STRIPE_PRICE[body.plan]; if (!priceId) { - throw Errors.invalidRequest(`Invalid plan. Must be one of: solo, pro, team`); + throw Errors.invalidRequest(`Invalid plan. Must be one of: pro, team, sovereign`); } // Validate URLs diff --git a/apps/api/src/handlers/validate-license.ts b/apps/api/src/handlers/validate-license.ts index 160b945..543d45b 100644 --- a/apps/api/src/handlers/validate-license.ts +++ b/apps/api/src/handlers/validate-license.ts @@ -162,7 +162,7 @@ export async function handleValidateLicense( let ciWarning: string | undefined; if (isCI) { const activeCIDevices = await getActiveCIDeviceCount(db, license.license_id, 30); - const ciLimit = CI_DEVICE_LIMITS[plan] ?? CI_DEVICE_LIMITS.free; + const ciLimit = CI_DEVICE_LIMITS[plan] ?? CI_DEVICE_LIMITS.community; if (ciLimit !== -1 && activeCIDevices >= ciLimit) { throw new AppError( diff --git a/apps/api/src/lib/constants.ts b/apps/api/src/lib/constants.ts index 6bd4093..fa8a43b 100644 --- a/apps/api/src/lib/constants.ts +++ b/apps/api/src/lib/constants.ts @@ -17,31 +17,16 @@ import type { Env } from '../types/env'; export type PlanType = 'community' | 'pro' | 'team' | 'sovereign'; export type PlanBillingType = 'free' | 'monthly' | 'annual' | 'lifetime'; -/** Legacy plan names for backward compatibility */ -export type LegacyPlanType = 'free' | 'solo' | 'enterprise'; - -export const LEGACY_PLAN_MIGRATIONS: Record = { - free: 'community', - solo: 'pro', - enterprise: 'sovereign', -}; - /** - * Normalize a plan name, converting legacy names to v4.0 names + * Normalize a plan name to a valid v4.0 plan type */ export function normalizePlanName(plan: string): PlanType { const lower = plan.toLowerCase(); - // Check v4.0 plan names if (['community', 'pro', 'team', 'sovereign'].includes(lower)) { return lower as PlanType; } - // Check legacy plan names - if (lower in LEGACY_PLAN_MIGRATIONS) { - return LEGACY_PLAN_MIGRATIONS[lower as LegacyPlanType]; - } - return 'community'; // Default to community for unknown plans } @@ -52,6 +37,10 @@ export function normalizePlanName(plan: string): PlanType { /** Far future expiry date for lifetime licenses - effectively "never expires" */ export const LIFETIME_EXPIRY = '2099-12-31T23:59:59.000Z'; +/** + * Plan features configuration for v4.0 pricing tiers. + * All plans have unlimited scans and resources - gating is on exports. + */ export const PLAN_FEATURES: Record = { community: { resources_per_scan: -1, // v4.0: UNLIMITED @@ -98,16 +87,11 @@ export const MAX_MACHINE_CHANGES_PER_MONTH = 3; * Plan rank - higher number = higher tier plan * Used to detect upgrades vs downgrades */ -export const PLAN_RANK: Record = { - // v4.0 plans +export const PLAN_RANK: Record = { community: 0, pro: 1, team: 2, sovereign: 3, - // Legacy plans (mapped to v4.0 equivalents) - free: 0, - solo: 1, - enterprise: 3, }; /** @@ -190,44 +174,32 @@ export const AWS_ACCOUNT_ID_PATTERN = /^\d{12}$/; /** * Plan prices in cents (monthly) */ -export const PLAN_PRICES: Record = { - 'community': 0, - 'pro': 2900, // $29/month - 'team': 9900, // $99/month - 'sovereign': 250000, // $2,500/month - // Legacy plan prices (mapped to v4.0) - 'free': 0, - 'solo': 2900, // $29/month (same as PRO) - 'enterprise': 250000, // $2,500/month (same as SOVEREIGN) +export const PLAN_PRICES: Record = { + community: 0, + pro: 2900, // $29/month + team: 9900, // $99/month + sovereign: 250000, // $2,500/month } as const; /** * Annual plan prices in cents (total per year) * 2 months free compared to monthly billing */ -export const PLAN_ANNUAL_PRICES: Record = { - 'community': 0, - 'pro': 29000, // $290/year (~$24/month, 2 months free) - 'team': 99000, // $990/year (~$83/month, 2 months free) - 'sovereign': 2500000, // $25,000/year (~$2,083/month, 2 months free) - // Legacy plans - 'free': 0, - 'solo': 29000, - 'enterprise': 2500000, +export const PLAN_ANNUAL_PRICES: Record = { + community: 0, + pro: 29000, // $290/year (~$24/month, 2 months free) + team: 99000, // $990/year (~$83/month, 2 months free) + sovereign: 2500000, // $25,000/year (~$2,083/month, 2 months free) } as const; /** * Lifetime plan prices in cents (one-time payment) */ -export const PLAN_LIFETIME_PRICES: Record = { - 'community': null, // No lifetime for free tier - 'pro': 19900, // $199 Early Bird (Regular: $249) - 'team': 49900, // $499 Early Bird (Regular: $699) - 'sovereign': null, // No lifetime for enterprise - // Legacy plans - 'free': null, - 'solo': 19900, - 'enterprise': null, +export const PLAN_LIFETIME_PRICES: Record = { + community: null, // No lifetime for free tier + pro: 19900, // $199 Early Bird (Regular: $249) + team: 49900, // $499 Early Bird (Regular: $699) + sovereign: null, // No lifetime for enterprise } as const; // ============================================================================ @@ -246,20 +218,18 @@ export const STRIPE_PRICE_TO_PLAN: Record = { 'price_v4_team_annual': 'team', 'price_v4_sovereign_annual': 'sovereign', - // Legacy price IDs (keep for backward compatibility) - 'price_1SiMWsAKLIiL9hdweoTnH17A': 'pro', // Legacy solo → pro - 'price_1SiMYgAKLIiL9hdwZLjLUOPm': 'pro', // Legacy pro → pro - 'price_1SiMZvAKLIiL9hdw8LAIvjrS': 'team', // Legacy team → team - 'price_1SiMpmAKLIiL9hdwhhn1dAVG': 'pro', // Legacy solo annual - 'price_1SiMqMAKLIiL9hdwj1EgfQMs': 'pro', // Legacy pro annual - 'price_1SiMrJAKLIiL9hdwF8xq4poz': 'team', // Legacy team annual + // Production Stripe price IDs (legacy subscriptions still active) + 'price_1SiMWsAKLIiL9hdweoTnH17A': 'pro', // Legacy solo monthly → pro + 'price_1SiMYgAKLIiL9hdwZLjLUOPm': 'pro', // Legacy pro monthly → pro + 'price_1SiMZvAKLIiL9hdw8LAIvjrS': 'team', // Legacy team monthly → team + 'price_1SiMpmAKLIiL9hdwhhn1dAVG': 'pro', // Legacy solo annual → pro + 'price_1SiMqMAKLIiL9hdwj1EgfQMs': 'pro', // Legacy pro annual → pro + 'price_1SiMrJAKLIiL9hdwF8xq4poz': 'team', // Legacy team annual → team - // Test price IDs (for unit testing) - Monthly + // Test price IDs (for unit testing) 'price_test_pro': 'pro', 'price_test_team': 'team', 'price_test_sovereign': 'sovereign', - - // Test price IDs (for unit testing) - Annual 'price_test_pro_annual': 'pro', 'price_test_team_annual': 'team', 'price_test_sovereign_annual': 'sovereign', @@ -269,20 +239,19 @@ export const STRIPE_PRICE_TO_PLAN: Record = { * Plan to Stripe Price ID (for creating checkout sessions) * TODO: Update these after creating new prices in Stripe Dashboard */ -export const PLAN_TO_STRIPE_PRICE: Record = { - // v4.0 Monthly prices - 'pro': 'price_v4_pro_monthly', - 'team': 'price_v4_team_monthly', - 'sovereign': 'price_v4_sovereign_monthly', +export const PLAN_TO_STRIPE_PRICE: Record, string> = { + pro: 'price_v4_pro_monthly', + team: 'price_v4_team_monthly', + sovereign: 'price_v4_sovereign_monthly', }; /** * Plan to Stripe Annual Price ID (for annual checkout sessions) */ -export const PLAN_TO_STRIPE_ANNUAL_PRICE: Record = { - 'pro': 'price_v4_pro_annual', - 'team': 'price_v4_team_annual', - 'sovereign': 'price_v4_sovereign_annual', +export const PLAN_TO_STRIPE_ANNUAL_PRICE: Record, string> = { + pro: 'price_v4_pro_annual', + team: 'price_v4_team_annual', + sovereign: 'price_v4_sovereign_annual', }; /** @@ -305,7 +274,7 @@ export const STRIPE_LIFETIME_PRICE_TO_PLAN: Record = { pro: 25, team: 50, sovereign: -1, // Unlimited - // Legacy plan names for backward compatibility - free: 3, - solo: 25, - enterprise: -1, }; // ============================================================================ diff --git a/apps/api/tests/admin.test.ts b/apps/api/tests/admin.test.ts index 57af954..1daa04d 100644 --- a/apps/api/tests/admin.test.ts +++ b/apps/api/tests/admin.test.ts @@ -6,7 +6,7 @@ * POST /v1/admin/licenses/{key}/revoke - Revoke license */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { handleCreateLicense, handleGetLicense, @@ -19,11 +19,28 @@ import { } from './helpers'; import type { Env } from '../src/types'; import type { ErrorResponse } from '../src/types/api'; +import * as db from '../src/lib/db'; + +// Mock the db module +vi.mock('../src/lib/db', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + getLicenseByKey: vi.fn(), + getLicenseWithUserDetails: vi.fn(), + createLicense: vi.fn(), + revokeLicense: vi.fn(), + findOrCreateUser: vi.fn(), + getActiveMachines: vi.fn().mockResolvedValue([]), + getActiveAwsAccountCount: vi.fn().mockResolvedValue(0), + }; +}); describe('Admin Endpoints', () => { let env: Env; beforeEach(() => { + vi.clearAllMocks(); env = createMockEnv(); }); @@ -104,48 +121,36 @@ describe('Admin Endpoints', () => { }); it('should create license with valid request', async () => { - let firstCallCount = 0; const now = new Date().toISOString(); - const mockDB = { - prepare: (query: string) => ({ - bind: (...args: unknown[]) => ({ - first: async () => { - firstCallCount++; - // First call: check existing user (SELECT ... FROM users WHERE email = ?) - if (firstCallCount === 1) { - return null; // No existing user - } - // Second call: return created user (SELECT * FROM users WHERE id = ?) - if (firstCallCount === 2) { - return { - id: 'user_test_123', - email: 'test@example.com', - stripe_customer_id: null, - created_at: now, - updated_at: now, - }; - } - // Third call: return created license (SELECT * FROM licenses WHERE id = ?) - // The createLicense function uses: bind(id, userId, licenseKey, ...) - // So args[2] is the license key in INSERT, but for SELECT it's just the id - return { - id: 'lic_test_123', - user_id: 'user_test_123', - license_key: 'RM-TEST-1234-5678-ABCD', // Use fixed test key - plan: 'pro', - status: 'active', - current_period_start: now, - current_period_end: null, - created_at: now, - updated_at: now, - }; - }, - run: async () => ({ success: true, results: [], meta: { last_row_id: 1, changes: 1 } }), - }), - }), - } as unknown as D1Database; - - env = createMockEnv({ DB: mockDB }); + + // Mock user creation + vi.mocked(db.findOrCreateUser).mockResolvedValue({ + id: 'user_test_123', + email: 'test@example.com', + customerId: null, + createdAt: new Date(now), + updatedAt: new Date(now), + }); + + // Mock license creation + vi.mocked(db.createLicense).mockResolvedValue({ + id: 'lic_test_123', + userId: 'user_test_123', + licenseKey: 'RM-TEST-1234-5678-ABCD', + plan: 'pro', + planType: 'monthly', + status: 'active', + currentPeriodStart: now, + currentPeriodEnd: null, + stripeSubscriptionId: null, + stripePriceId: null, + stripeSessionId: null, + createdAt: now, + updatedAt: now, + canceledAt: null, + revokedAt: null, + revokedReason: null, + }); const request = createRequest('POST', '/v1/admin/licenses', { customer_email: 'test@example.com', @@ -158,7 +163,7 @@ describe('Admin Endpoints', () => { const data = await response.json() as { license_key: string; plan: string }; expect(response.status).toBe(201); - expect(data.license_key).toMatch(/^RM-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/); + expect(data.license_key).toBe('RM-TEST-1234-5678-ABCD'); expect(data.plan).toBe('pro'); }); }); @@ -174,6 +179,8 @@ describe('Admin Endpoints', () => { }); it('should return 404 for non-existent license', async () => { + vi.mocked(db.getLicenseWithUserDetails).mockResolvedValue(null); + const request = createRequest('GET', '/v1/admin/licenses/RM-XXXX-XXXX-XXXX-XXXX', undefined, { 'X-API-Key': 'test-admin-key', }); @@ -186,29 +193,25 @@ describe('Admin Endpoints', () => { }); it('should return license details for valid key', async () => { - const mockDB = { - prepare: () => ({ - bind: () => ({ - first: async () => ({ - id: 'lic_123', - license_key: 'RM-TEST-1234-5678-ABCD', - plan: 'pro', - status: 'active', - current_period_start: new Date().toISOString(), - current_period_end: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), - email: 'test@example.com', - stripe_customer_id: 'cus_123', - stripe_subscription_id: 'sub_123', - created_at: new Date().toISOString(), - active_machines: 2, - active_aws_accounts: 1, - }), - all: async () => ({ results: [], success: true }), - }), - }), - } as unknown as D1Database; - - env = createMockEnv({ DB: mockDB }); + // Mock getLicenseByKey (not getLicenseWithUserDetails) + vi.mocked(db.getLicenseByKey).mockResolvedValue({ + id: 'lic_123', + userId: 'user_123', + licenseKey: 'RM-TEST-1234-5678-ABCD', + plan: 'pro', + planType: 'monthly', + status: 'active', + currentPeriodStart: new Date().toISOString(), + currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), + stripeSubscriptionId: 'sub_123', + stripePriceId: null, + stripeSessionId: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + canceledAt: null, + revokedAt: null, + revokedReason: null, + }); const request = createRequest('GET', '/v1/admin/licenses/RM-TEST-1234-5678-ABCD', undefined, { 'X-API-Key': 'test-admin-key', @@ -235,6 +238,8 @@ describe('Admin Endpoints', () => { }); it('should return 404 for non-existent license', async () => { + vi.mocked(db.getLicenseByKey).mockResolvedValue(null); + const request = createRequest('POST', '/v1/admin/licenses/RM-XXXX-XXXX-XXXX-XXXX/revoke', { reason: 'Test revocation', }, { @@ -249,19 +254,25 @@ describe('Admin Endpoints', () => { }); it('should revoke license successfully', async () => { - const mockDB = { - prepare: () => ({ - bind: () => ({ - first: async () => ({ - id: 'lic_123', - status: 'active', - }), - run: async () => ({ success: true, results: [] }), - }), - }), - } as unknown as D1Database; - - env = createMockEnv({ DB: mockDB }); + vi.mocked(db.getLicenseByKey).mockResolvedValue({ + id: 'lic_123', + userId: 'user_123', + licenseKey: 'RM-TEST-1234-5678-ABCD', + plan: 'pro', + planType: 'monthly', + status: 'active', + currentPeriodStart: new Date().toISOString(), + currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), + stripeSubscriptionId: null, + stripePriceId: null, + stripeSessionId: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + canceledAt: null, + revokedAt: null, + revokedReason: null, + }); + vi.mocked(db.revokeLicense).mockResolvedValue(undefined); const request = createRequest('POST', '/v1/admin/licenses/RM-TEST-1234-5678-ABCD/revoke', { reason: 'Fraud detected', diff --git a/apps/api/tests/aws-accounts.test.ts b/apps/api/tests/aws-accounts.test.ts index 8397e31..b835257 100644 --- a/apps/api/tests/aws-accounts.test.ts +++ b/apps/api/tests/aws-accounts.test.ts @@ -5,7 +5,7 @@ * GET /v1/licenses/{key}/aws-accounts - Get AWS accounts for license */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { handleTrackAwsAccount, handleGetAwsAccounts, @@ -19,11 +19,25 @@ import { } from './helpers'; import type { Env } from '../src/types'; import type { ErrorResponse } from '../src/types/api'; +import * as db from '../src/lib/db'; + +// Mock the db module +vi.mock('../src/lib/db', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + getLicenseByKey: vi.fn(), + trackAwsAccount: vi.fn(), + getActiveAwsAccountCount: vi.fn().mockResolvedValue(0), + getAwsAccountsForLicense: vi.fn().mockResolvedValue([]), + }; +}); describe('AWS Account Endpoints', () => { let env: Env; beforeEach(() => { + vi.clearAllMocks(); env = createMockEnv(); }); @@ -79,6 +93,8 @@ describe('AWS Account Endpoints', () => { }); it('should return 404 for non-existent license', async () => { + vi.mocked(db.getLicenseByKey).mockResolvedValue(null); + const request = createRequest('POST', '/v1/aws-accounts/track', { license_key: 'RM-XXXX-XXXX-XXXX-XXXX', aws_account_id: generateAwsAccountId(), @@ -102,6 +118,8 @@ describe('AWS Account Endpoints', () => { }); it('should return 404 for non-existent license', async () => { + vi.mocked(db.getLicenseByKey).mockResolvedValue(null); + const request = createRequest('GET', '/v1/licenses/RM-XXXX-XXXX-XXXX-XXXX/aws-accounts'); const response = await handleGetAwsAccounts(request, env, 'RM-XXXX-XXXX-XXXX-XXXX', '1.2.3.4'); diff --git a/apps/api/tests/billing.test.ts b/apps/api/tests/billing.test.ts index 0dbc1e2..87ebafa 100644 --- a/apps/api/tests/billing.test.ts +++ b/apps/api/tests/billing.test.ts @@ -17,11 +17,23 @@ import { } from './helpers'; import type { Env } from '../src/types'; import type { ErrorResponse } from '../src/types/api'; +import * as db from '../src/lib/db'; // Mock fetch for Stripe API calls const mockFetch = vi.fn(); global.fetch = mockFetch; +// Mock the db module +vi.mock('../src/lib/db', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + createDb: vi.fn(() => ({ + get: vi.fn().mockResolvedValue(null), + })), + }; +}); + describe('Billing Endpoints', () => { let env: Env; @@ -214,6 +226,11 @@ describe('Billing Endpoints', () => { }); it('should return 404 for non-existent license', async () => { + // Mock createDb to return null for license lookup + vi.mocked(db.createDb).mockReturnValue({ + get: vi.fn().mockResolvedValue(null), + } as unknown as ReturnType); + const request = createRequest('POST', '/v1/billing/portal', { license_key: 'RM-XXXX-XXXX-XXXX-XXXX', return_url: 'https://example.com/dashboard', @@ -227,18 +244,13 @@ describe('Billing Endpoints', () => { }); it('should reject license without billing account', async () => { - const mockDB = { - prepare: () => ({ - bind: () => ({ - first: async () => ({ - stripe_subscription_id: null, - stripe_customer_id: null, // No billing account - }), - }), + // Mock createDb to return license without billing account + vi.mocked(db.createDb).mockReturnValue({ + get: vi.fn().mockResolvedValue({ + stripe_subscription_id: null, + customer_id: null, // No billing account }), - } as unknown as D1Database; - - env = createMockEnv({ DB: mockDB }); + } as unknown as ReturnType); const request = createRequest('POST', '/v1/billing/portal', { license_key: 'RM-TEST-1234-5678-ABCD', @@ -253,18 +265,13 @@ describe('Billing Endpoints', () => { }); it('should create portal session successfully', async () => { - const mockDB = { - prepare: () => ({ - bind: () => ({ - first: async () => ({ - stripe_subscription_id: 'sub_123', - stripe_customer_id: 'cus_123', - }), - }), + // Mock createDb to return license with billing account + vi.mocked(db.createDb).mockReturnValue({ + get: vi.fn().mockResolvedValue({ + stripe_subscription_id: 'sub_123', + customer_id: 'cus_123', }), - } as unknown as D1Database; - - env = createMockEnv({ DB: mockDB }); + } as unknown as ReturnType); mockFetch.mockResolvedValueOnce({ ok: true, diff --git a/apps/api/tests/constants.test.ts b/apps/api/tests/constants.test.ts index ad4c451..0b553ef 100644 --- a/apps/api/tests/constants.test.ts +++ b/apps/api/tests/constants.test.ts @@ -19,32 +19,42 @@ import { getLifetimePriceIds, } from '../src/lib/constants'; -describe('Plan Features', () => { - it('should have all plan types defined', () => { - expect(PLAN_FEATURES.free).toBeDefined(); - expect(PLAN_FEATURES.solo).toBeDefined(); +describe('Plan Features v4.0', () => { + it('should have all v4.0 plan types defined', () => { + expect(PLAN_FEATURES.community).toBeDefined(); expect(PLAN_FEATURES.pro).toBeDefined(); expect(PLAN_FEATURES.team).toBeDefined(); + expect(PLAN_FEATURES.sovereign).toBeDefined(); }); - it('should have correct free plan limits', () => { - expect(PLAN_FEATURES.free.resources_per_scan).toBe(5); - expect(PLAN_FEATURES.free.scans_per_month).toBe(3); - expect(PLAN_FEATURES.free.aws_accounts).toBe(1); - expect(PLAN_FEATURES.free.machines).toBe(1); - expect(PLAN_FEATURES.free.export_formats).toContain('terraform'); + it('should have correct community plan limits (v4.0: unlimited scans)', () => { + expect(PLAN_FEATURES.community.resources_per_scan).toBe(-1); // v4.0: UNLIMITED + expect(PLAN_FEATURES.community.scans_per_month).toBe(-1); // v4.0: UNLIMITED + expect(PLAN_FEATURES.community.aws_accounts).toBe(1); + expect(PLAN_FEATURES.community.machines).toBe(1); + expect(PLAN_FEATURES.community.export_formats).toContain('json'); + expect(PLAN_FEATURES.community.export_formats).not.toContain('terraform'); // v4.0: JSON only for community }); - it('should have unlimited resources for paid plans', () => { - expect(PLAN_FEATURES.solo.resources_per_scan).toBe(-1); + it('should have unlimited resources for all plans (v4.0 philosophy)', () => { + expect(PLAN_FEATURES.community.resources_per_scan).toBe(-1); expect(PLAN_FEATURES.pro.resources_per_scan).toBe(-1); expect(PLAN_FEATURES.team.resources_per_scan).toBe(-1); + expect(PLAN_FEATURES.sovereign.resources_per_scan).toBe(-1); }); - it('should have increasing machine limits', () => { - expect(PLAN_FEATURES.free.machines).toBeLessThan(PLAN_FEATURES.solo.machines); - expect(PLAN_FEATURES.solo.machines).toBeLessThan(PLAN_FEATURES.pro.machines); + it('should have increasing machine limits across tiers', () => { + expect(PLAN_FEATURES.community.machines).toBeLessThan(PLAN_FEATURES.pro.machines); expect(PLAN_FEATURES.pro.machines).toBeLessThan(PLAN_FEATURES.team.machines); + // Sovereign has unlimited machines (-1) + expect(PLAN_FEATURES.sovereign.machines).toBe(-1); + }); + + it('should have increasing AWS account limits across tiers', () => { + expect(PLAN_FEATURES.community.aws_accounts).toBe(1); + expect(PLAN_FEATURES.pro.aws_accounts).toBe(3); + expect(PLAN_FEATURES.team.aws_accounts).toBe(10); + expect(PLAN_FEATURES.sovereign.aws_accounts).toBe(-1); // Unlimited }); }); @@ -99,30 +109,30 @@ describe('CLI Version Check', () => { }); }); -describe('Stripe Price Mapping', () => { +describe('Stripe Price Mapping v4.0', () => { it('should have a primary price ID for each paid plan in PLAN_TO_STRIPE_PRICE', () => { - // Each plan should have at least one price ID for checkout - expect(PLAN_TO_STRIPE_PRICE['solo']).toBeDefined(); - expect(PLAN_TO_STRIPE_PRICE['pro']).toBeDefined(); - expect(PLAN_TO_STRIPE_PRICE['team']).toBeDefined(); + // v4.0 plans: pro, team, sovereign + expect(PLAN_TO_STRIPE_PRICE.pro).toBeDefined(); + expect(PLAN_TO_STRIPE_PRICE.team).toBeDefined(); + expect(PLAN_TO_STRIPE_PRICE.sovereign).toBeDefined(); }); - it('should have price-to-plan mappings that include all plans', () => { - // All plans with prices should appear in STRIPE_PRICE_TO_PLAN values + it('should have price-to-plan mappings that include all v4.0 plans', () => { + // v4.0 plans should appear in STRIPE_PRICE_TO_PLAN values const plans = new Set(Object.values(STRIPE_PRICE_TO_PLAN)); - expect(plans.has('solo')).toBe(true); expect(plans.has('pro')).toBe(true); expect(plans.has('team')).toBe(true); + expect(plans.has('sovereign')).toBe(true); }); - it('should return free for unknown price IDs', () => { - expect(getPlanFromPriceId('unknown_price_id')).toBe('free'); + it('should return community for unknown price IDs', () => { + expect(getPlanFromPriceId('unknown_price_id')).toBe('community'); }); it('should return correct plan for known price IDs', () => { - expect(getPlanFromPriceId('price_test_solo')).toBe('solo'); expect(getPlanFromPriceId('price_test_pro')).toBe('pro'); expect(getPlanFromPriceId('price_test_team')).toBe('team'); + expect(getPlanFromPriceId('price_test_sovereign')).toBe('sovereign'); }); }); @@ -141,12 +151,11 @@ describe('Lifetime Plan Constants', () => { describe('isLifetimePriceId', () => { it('should return true for test lifetime price IDs', () => { - expect(isLifetimePriceId('price_test_solo_lifetime')).toBe(true); expect(isLifetimePriceId('price_test_pro_lifetime')).toBe(true); + expect(isLifetimePriceId('price_test_team_lifetime')).toBe(true); }); it('should return false for subscription price IDs', () => { - expect(isLifetimePriceId('price_test_solo')).toBe(false); expect(isLifetimePriceId('price_test_pro')).toBe(false); }); @@ -157,80 +166,82 @@ describe('Lifetime Plan Constants', () => { describe('getPlanInfoFromPriceId', () => { it('should return lifetime billing type for lifetime prices', () => { - const result = getPlanInfoFromPriceId('price_test_solo_lifetime'); - expect(result.plan).toBe('solo'); + const result = getPlanInfoFromPriceId('price_test_pro_lifetime'); + expect(result.plan).toBe('pro'); expect(result.billingType).toBe('lifetime'); }); it('should return monthly billing type for subscription prices', () => { - const result = getPlanInfoFromPriceId('price_test_solo'); - expect(result.plan).toBe('solo'); + const result = getPlanInfoFromPriceId('price_test_pro'); + expect(result.plan).toBe('pro'); expect(result.billingType).toBe('monthly'); }); - it('should return free plan for unknown prices', () => { + it('should return community plan for unknown prices', () => { const result = getPlanInfoFromPriceId('unknown'); - expect(result.plan).toBe('free'); + expect(result.plan).toBe('community'); expect(result.billingType).toBe('monthly'); }); }); describe('getStripePriceMapping', () => { it('should include test lifetime prices', () => { - const mapping = getStripePriceMapping({}); - expect(mapping['price_test_solo_lifetime']).toBeDefined(); - expect(mapping['price_test_solo_lifetime'].billingType).toBe('lifetime'); + const mapping = getStripePriceMapping({} as any); + expect(mapping['price_test_pro_lifetime']).toBeDefined(); + expect(mapping['price_test_pro_lifetime'].billingType).toBe('lifetime'); + expect(mapping['price_test_pro_lifetime'].plan).toBe('pro'); }); it('should include environment-configured lifetime prices', () => { const env = { - STRIPE_SOLO_LIFETIME_PRICE_ID: 'price_live_solo_lt', STRIPE_PRO_LIFETIME_PRICE_ID: 'price_live_pro_lt', + STRIPE_TEAM_LIFETIME_PRICE_ID: 'price_live_team_lt', }; - const mapping = getStripePriceMapping(env); + const mapping = getStripePriceMapping(env as any); - expect(mapping['price_live_solo_lt']).toEqual({ - plan: 'solo', - billingType: 'lifetime', - }); expect(mapping['price_live_pro_lt']).toEqual({ plan: 'pro', billingType: 'lifetime', }); + expect(mapping['price_live_team_lt']).toEqual({ + plan: 'team', + billingType: 'lifetime', + }); }); it('should include subscription prices as monthly', () => { - const mapping = getStripePriceMapping({}); - expect(mapping['price_test_solo']).toBeDefined(); - expect(mapping['price_test_solo'].billingType).toBe('monthly'); + const mapping = getStripePriceMapping({} as any); + expect(mapping['price_test_pro']).toBeDefined(); + expect(mapping['price_test_pro'].billingType).toBe('monthly'); + expect(mapping['price_test_pro'].plan).toBe('pro'); }); }); describe('getLifetimePriceIds', () => { it('should return empty array when no lifetime prices configured', () => { - const ids = getLifetimePriceIds({}); + const ids = getLifetimePriceIds({} as any); expect(ids).toEqual([]); }); it('should return configured lifetime price IDs', () => { const env = { - STRIPE_SOLO_LIFETIME_PRICE_ID: 'price_solo_lt', STRIPE_PRO_LIFETIME_PRICE_ID: 'price_pro_lt', + STRIPE_TEAM_LIFETIME_PRICE_ID: 'price_team_lt', }; - const ids = getLifetimePriceIds(env); + const ids = getLifetimePriceIds(env as any); - expect(ids).toContain('price_solo_lt'); expect(ids).toContain('price_pro_lt'); + expect(ids).toContain('price_team_lt'); expect(ids.length).toBe(2); }); it('should handle partial configuration', () => { const env = { - STRIPE_SOLO_LIFETIME_PRICE_ID: 'price_solo_lt', + STRIPE_PRO_LIFETIME_PRICE_ID: 'price_pro_lt', }; - const ids = getLifetimePriceIds(env); + const ids = getLifetimePriceIds(env as any); - expect(ids).toContain('price_solo_lt'); + expect(ids).toContain('price_pro_lt'); expect(ids.length).toBe(1); }); }); diff --git a/apps/api/tests/helpers.ts b/apps/api/tests/helpers.ts index a7d883f..e13dd69 100644 --- a/apps/api/tests/helpers.ts +++ b/apps/api/tests/helpers.ts @@ -19,6 +19,7 @@ interface MockPreparedStatement { first: () => Promise; all: () => Promise<{ results: T[]; success: boolean }>; run: () => Promise; + raw: () => Promise; } export function createMockDB(data: Record = {}): D1Database { @@ -50,6 +51,18 @@ export function createMockDB(data: Record = {}): D1Database { results: [], meta: { changes: 1 }, }), + raw: async () => { + // raw() returns results as arrays instead of objects + const table = extractTableName(query); + const results = data[table] ?? []; + // Convert objects to arrays of values + return results.map((row) => { + if (typeof row === 'object' && row !== null) { + return Object.values(row) as T; + } + return [row] as T; + }); + }, }; return statement; @@ -193,7 +206,7 @@ export const mockLifetimeLicense = { id: 'lic_lifetime_123', license_key: 'RM-LIFE-1234-5678-ABCD', user_id: 'user_test_123', - plan: 'solo', + plan: 'pro', plan_type: 'lifetime', status: 'active', current_period_start: new Date().toISOString(), @@ -212,7 +225,7 @@ export const mockRevokedLicense = { id: 'lic_revoked_123', license_key: 'RM-REVK-1234-5678-ABCD', user_id: 'user_test_123', - plan: 'solo', + plan: 'pro', plan_type: 'lifetime', status: 'revoked', current_period_start: new Date().toISOString(), diff --git a/apps/api/tests/stripe-webhook.test.ts b/apps/api/tests/stripe-webhook.test.ts index 403ca56..5c01620 100644 --- a/apps/api/tests/stripe-webhook.test.ts +++ b/apps/api/tests/stripe-webhook.test.ts @@ -125,12 +125,11 @@ describe('Stripe Price Mapping for Lifetime', () => { describe('isLifetimePriceId', () => { it('should return true for test lifetime price IDs', () => { - expect(isLifetimePriceId('price_test_solo_lifetime')).toBe(true); expect(isLifetimePriceId('price_test_pro_lifetime')).toBe(true); + expect(isLifetimePriceId('price_test_team_lifetime')).toBe(true); }); it('should return false for regular price IDs', () => { - expect(isLifetimePriceId('price_test_solo')).toBe(false); expect(isLifetimePriceId('price_test_pro')).toBe(false); expect(isLifetimePriceId('unknown_price')).toBe(false); }); @@ -138,20 +137,20 @@ describe('Stripe Price Mapping for Lifetime', () => { describe('getPlanInfoFromPriceId', () => { it('should return lifetime billing type for lifetime price IDs', () => { - const result = getPlanInfoFromPriceId('price_test_solo_lifetime'); - expect(result.plan).toBe('solo'); + const result = getPlanInfoFromPriceId('price_test_pro_lifetime'); + expect(result.plan).toBe('pro'); expect(result.billingType).toBe('lifetime'); }); it('should return monthly billing type for regular price IDs', () => { - const result = getPlanInfoFromPriceId('price_test_solo'); - expect(result.plan).toBe('solo'); + const result = getPlanInfoFromPriceId('price_test_pro'); + expect(result.plan).toBe('pro'); expect(result.billingType).toBe('monthly'); }); - it('should return free plan for unknown price IDs', () => { + it('should return community plan for unknown price IDs', () => { const result = getPlanInfoFromPriceId('unknown_price'); - expect(result.plan).toBe('free'); + expect(result.plan).toBe('community'); expect(result.billingType).toBe('monthly'); }); }); @@ -159,28 +158,28 @@ describe('Stripe Price Mapping for Lifetime', () => { describe('getStripePriceMapping', () => { it('should include lifetime prices from environment', () => { const mockEnv = { - STRIPE_SOLO_LIFETIME_PRICE_ID: 'price_prod_solo_lifetime', STRIPE_PRO_LIFETIME_PRICE_ID: 'price_prod_pro_lifetime', + STRIPE_TEAM_LIFETIME_PRICE_ID: 'price_prod_team_lifetime', }; const mapping = getStripePriceMapping(mockEnv); - expect(mapping['price_prod_solo_lifetime']).toEqual({ - plan: 'solo', - billingType: 'lifetime', - }); expect(mapping['price_prod_pro_lifetime']).toEqual({ plan: 'pro', billingType: 'lifetime', }); + expect(mapping['price_prod_team_lifetime']).toEqual({ + plan: 'team', + billingType: 'lifetime', + }); }); it('should include test lifetime prices', () => { const mockEnv = {}; const mapping = getStripePriceMapping(mockEnv); - expect(mapping['price_test_solo_lifetime']).toEqual({ - plan: 'solo', + expect(mapping['price_test_pro_lifetime']).toEqual({ + plan: 'pro', billingType: 'lifetime', }); }); @@ -189,8 +188,8 @@ describe('Stripe Price Mapping for Lifetime', () => { const mockEnv = {}; const mapping = getStripePriceMapping(mockEnv); - expect(mapping['price_test_solo']).toEqual({ - plan: 'solo', + expect(mapping['price_test_pro']).toEqual({ + plan: 'pro', billingType: 'monthly', }); }); @@ -224,7 +223,7 @@ describe('Webhook Event Type Handling', () => { subscription: null, line_items: { data: [{ - price: { id: 'price_test_solo_lifetime' }, + price: { id: 'price_test_pro_lifetime' }, }], }, }; diff --git a/apps/api/tests/usage.test.ts b/apps/api/tests/usage.test.ts index 7dcf567..58caf51 100644 --- a/apps/api/tests/usage.test.ts +++ b/apps/api/tests/usage.test.ts @@ -7,7 +7,7 @@ * POST /v1/usage/check-quota - Check quota availability */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { handleSyncUsage, handleGetUsage, @@ -23,11 +23,27 @@ import { } from './helpers'; import type { Env } from '../src/types'; import type { ErrorResponse } from '../src/types/api'; +import * as db from '../src/lib/db'; + +// Mock the db module +vi.mock('../src/lib/db', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + getLicenseByKey: vi.fn(), + logUsage: vi.fn(), + recordUsageEvent: vi.fn(), + getUsageForPeriod: vi.fn().mockResolvedValue({ scans: 0, resources: 0 }), + getUsageHistory: vi.fn().mockResolvedValue([]), + getMonthlyUsageCount: vi.fn().mockResolvedValue(0), + }; +}); describe('Usage Endpoints', () => { let env: Env; beforeEach(() => { + vi.clearAllMocks(); env = createMockEnv(); }); @@ -86,6 +102,8 @@ describe('Usage Endpoints', () => { }); it('should return 404 for non-existent license', async () => { + vi.mocked(db.getLicenseByKey).mockResolvedValue(null); + const request = createRequest('POST', '/v1/usage/sync', { license_key: 'RM-XXXX-XXXX-XXXX-XXXX', machine_id: generateMachineId(), @@ -112,6 +130,8 @@ describe('Usage Endpoints', () => { }); it('should return 404 for non-existent license', async () => { + vi.mocked(db.getLicenseByKey).mockResolvedValue(null); + const request = createRequest('GET', '/v1/usage/RM-XXXX-XXXX-XXXX-XXXX'); const response = await handleGetUsage(request, env, 'RM-XXXX-XXXX-XXXX-XXXX', '1.2.3.4'); @@ -132,6 +152,8 @@ describe('Usage Endpoints', () => { }); it('should return 404 for non-existent license', async () => { + vi.mocked(db.getLicenseByKey).mockResolvedValue(null); + const request = createRequest('GET', '/v1/usage/RM-XXXX-XXXX-XXXX-XXXX/history'); const response = await handleGetUsageHistory(request, env, 'RM-XXXX-XXXX-XXXX-XXXX', '1.2.3.4'); @@ -181,6 +203,8 @@ describe('Usage Endpoints', () => { }); it('should return 404 for non-existent license', async () => { + vi.mocked(db.getLicenseByKey).mockResolvedValue(null); + const request = createRequest('POST', '/v1/usage/check-quota', { license_key: 'RM-XXXX-XXXX-XXXX-XXXX', operation: 'scans', diff --git a/apps/api/tests/user.test.ts b/apps/api/tests/user.test.ts index 96378ab..e993175 100644 --- a/apps/api/tests/user.test.ts +++ b/apps/api/tests/user.test.ts @@ -6,7 +6,7 @@ * POST /v1/me/resend-key - Resend license key via email */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { handleGetOwnLicense, handleGetOwnMachines, @@ -21,6 +21,23 @@ import { } from './helpers'; import type { Env } from '../src/types'; import type { ErrorResponse } from '../src/types/api'; +import * as db from '../src/lib/db'; + +// Mock the db module +vi.mock('../src/lib/db', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + getLicenseByKey: vi.fn(), + getLicensesForUser: vi.fn().mockResolvedValue([]), + getActiveMachines: vi.fn().mockResolvedValue([]), + getMachineChangesThisMonth: vi.fn().mockResolvedValue(0), + createDb: vi.fn(() => ({ + get: vi.fn().mockResolvedValue(null), + all: vi.fn().mockResolvedValue([]), + })), + }; +}); describe('User Self-Service Endpoints', () => { let env: Env; @@ -52,6 +69,11 @@ describe('User Self-Service Endpoints', () => { }); it('should return 404 for non-existent license', async () => { + // Mock createDb to return null for license lookup + vi.mocked(db.createDb).mockReturnValue({ + get: vi.fn().mockResolvedValue(null), + } as unknown as ReturnType); + const request = createRequest('GET', '/v1/me/license?license_key=RM-XXXX-XXXX-XXXX-XXXX'); const response = await handleGetOwnLicense(request, env, '1.2.3.4'); @@ -62,26 +84,20 @@ describe('User Self-Service Endpoints', () => { }); it('should return license details for valid key', async () => { - const mockDB = { - prepare: () => ({ - bind: () => ({ - first: async () => ({ - license_key: mockLicense.license_key, - plan: 'pro', - status: 'active', - current_period_start: mockLicense.current_period_start, - current_period_end: mockLicense.current_period_end, - stripe_subscription_id: 'sub_123', - created_at: mockLicense.created_at, - active_machines: 2, - active_aws_accounts: 1, - id: mockLicense.id, - }), - }), + // Mock createDb to return license details + vi.mocked(db.createDb).mockReturnValue({ + get: vi.fn().mockResolvedValue({ + license_key: mockLicense.license_key, + plan: 'pro', + status: 'active', + current_period_start: mockLicense.current_period_start, + current_period_end: mockLicense.current_period_end, + stripe_subscription_id: 'sub_123', + created_at: mockLicense.created_at, + active_machines: 2, + active_aws_accounts: 1, }), - } as unknown as D1Database; - - env = createMockEnv({ DB: mockDB }); + } as unknown as ReturnType); const request = createRequest('GET', `/v1/me/license?license_key=${mockLicense.license_key}`); @@ -110,6 +126,9 @@ describe('User Self-Service Endpoints', () => { }); it('should return 404 for non-existent license', async () => { + // Mock getLicenseByKey to return null + vi.mocked(db.getLicenseByKey).mockResolvedValue(null); + const request = createRequest('GET', '/v1/me/machines?license_key=RM-XXXX-XXXX-XXXX-XXXX'); const response = await handleGetOwnMachines(request, env, '1.2.3.4'); @@ -119,36 +138,38 @@ describe('User Self-Service Endpoints', () => { }); it('should return machines list for valid license', async () => { - let callCount = 0; - const mockDB = { - prepare: () => ({ - bind: () => ({ - first: async () => { - callCount++; - // First call: getLicenseByKey - if (callCount === 1) { - return { id: mockLicense.id, plan: 'pro' }; - } - // Third call: machine changes count - return { count: 1 }; - }, - all: async () => ({ - results: [ - { - machine_id: mockMachine.machine_id, - machine_name: 'Test Machine', - is_active: 1, - first_seen_at: mockMachine.first_seen_at, - last_seen_at: mockMachine.last_seen_at, - }, - ], - success: true, - }), - }), - }), - } as unknown as D1Database; + // Mock getLicenseByKey to return license + vi.mocked(db.getLicenseByKey).mockResolvedValue({ + id: mockLicense.id, + userId: 'user_123', + licenseKey: mockLicense.license_key, + plan: 'pro', + planType: 'monthly', + status: 'active', + currentPeriodStart: mockLicense.current_period_start, + currentPeriodEnd: mockLicense.current_period_end, + stripeSubscriptionId: null, + stripePriceId: null, + stripeSessionId: null, + createdAt: mockLicense.created_at, + updatedAt: mockLicense.updated_at, + canceledAt: null, + revokedAt: null, + revokedReason: null, + }); - env = createMockEnv({ DB: mockDB }); + // Mock createDb to return machines via all() and changes via get() + vi.mocked(db.createDb).mockReturnValue({ + get: vi.fn().mockResolvedValue({ count: 1 }), // Machine changes count + all: vi.fn().mockResolvedValue([{ + machine_id: mockMachine.machine_id, + machine_name: 'Test Machine', + is_active: 1, + first_seen_at: mockMachine.first_seen_at, + last_seen_at: mockMachine.last_seen_at, + }]), + query: { licenses: { findFirst: vi.fn() } }, + } as unknown as ReturnType); const request = createRequest('GET', `/v1/me/machines?license_key=${mockLicense.license_key}`); @@ -164,32 +185,38 @@ describe('User Self-Service Endpoints', () => { }); it('should truncate machine IDs in response', async () => { - let callCount = 0; - const mockDB = { - prepare: () => ({ - bind: () => ({ - first: async () => { - callCount++; - if (callCount === 1) return { id: mockLicense.id, plan: 'pro' }; - return { count: 0 }; - }, - all: async () => ({ - results: [ - { - machine_id: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6', - machine_name: null, - is_active: 1, - first_seen_at: new Date().toISOString(), - last_seen_at: new Date().toISOString(), - }, - ], - success: true, - }), - }), - }), - } as unknown as D1Database; + // Mock getLicenseByKey to return license + vi.mocked(db.getLicenseByKey).mockResolvedValue({ + id: mockLicense.id, + userId: 'user_123', + licenseKey: mockLicense.license_key, + plan: 'pro', + planType: 'monthly', + status: 'active', + currentPeriodStart: mockLicense.current_period_start, + currentPeriodEnd: mockLicense.current_period_end, + stripeSubscriptionId: null, + stripePriceId: null, + stripeSessionId: null, + createdAt: mockLicense.created_at, + updatedAt: mockLicense.updated_at, + canceledAt: null, + revokedAt: null, + revokedReason: null, + }); - env = createMockEnv({ DB: mockDB }); + // Mock createDb + vi.mocked(db.createDb).mockReturnValue({ + get: vi.fn().mockResolvedValue({ count: 0 }), + all: vi.fn().mockResolvedValue([{ + machine_id: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6', + machine_name: null, + is_active: 1, + first_seen_at: new Date().toISOString(), + last_seen_at: new Date().toISOString(), + }]), + query: { licenses: { findFirst: vi.fn() } }, + } as unknown as ReturnType); const request = createRequest('GET', `/v1/me/machines?license_key=${mockLicense.license_key}`); @@ -239,18 +266,11 @@ describe('User Self-Service Endpoints', () => { }); it('should return success even if email not found (prevent enumeration)', async () => { - const mockDB = { - prepare: () => ({ - bind: () => ({ - all: async () => ({ - results: [], - success: true, - }), - }), - }), - } as unknown as D1Database; - - env = createMockEnv({ DB: mockDB }); + // Mock createDb to return empty results + vi.mocked(db.createDb).mockReturnValue({ + get: vi.fn().mockResolvedValue(null), + all: vi.fn().mockResolvedValue([]), + } as unknown as ReturnType); const request = createRequest('POST', '/v1/me/resend-key', { email: 'nonexistent@example.com', @@ -265,24 +285,15 @@ describe('User Self-Service Endpoints', () => { }); it('should return success for existing email', async () => { - const mockDB = { - prepare: () => ({ - bind: () => ({ - all: async () => ({ - results: [ - { - license_key: mockLicense.license_key, - plan: 'pro', - status: 'active', - }, - ], - success: true, - }), - }), - }), - } as unknown as D1Database; - - env = createMockEnv({ DB: mockDB }); + // Mock createDb to return a license + vi.mocked(db.createDb).mockReturnValue({ + get: vi.fn().mockResolvedValue(null), + all: vi.fn().mockResolvedValue([{ + license_key: mockLicense.license_key, + plan: 'pro', + status: 'active', + }]), + } as unknown as ReturnType); const request = createRequest('POST', '/v1/me/resend-key', { email: 'test@example.com', diff --git a/apps/api/tests/validate-license.test.ts b/apps/api/tests/validate-license.test.ts index 3edacf1..c95a319 100644 --- a/apps/api/tests/validate-license.test.ts +++ b/apps/api/tests/validate-license.test.ts @@ -15,11 +15,31 @@ import { } from './helpers'; import type { Env } from '../src/types'; import type { ValidateLicenseResponse, ErrorResponse } from '../src/types/api'; +import * as db from '../src/lib/db'; + +// Mock the db module +vi.mock('../src/lib/db', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + getLicenseForValidation: vi.fn(), + getActiveDeviceCount: vi.fn().mockResolvedValue(1), + getNewDeviceCount: vi.fn().mockResolvedValue(0), + getActiveCIDeviceCount: vi.fn().mockResolvedValue(0), + updateMachineLastSeen: vi.fn().mockResolvedValue(undefined), + registerMachine: vi.fn().mockResolvedValue(undefined), + recordMachineChange: vi.fn().mockResolvedValue(undefined), + logUsage: vi.fn().mockResolvedValue(undefined), + getMonthlyUsageCount: vi.fn().mockResolvedValue(0), + getActiveMachines: vi.fn().mockResolvedValue([]), + }; +}); describe('POST /v1/license/validate', () => { let env: Env; beforeEach(() => { + vi.clearAllMocks(); env = createMockEnv(); }); @@ -74,7 +94,6 @@ describe('POST /v1/license/validate', () => { const data = await parseResponse(response); expect(response.status).toBe(400); - // Zod validation returns INVALID_REQUEST with descriptive message expect(data.error_code).toBe('INVALID_REQUEST'); expect(data.message).toContain('license'); }); @@ -89,7 +108,6 @@ describe('POST /v1/license/validate', () => { const data = await parseResponse(response); expect(response.status).toBe(400); - // Zod validation returns INVALID_REQUEST with descriptive message expect(data.error_code).toBe('INVALID_REQUEST'); expect(data.message).toContain('machine'); }); @@ -97,6 +115,9 @@ describe('POST /v1/license/validate', () => { describe('license lookup', () => { it('should return 404 for non-existent license', async () => { + // Mock returns null for non-existent license + vi.mocked(db.getLicenseForValidation).mockResolvedValue(null); + const request = createRequest('POST', '/v1/license/validate', { license_key: 'RM-XXXX-XXXX-XXXX-XXXX', machine_id: generateMachineId(), @@ -112,27 +133,19 @@ describe('POST /v1/license/validate', () => { describe('response format', () => { it('should include all required fields in success response', async () => { - // Create mock DB with license data - const mockDB = { - prepare: (query: string) => ({ - bind: () => ({ - first: async () => ({ - license_id: mockLicense.id, - plan: 'pro', - status: 'active', - current_period_end: mockLicense.current_period_end, - machine_is_active: 1, - active_machines: 1, - monthly_changes: 0, - active_aws_accounts: 1, - }), - all: async () => ({ results: [], success: true }), - run: async () => ({ success: true, results: [] }), - }), - }), - } as unknown as D1Database; - - env = createMockEnv({ DB: mockDB }); + // Mock license data + vi.mocked(db.getLicenseForValidation).mockResolvedValue({ + license_id: mockLicense.id, + license_key: mockLicense.license_key, + plan: 'pro', + status: 'active', + current_period_end: mockLicense.current_period_end, + machine_is_active: 1, + machine_last_seen: new Date().toISOString(), + active_machines: 1, + monthly_changes: 0, + active_aws_accounts: 1, + }); const request = createRequest('POST', '/v1/license/validate', { license_key: 'RM-TEST-1234-5678-ABCD', @@ -148,8 +161,8 @@ describe('POST /v1/license/validate', () => { expect(data.plan).toBe('pro'); expect(data.status).toBe('active'); expect(data.features).toBeDefined(); - expect(data.features.resources_per_scan).toBe(-1); // unlimited for pro - expect(data.features.machines).toBe(3); + expect(data.features.resources_per_scan).toBe(-1); // unlimited for pro (v4.0) + expect(data.features.machines).toBe(2); // v4.0 pro plan has 2 machines expect(data.usage).toBeDefined(); expect(data.cache_until).toBeDefined(); expect(data.cli_version).toBeDefined(); @@ -159,26 +172,18 @@ describe('POST /v1/license/validate', () => { describe('CLI version check', () => { it('should return ok for current version', async () => { - const mockDB = { - prepare: () => ({ - bind: () => ({ - first: async () => ({ - license_id: mockLicense.id, - plan: 'pro', - status: 'active', - current_period_end: mockLicense.current_period_end, - machine_is_active: 1, - active_machines: 1, - monthly_changes: 0, - active_aws_accounts: 0, - }), - all: async () => ({ results: [], success: true }), - run: async () => ({ success: true, results: [] }), - }), - }), - } as unknown as D1Database; - - env = createMockEnv({ DB: mockDB }); + vi.mocked(db.getLicenseForValidation).mockResolvedValue({ + license_id: mockLicense.id, + license_key: mockLicense.license_key, + plan: 'pro', + status: 'active', + current_period_end: mockLicense.current_period_end, + machine_is_active: 1, + machine_last_seen: new Date().toISOString(), + active_machines: 1, + monthly_changes: 0, + active_aws_accounts: 0, + }); const request = createRequest('POST', '/v1/license/validate', { license_key: 'RM-TEST-1234-5678-ABCD', @@ -193,26 +198,18 @@ describe('POST /v1/license/validate', () => { }); it('should return deprecated for old versions', async () => { - const mockDB = { - prepare: () => ({ - bind: () => ({ - first: async () => ({ - license_id: mockLicense.id, - plan: 'pro', - status: 'active', - current_period_end: mockLicense.current_period_end, - machine_is_active: 1, - active_machines: 1, - monthly_changes: 0, - active_aws_accounts: 0, - }), - all: async () => ({ results: [], success: true }), - run: async () => ({ success: true, results: [] }), - }), - }), - } as unknown as D1Database; - - env = createMockEnv({ DB: mockDB }); + vi.mocked(db.getLicenseForValidation).mockResolvedValue({ + license_id: mockLicense.id, + license_key: mockLicense.license_key, + plan: 'pro', + status: 'active', + current_period_end: mockLicense.current_period_end, + machine_is_active: 1, + machine_last_seen: new Date().toISOString(), + active_machines: 1, + monthly_changes: 0, + active_aws_accounts: 0, + }); const request = createRequest('POST', '/v1/license/validate', { license_key: 'RM-TEST-1234-5678-ABCD', @@ -228,26 +225,18 @@ describe('POST /v1/license/validate', () => { }); it('should return unsupported for very old versions', async () => { - const mockDB = { - prepare: () => ({ - bind: () => ({ - first: async () => ({ - license_id: mockLicense.id, - plan: 'pro', - status: 'active', - current_period_end: mockLicense.current_period_end, - machine_is_active: 1, - active_machines: 1, - monthly_changes: 0, - active_aws_accounts: 0, - }), - all: async () => ({ results: [], success: true }), - run: async () => ({ success: true, results: [] }), - }), - }), - } as unknown as D1Database; - - env = createMockEnv({ DB: mockDB }); + vi.mocked(db.getLicenseForValidation).mockResolvedValue({ + license_id: mockLicense.id, + license_key: mockLicense.license_key, + plan: 'pro', + status: 'active', + current_period_end: mockLicense.current_period_end, + machine_is_active: 1, + machine_last_seen: new Date().toISOString(), + active_machines: 1, + monthly_changes: 0, + active_aws_accounts: 0, + }); const request = createRequest('POST', '/v1/license/validate', { license_key: 'RM-TEST-1234-5678-ABCD', diff --git a/apps/web/.env.example b/apps/web/.env.example index 08b0651..7fa7020 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -24,15 +24,16 @@ NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/dashboard STRIPE_SECRET_KEY=sk_test_placeholder STRIPE_WEBHOOK_SECRET=whsec_placeholder -# Stripe Price IDs (create in Stripe Dashboard) -STRIPE_SOLO_MONTHLY_PRICE_ID=price_placeholder -STRIPE_SOLO_ANNUAL_PRICE_ID=price_placeholder -STRIPE_SOLO_LIFETIME_PRICE_ID=price_placeholder +# Stripe Price IDs - v4.0 Pricing (create in Stripe Dashboard) +# Community tier is free - no price ID needed STRIPE_PRO_MONTHLY_PRICE_ID=price_placeholder STRIPE_PRO_ANNUAL_PRICE_ID=price_placeholder STRIPE_PRO_LIFETIME_PRICE_ID=price_placeholder STRIPE_TEAM_MONTHLY_PRICE_ID=price_placeholder STRIPE_TEAM_ANNUAL_PRICE_ID=price_placeholder +STRIPE_TEAM_LIFETIME_PRICE_ID=price_placeholder +STRIPE_SOVEREIGN_MONTHLY_PRICE_ID=price_placeholder +STRIPE_SOVEREIGN_ANNUAL_PRICE_ID=price_placeholder # ----------------------------- # App URLs