diff --git a/services/platform-service/src/server.test.ts b/services/platform-service/src/server.test.ts new file mode 100644 index 00000000..dd30c6db --- /dev/null +++ b/services/platform-service/src/server.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const resolveKeyVaultSecretsMock = vi.fn(async () => undefined); +const createServiceAppMock = vi.fn(); +const startServiceMock = vi.fn(async () => undefined); +const loadProductCacheMock = vi.fn(async () => undefined); +const initCosmosIfNeededMock = vi.fn(async () => undefined); +const verifyTokenMock = vi.fn(async () => ({ sub: 'user_1', productId: 'lysnrai' })); + +const appMock = { + addHook: vi.fn(), + register: vi.fn(async () => undefined), +}; + +vi.mock('@bytelyst/config', () => ({ + LYSNR_SECRETS: { + COSMOS_KEY: 'cosmos-key', + COSMOS_ENDPOINT: 'cosmos-endpoint', + JWT_SECRET: 'jwt-secret', + STRIPE_SECRET_KEY: 'stripe-secret', + STRIPE_WEBHOOK_SECRET: 'stripe-webhook', + AZURE_BLOB_CONNECTION_STRING: 'blob-conn', + AZURE_BLOB_ACCOUNT_KEY: 'blob-key', + }, + resolveKeyVaultSecrets: resolveKeyVaultSecretsMock, +})); + +vi.mock('@bytelyst/fastify-core', () => ({ + createServiceApp: createServiceAppMock, + startService: startServiceMock, +})); + +vi.mock('./modules/products/routes.js', () => ({ productRoutes: vi.fn() })); +vi.mock('./modules/products/cache.js', () => ({ loadProductCache: loadProductCacheMock })); +vi.mock('./modules/auth/routes.js', () => ({ authRoutes: vi.fn() })); +vi.mock('./modules/audit/routes.js', () => ({ auditRoutes: vi.fn() })); +vi.mock('./modules/notifications/routes.js', () => ({ notificationRoutes: vi.fn() })); +vi.mock('./modules/flags/routes.js', () => ({ flagRoutes: vi.fn() })); +vi.mock('./modules/ratelimit/routes.js', () => ({ rateLimitRoutes: vi.fn() })); +vi.mock('./modules/blob/routes.js', () => ({ blobRoutes: vi.fn() })); +vi.mock('./modules/invitations/routes.js', () => ({ invitationRoutes: vi.fn() })); +vi.mock('./modules/referrals/routes.js', () => ({ referralRoutes: vi.fn() })); +vi.mock('./modules/promos/routes.js', () => ({ promoRoutes: vi.fn() })); +vi.mock('./modules/subscriptions/routes.js', () => ({ subscriptionRoutes: vi.fn() })); +vi.mock('./modules/usage/routes.js', () => ({ usageRoutes: vi.fn() })); +vi.mock('./modules/plans/routes.js', () => ({ planRoutes: vi.fn() })); +vi.mock('./modules/licenses/routes.js', () => ({ licenseRoutes: vi.fn() })); +vi.mock('./modules/stripe/routes.js', () => ({ stripeRoutes: vi.fn() })); +vi.mock('./modules/settings/routes.js', () => ({ settingsRoutes: vi.fn() })); +vi.mock('./modules/items/routes.js', () => ({ itemRoutes: vi.fn() })); +vi.mock('./modules/comments/routes.js', () => ({ commentRoutes: vi.fn() })); +vi.mock('./modules/votes/routes.js', () => ({ voteRoutes: vi.fn() })); +vi.mock('./modules/memory/routes.js', () => ({ memoryRoutes: vi.fn() })); +vi.mock('./modules/public/routes.js', () => ({ publicRoutes: vi.fn() })); +vi.mock('./modules/tokens/routes.js', () => ({ tokenRoutes: vi.fn() })); +vi.mock('./modules/themes/routes.js', () => ({ themeRoutes: vi.fn() })); +vi.mock('./lib/cosmos-init.js', () => ({ initCosmosIfNeeded: initCosmosIfNeededMock })); +vi.mock('./lib/config.js', () => ({ config: { CORS_ORIGIN: '*', PORT: 4003, HOST: '0.0.0.0' } })); +vi.mock('./modules/auth/jwt.js', () => ({ verifyToken: verifyTokenMock })); + +describe('server bootstrap', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + createServiceAppMock.mockResolvedValue(appMock); + appMock.addHook.mockReset(); + appMock.register.mockReset(); + appMock.register.mockResolvedValue(undefined); + }); + + it('initializes secrets, app, routes, and starts service', async () => { + await import('./server.js'); + + expect(resolveKeyVaultSecretsMock).toHaveBeenCalledOnce(); + expect(initCosmosIfNeededMock).toHaveBeenCalledOnce(); + expect(loadProductCacheMock).toHaveBeenCalledOnce(); + expect(createServiceAppMock).toHaveBeenCalledOnce(); + expect(appMock.register).toHaveBeenCalled(); + expect(appMock.register.mock.calls.length).toBeGreaterThan(15); + expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4003, host: '0.0.0.0' }); + }); + + it('onRequest hook sets jwtPayload when bearer token is valid', async () => { + await import('./server.js'); + + const onRequestHook = appMock.addHook.mock.calls.find(([name]) => name === 'onRequest')?.[1]; + expect(onRequestHook).toBeTypeOf('function'); + + const req = { headers: { authorization: 'Bearer token_abc' } } as { + headers: { authorization: string }; + jwtPayload?: unknown; + }; + await onRequestHook(req); + + expect(verifyTokenMock).toHaveBeenCalledWith('token_abc'); + expect(req.jwtPayload).toEqual({ sub: 'user_1', productId: 'lysnrai' }); + }); + + it('onRequest hook ignores invalid bearer token errors', async () => { + verifyTokenMock.mockRejectedValueOnce(new Error('invalid token')); + await import('./server.js'); + + const onRequestHook = appMock.addHook.mock.calls.find(([name]) => name === 'onRequest')?.[1]; + const req = { headers: { authorization: 'Bearer broken' } } as { + headers: { authorization: string }; + jwtPayload?: unknown; + }; + + await expect(onRequestHook(req)).resolves.toBeUndefined(); + expect(req.jwtPayload).toBeUndefined(); + }); +});