diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..eacf049b --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,23 @@ +{ + "name": "subtrackr-devcontainer", + "dockerComposeFile": ["../../docker-compose.yml"], + "service": "backend", + "workspaceFolder": "/workspace", + "shutdownAction": "stopCompose", + "postCreateCommand": "npm install && npm run prepare", + "customizations": { + "vscode": { + "extensions": [ + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "ms-azuretools.vscode-docker" + ], + "settings": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.codeActionsOnSave": { "source.fixAll.eslint": true }, + "typescript.tsdk": "node_modules/typescript/lib" + } + } + } +} diff --git a/.gitignore b/.gitignore index 87e907f1..a28c2299 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,6 @@ -# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files - -# dependencies +# Dependencies node_modules/ -pnpm-lock.yaml +pnpm-lock.yaml/ # Expo .expo/ @@ -22,19 +20,20 @@ expo-env.d.ts # Metro .metro-health-check* -# debug +# Debug npm-debug.* yarn-debug.* yarn-error.* -# macOS +# macOS / Linux .DS_Store *.pem -# local env files +# Local env files .env*.local +.env -# typescript +# TypeScript *.tsbuildinfo # Build outputs @@ -42,11 +41,11 @@ builds/ android/ ios/ -.DS_Store -.env - # IDE / Tools .claude/ +.idea/ +.vscode/settings.json +!.vscode/extensions.json _typechain_tmp/ _tc_verify/ @@ -74,47 +73,41 @@ coverage/ # Test snapshots & generated test/lint artifacts **/__snapshots__/ *.snap +*.snap.orig junit.xml jest-results.json -*_output.txt -*_output_*.txt +test-results/ +playwright-report/ +e2e/artifacts/ +e2e/screenshots/ +_jest-cache/ +test_output.txt +tsc_output*.txt +lint_output*.txt +lint_final_error.txt +final_lint_check.txt -# Load test reports (generated) — keep the dir so k6 can write into it +# Load test reports load-tests/reports/* !load-tests/reports/.gitkeep -# VS Code -.vscode/settings.json -!.vscode/extensions.json - # React Native .expo-shared/ - -# Test snapshots -__snapshots__/ -*.snap - # Build artifacts build_errors*.txt -*.log contracts/migrations/history/* !contracts/migrations/history/.gitkeep contracts/migrations/snapshots/* !contracts/migrations/snapshots/.gitkeep -# Rust / Soroban test snapshots (generated by soroban-sdk test runner) +# Rust / Soroban test snapshots contracts/**/test_snapshots/ test_snapshots/ -# Generated files +# Generated / artifact files *.orig SubTrackr -test_output.txt -tsc_output*.txt -lint_output*.txt -lint_final_error.txt -final_lint_check.txt contracts/clippy_output.txt issue*.json issues_summary.json @@ -129,19 +122,9 @@ DESIGN_SYSTEM_INTEGRATION.md DESIGN_SYSTEM_IMPLEMENTATION.md DESIGN_SYSTEM_SETUP.md WCAG_COMPLIANCE.md +FORMATTING.md # Backup / duplicate files *.backup *\ copy.* package.json.backup -SubTrackr -FORMATTING.md -package.json.backup -# Test run artifacts (NOT the committed contract insta snapshots under -# contracts/**/test_snapshots/, which are intentional fixtures) -test-results/ -playwright-report/ -e2e/artifacts/ -e2e/screenshots/ -*.snap.orig -.jest-cache/ diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 00000000..ea3ad321 --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,19 @@ +image: mcr.microsoft.com/devcontainers/typescript-node:20 +ports: + - "3000:3000" + - "3001:3001" +tasks: + - name: Install dependencies + command: | + npm install + npm run prepare +vscode: + extensions: + - dbaeumer.vscode-eslint + - esbenp.prettier-vscode + - ms-azuretools.vscode-docker + settings: + editor.formatOnSave: true + editor.defaultFormatter: esbenp.prettier-vscode + editor.codeActionsOnSave: + source.fixAll.eslint: true diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 00000000..34eed8b2 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +npx --no -- commitlint --edit $1 \ No newline at end of file diff --git a/app.json b/app.json index 10f74273..2501945b 100644 --- a/app.json +++ b/app.json @@ -48,7 +48,21 @@ "web": { "favicon": "./assets/subtrackr-icon.png", "name": "SubTrackr - Subscription Management with Crypto Payments", - "description": "Manage your subscriptions with Web3 crypto payments" + "description": "Manage your subscriptions with Web3 crypto payments", + "security": { + "hsts": { + "maxAgeSeconds": 63072000, + "includeSubDomains": true, + "preload": true + }, + "permissionsPolicy": { + "geolocation": "none", + "microphone": "none", + "camera": "none", + "payment": "none", + "usb": "none" + } + } }, "extra": { "eas": { diff --git a/backend/server.ts b/backend/server.ts index 7b538071..b6e3b953 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -36,6 +36,7 @@ import { rateLimitingService } from './services/shared/rateLimitingService'; import { createRateLimitMiddleware, RATE_LIMIT_HEADERS } from './services/shared/rateLimitMiddleware'; import { applyCompression, compressionPrometheusMetrics } from './services/shared/compression'; import { wrapWithMonitor, type MonitoredPool } from './services/shared/poolMonitor'; +import { applySecurityHeadersToResponse } from './shared/middleware/securityHeaders'; import { SubscriptionTier } from '../src/types/subscription'; export interface StartServerOptions { @@ -209,6 +210,19 @@ export async function startServer(options: StartServerOptions = {}): Promise) => { + if (!res.headersSent) { + applySecurityHeadersToResponse(res); + } + if (typeof statusMessage === 'string') { + return originalWriteHead(statusCode, statusMessage); + } + return originalWriteHead(statusCode, statusMessage); + }) as typeof res.writeHead; + try { // ----------------------------------------------------------------- // Health (bypass rate limiting) diff --git a/backend/shared/middleware/__tests__/securityHeaders.test.ts b/backend/shared/middleware/__tests__/securityHeaders.test.ts new file mode 100644 index 00000000..e3d235ed --- /dev/null +++ b/backend/shared/middleware/__tests__/securityHeaders.test.ts @@ -0,0 +1,107 @@ +/** + * Tests for security headers middleware. + * + * Issue #1010: HSTS and Permissions-Policy hardening. + */ + +import { + buildSecurityHeaders, + buildHsts, + buildPermissionsPolicy, + securityHeadersMiddleware, + applySecurityHeadersToResponse, + type SecurityHeadersOptions, +} from '../securityHeaders'; + +describe('buildHsts', () => { + it('returns default HSTS header', () => { + expect(buildHsts({})).toBe('max-age=63072000; includeSubDomains; preload'); + }); + + it('respects custom max-age', () => { + expect(buildHsts({ hstsMaxAge: 86400 })).toBe('max-age=86400; includeSubDomains; preload'); + }); + + it('omits includeSubDomains when disabled', () => { + expect(buildHsts({ hstsIncludeSubDomains: false })).toBe('max-age=63072000; preload'); + }); + + it('omits preload when disabled', () => { + expect(buildHsts({ hstsPreload: false })).toBe('max-age=63072000; includeSubDomains'); + }); +}); + +describe('buildPermissionsPolicy', () => { + it('returns default hardened policy', () => { + const policy = buildPermissionsPolicy({}); + expect(policy).toContain("geolocation='none'"); + expect(policy).toContain("microphone='none'"); + expect(policy).toContain("camera='none'"); + expect(policy).toContain("publickeyCredentialsGet='self'"); + }); + + it('merges custom directives', () => { + const policy = buildPermissionsPolicy({ + permissionsPolicy: { geolocation: ["'self'"] }, + }); + expect(policy).toContain("geolocation='self'"); + expect(policy).toContain("microphone='none'"); + }); +}); + +describe('buildSecurityHeaders', () => { + it('includes HSTS and Permissions-Policy', () => { + const headers = buildSecurityHeaders({}); + expect(headers['Strict-Transport-Security']).toBe('max-age=63072000; includeSubDomains; preload'); + expect(headers['Permissions-Policy']).toContain("geolocation='none'"); + expect(headers['X-Content-Type-Options']).toBe('nosniff'); + expect(headers['X-Frame-Options']).toBe('SAMEORIGIN'); + expect(headers['Referrer-Policy']).toBe('strict-origin-when-cross-origin'); + }); +}); + +describe('securityHeadersMiddleware', () => { + it('sets headers on the response', () => { + const headersOut: Record = {}; + const res = { + headersSent: false, + setHeader: (name: string, value: string | number | string[]) => { + headersOut[name] = String(value); + }, + } as any; + const next = jest.fn(); + + securityHeadersMiddleware()(null, res, next); + expect(next).toHaveBeenCalled(); + expect(headersOut['Strict-Transport-Security']).toBe('max-age=63072000; includeSubDomains; preload'); + expect(headersOut['X-Frame-Options']).toBe('SAMEORIGIN'); + }); + + it('skips setting headers when already sent', () => { + const setHeader = jest.fn(); + const res = { + headersSent: true, + setHeader, + } as any; + const next = jest.fn(); + + securityHeadersMiddleware()(null, res, next); + expect(setHeader).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalled(); + }); +}); + +describe('applySecurityHeadersToResponse', () => { + it('applies headers to response object', () => { + const headersOut: Record = {}; + const res = { + headersSent: false, + setHeader: (name: string, value: string | number | string[]) => { + headersOut[name] = String(value); + }, + } as any; + + applySecurityHeadersToResponse(res, { hstsMaxAge: 100 }); + expect(headersOut['Strict-Transport-Security']).toBe('max-age=100; includeSubDomains; preload'); + }); +}); diff --git a/backend/shared/middleware/index.ts b/backend/shared/middleware/index.ts index 6ca251e7..985a0615 100644 --- a/backend/shared/middleware/index.ts +++ b/backend/shared/middleware/index.ts @@ -36,3 +36,9 @@ export { applyETagToRawHandler, } from './etagMiddleware'; export type { ETagMiddlewareOptions } from './etagMiddleware'; +export { + buildSecurityHeaders, + securityHeadersMiddleware, + applySecurityHeadersToResponse, +} from './securityHeaders'; +export type { SecurityHeadersOptions } from './securityHeaders'; diff --git a/backend/shared/middleware/securityHeaders.ts b/backend/shared/middleware/securityHeaders.ts new file mode 100644 index 00000000..ce8bda56 --- /dev/null +++ b/backend/shared/middleware/securityHeaders.ts @@ -0,0 +1,104 @@ +/** + * Security headers middleware for HTTP responses. + * + * Issue #1010: adds production-ready HSTS and Permissions-Policy headers, + * plus common hardening headers, to every response. + */ + +export interface SecurityHeadersOptions { + /** HSTS max-age in seconds. Default: 1 year. */ + hstsMaxAge?: number; + /** Whether to include subdomains in HSTS. Default: true. */ + hstsIncludeSubDomains?: boolean; + /** Whether to preload HSTS. Default: true. */ + hstsPreload?: boolean; + /** Custom permissions policy directives. */ + permissionsPolicy?: Record; +} + +const DEFAULT_PERMISSIONS_POLICY: Record = { + accelerometer: ["'none'"], + ambientLightSensor: ["'none'"], + autoplay: ["'none'"], + camera: ["'none'"], + crossOriginIsolated: ["'none'"], + displayCapture: ["'none'"], + encryptedMedia: ["'none'"], + geolocation: ["'none'"], + gyroscope: ["'none'"], + magnetometer: ["'none'"], + microphone: ["'none'"], + midi: ["'none'"], + payment: ["'none'"], + pictureInPicture: ["'none'"], + publickeyCredentialsGet: ["'self'"], + screenWakeLock: ["'none'"], + usb: ["'none'"], + webRTC: ["'none'"], + xrSpatialTracking: ["'none'"], +}; + +function buildHsts(options: SecurityHeadersOptions): string { + const maxAge = options.hstsMaxAge ?? 63_072_000; + const includeSubDomains = options.hstsIncludeSubDomains ?? true; + const preload = options.hstsPreload ?? true; + const parts = [`max-age=${maxAge}`]; + if (includeSubDomains) parts.push('includeSubDomains'); + if (preload) parts.push('preload'); + return parts.join('; '); +} + +function buildPermissionsPolicy( + options: SecurityHeadersOptions, +): string { + const policy = { ...DEFAULT_PERMISSIONS_POLICY, ...(options.permissionsPolicy ?? {}) }; + return Object.entries(policy) + .map(([name, values]) => `${name}=${Array.isArray(values) ? values.join(' ') : values}`) + .join(', '); +} + +export function buildSecurityHeaders( + options: SecurityHeadersOptions = {}, +): Record { + return { + 'Strict-Transport-Security': buildHsts(options), + 'Permissions-Policy': buildPermissionsPolicy(options), + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'SAMEORIGIN', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + }; +} + +export function securityHeadersMiddleware(options: SecurityHeadersOptions = {}) { + const headers = buildSecurityHeaders(options); + return function applySecurityHeaders( + _req: unknown, + res: { + setHeader(name: string, value: string | number | string[]): void; + headersSent: boolean; + }, + next: () => void, + ): void { + if (!res.headersSent) { + for (const [name, value] of Object.entries(headers)) { + res.setHeader(name, value); + } + } + next(); + }; +} + +export function applySecurityHeadersToResponse( + res: { + setHeader(name: string, value: string | number | string[]): void; + headersSent: boolean; + }, + options: SecurityHeadersOptions = {}, +): void { + if (!res.headersSent) { + const headers = buildSecurityHeaders(options); + for (const [name, value] of Object.entries(headers)) { + res.setHeader(name, value); + } + } +} diff --git a/src/services/sandbox/__tests__/developerPortalService.test.ts b/src/services/sandbox/__tests__/developerPortalService.test.ts new file mode 100644 index 00000000..45fae663 --- /dev/null +++ b/src/services/sandbox/__tests__/developerPortalService.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, jest, beforeEach } from '@jest/globals'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { developerPortalService } from '../developerPortalService'; +import type { DeveloperProfile, OnboardingStep, DocumentationSection, IntegrationGuide } from '../../types/developerPortal'; + +jest.mock('@react-native-async-storage/async-storage', () => ({ + setItem: jest.fn(() => Promise.resolve()), + getItem: jest.fn(() => Promise.resolve(null)), + removeItem: jest.fn(() => Promise.resolve()), + clear: jest.fn(() => Promise.resolve()), +})); + +describe('DeveloperPortalService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('registerDeveloper', () => { + it('registers a new developer', async () => { + const developer = await developerPortalService.registerDeveloper( + 'test@example.com', + 'Test User', + 'Acme', + 'https://example.com' + ); + + expect(developer.email).toBe('test@example.com'); + expect(developer.name).toBe('Test User'); + expect(developer.company).toBe('Acme'); + expect(developer.website).toBe('https://example.com'); + expect(developer.status).toBe('active'); + expect(developer.tier).toBe('free'); + }); + + it('generates a unique id', async () => { + const dev1 = await developerPortalService.registerDeveloper('a@b.com', 'A'); + const dev2 = await developerPortalService.registerDeveloper('c@d.com', 'C'); + expect(dev1.id).not.toBe(dev2.id); + }); + }); + + describe('getDeveloper', () => { + it('returns null for unknown id', async () => { + const dev = await developerPortalService.getDeveloper('dev_unknown'); + expect(dev).toBeNull(); + }); + }); + + describe('updateDeveloper', () => { + it('updates fields on an existing developer', async () => { + const dev = await developerPortalService.registerDeveloper('upd@example.com', 'Updater'); + const updated = await developerPortalService.updateDeveloper(dev.id, { name: 'Updated Name' }); + expect(updated?.name).toBe('Updated Name'); + expect(updated?.email).toBe('upd@example.com'); + }); + }); + + describe('onboarding', () => { + it('returns default onboarding steps after registration', async () => { + const dev = await developerPortalService.registerDeveloper('onboard@example.com', 'Onboard'); + const steps = await developerPortalService.getOnboardingSteps(dev.id); + expect(steps.length).toBeGreaterThan(0); + }); + + it('marks a step as completed', async () => { + const dev = await developerPortalService.registerDeveloper('step@example.com', 'Step'); + const stepsBefore = await developerPortalService.getOnboardingSteps(dev.id); + const targetStep = stepsBefore[0]; + const updated = await developerPortalService.completeOnboardingStep(dev.id, targetStep.id); + expect(updated?.some((s) => s.id === targetStep.id && s.isCompleted)).toBe(true); + }); + + it('returns null for unknown developer', async () => { + const steps = await developerPortalService.getOnboardingSteps('unknown'); + expect(steps).toEqual([]); + }); + }); + + describe('documentation', () => { + it('returns all documentation sections', () => { + const docs = developerPortalService.getDocumentationSections(); + expect(docs.length).toBeGreaterThan(0); + }); + + it('filters documentation by category', () => { + const docs = developerPortalService.getDocumentationByCategory('API Reference'); + expect(docs.every((d) => d.category === 'API Reference')).toBe(true); + }); + + it('finds documentation by slug', () => { + const doc = developerPortalService.getDocumentationBySlug('getting-started'); + expect(doc?.title).toBe('Getting Started'); + }); + + it('searches documentation by title', () => { + const docs = developerPortalService.searchDocumentation('Webhooks'); + expect(docs.some((d) => d.title === 'Webhooks')).toBe(true); + }); + + it('searches documentation by tag', () => { + const docs = developerPortalService.searchDocumentation('nodejs'); + expect(docs.some((d) => d.tags.includes('nodejs'))).toBe(true); + }); + }); + + describe('integration guides', () => { + it('returns all integration guides', () => { + const guides = developerPortalService.getIntegrationGuides(); + expect(guides.length).toBeGreaterThan(0); + }); + + it('filters guides by difficulty', () => { + const guides = developerPortalService.getIntegrationGuidesByDifficulty('beginner'); + expect(guides.every((g) => g.difficulty === 'beginner')).toBe(true); + }); + + it('searches guides by title', () => { + const guides = developerPortalService.searchIntegrationGuides('Python'); + expect(guides.some((g) => g.title === 'Python Quickstart')).toBe(true); + }); + + it('searches guides by tag', () => { + const guides = developerPortalService.searchIntegrationGuides('webhooks'); + expect(guides.some((g) => g.tags.includes('webhooks'))).toBe(true); + }); + }); +}); diff --git a/src/store/__tests__/developerPortalStore.test.ts b/src/store/__tests__/developerPortalStore.test.ts new file mode 100644 index 00000000..266af42d --- /dev/null +++ b/src/store/__tests__/developerPortalStore.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it, jest, beforeEach } from '@jest/globals'; +import { act } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useDeveloperPortalStore } from '../developerPortalStore'; + +jest.mock('@react-native-async-storage/async-storage', () => ({ + setItem: jest.fn(() => Promise.resolve()), + getItem: jest.fn(() => Promise.resolve(null)), + removeItem: jest.fn(() => Promise.resolve()), + clear: jest.fn(() => Promise.resolve()), +})); + +jest.mock('../../services/sandbox/apiKeyService', () => ({ + apiKeyService: { + loadApiKeys: jest.fn(() => Promise.resolve()), + getApiKeysByDeveloper: jest.fn(() => Promise.resolve([])), + createApiKey: jest.fn(() => + Promise.resolve({ + id: 'key-1', + key: 'sk_test_abc', + name: 'Test Key', + status: 'active', + permissions: ['read', 'write'], + createdAt: new Date(), + }) + ), + revokeApiKey: jest.fn(() => Promise.resolve()), + rotateApiKey: jest.fn(() => Promise.resolve(null)), + deleteApiKey: jest.fn(() => Promise.resolve()), + }, +})); + +jest.mock('../../services/sandbox/usageTrackingService', () => ({ + usageTrackingService: { + loadUsage: jest.fn(() => Promise.resolve()), + getUsageStats: jest.fn(() => + Promise.resolve({ + totalRequests: 10, + successfulRequests: 8, + failedRequests: 2, + avgResponseTime: 120, + }) + ), + getRecentMetrics: jest.fn(() => Promise.resolve([])), + }, +})); + +jest.mock('../../services/sandbox/developerPortalService', () => ({ + developerPortalService: { + registerDeveloper: jest.fn((email, name) => + Promise.resolve({ + id: 'dev-1', + email, + name, + status: 'active', + tier: 'free', + createdAt: new Date(), + updatedAt: new Date(), + lastLoginAt: new Date(), + }) + ), + getDeveloper: jest.fn(() => + Promise.resolve({ + id: 'dev-1', + email: 'test@example.com', + name: 'Test', + status: 'active', + tier: 'free', + createdAt: new Date(), + updatedAt: new Date(), + lastLoginAt: new Date(), + }) + ), + updateDeveloper: jest.fn((_id, updates) => + Promise.resolve({ + id: 'dev-1', + email: 'test@example.com', + name: 'Updated', + status: 'active', + tier: 'free', + createdAt: new Date(), + updatedAt: new Date(), + lastLoginAt: new Date(), + ...updates, + }) + ), + getOnboardingSteps: jest.fn(() => + Promise.resolve([ + { id: 'step-1', title: 'Step 1', isCompleted: false, isRequired: true, order: 1 }, + { id: 'step-2', title: 'Step 2', isCompleted: false, isRequired: true, order: 2 }, + ]) + ), + completeOnboardingStep: jest.fn(() => + Promise.resolve([ + { id: 'step-1', title: 'Step 1', isCompleted: true, isRequired: true, order: 1 }, + { id: 'step-2', title: 'Step 2', isCompleted: false, isRequired: true, order: 2 }, + ]) + ), + getDocumentationSections: jest.fn(() => [ + { id: 'doc-1', title: 'Getting Started', category: 'Basics', tags: [], content: '' }, + ]), + searchDocumentation: jest.fn(() => []), + getIntegrationGuides: jest.fn(() => [ + { id: 'guide-1', title: 'Node.js', difficulty: 'beginner', tags: [] }, + ]), + searchIntegrationGuides: jest.fn(() => []), + }, +})); + +jest.mock('../../services/errorHandler', () => ({ + errorHandler: { + handleError: jest.fn((error) => ({ + id: 'err-1', + type: 'unknown', + severity: 'medium', + message: error instanceof Error ? error.message : 'Unknown error', + userMessage: 'Something went wrong', + recoverySuggestions: [], + context: { action: 'test' }, + isHandled: false, + })), + }, + AppError: {}, +})); + +describe('useDeveloperPortalStore', () => { + beforeEach(() => { + useDeveloperPortalStore.setState({ + developer: null, + apiKeys: [], + usageStats: null, + recentUsage: [], + onboardingSteps: [], + documentation: [], + integrationGuides: [], + isLoading: false, + error: null, + }); + }); + + it('starts with empty state', () => { + const state = useDeveloperPortalStore.getState(); + expect(state.developer).toBeNull(); + expect(state.apiKeys).toHaveLength(0); + expect(state.isLoading).toBe(false); + }); + + it('registers a developer', async () => { + await act(async () => { + await useDeveloperPortalStore.getState().registerDeveloper('test@example.com', 'Test'); + }); + + const state = useDeveloperPortalStore.getState(); + expect(state.developer?.email).toBe('test@example.com'); + expect(state.developer?.name).toBe('Test'); + expect(state.isLoading).toBe(false); + }); + + it('fetches developer and api keys', async () => { + await act(async () => { + await useDeveloperPortalStore.getState().fetchDeveloper('dev-1'); + }); + + const state = useDeveloperPortalStore.getState(); + expect(state.developer?.id).toBe('dev-1'); + expect(state.apiKeys).toHaveLength(0); + expect(state.onboardingSteps).toHaveLength(2); + }); + + it('updates developer', async () => { + await act(async () => { + await useDeveloperPortalStore.getState().fetchDeveloper('dev-1'); + }); + + await act(async () => { + await useDeveloperPortalStore.getState().updateDeveloper({ name: 'Updated' }); + }); + + const state = useDeveloperPortalStore.getState(); + expect(state.developer?.name).toBe('Updated'); + }); + + it('creates an api key', async () => { + await act(async () => { + await useDeveloperPortalStore.getState().createApiKey('dev-1', 'New Key', ['read']); + }); + + const state = useDeveloperPortalStore.getState(); + expect(state.apiKeys).toHaveLength(1); + expect(state.apiKeys[0].name).toBe('New Key'); + }); + + it('revokes an api key', async () => { + await act(async () => { + await useDeveloperPortalStore.getState().createApiKey('dev-1', 'Key'); + await useDeveloperPortalStore.getState().revokeApiKey('key-1'); + }); + + const state = useDeveloperPortalStore.getState(); + expect(state.apiKeys[0].status).toBe('revoked'); + }); + + it('deletes an api key', async () => { + await act(async () => { + await useDeveloperPortalStore.getState().createApiKey('dev-1', 'Key'); + await useDeveloperPortalStore.getState().deleteApiKey('key-1'); + }); + + const state = useDeveloperPortalStore.getState(); + expect(state.apiKeys).toHaveLength(0); + }); + + it('fetches usage stats', async () => { + await act(async () => { + await useDeveloperPortalStore.getState().fetchUsageStats('dev-1', { + start: new Date(), + end: new Date(), + }); + }); + + const state = useDeveloperPortalStore.getState(); + expect(state.usageStats?.totalRequests).toBe(10); + }); + + it('fetches documentation', () => { + useDeveloperPortalStore.getState().fetchDocumentation(); + const state = useDeveloperPortalStore.getState(); + expect(state.documentation).toHaveLength(1); + }); + + it('searches documentation', () => { + useDeveloperPortalStore.getState().searchDocumentation('query'); + const state = useDeveloperPortalStore.getState(); + expect(state.documentation).toEqual([]); + }); + + it('clears error', async () => { + useDeveloperPortalStore.setState({ + error: { + id: 'err-1', + type: 'unknown', + severity: 'medium', + message: 'test', + userMessage: 'test', + recoverySuggestions: [], + context: { action: 'test' }, + isHandled: false, + } as any, + }); + + useDeveloperPortalStore.getState().clearError(); + const state = useDeveloperPortalStore.getState(); + expect(state.error).toBeNull(); + }); +});