diff --git a/backend/src/config/swagger.ts b/backend/src/config/swagger.ts index 6c699c07..fa2ac827 100644 --- a/backend/src/config/swagger.ts +++ b/backend/src/config/swagger.ts @@ -76,6 +76,18 @@ See [Sandbox Mode Documentation](../docs/SANDBOX_MODE.md) for details.`, scheme: 'bearer', bearerFormat: 'JWT', description: 'JSON Web Token issued by /v1/auth/verify after completing the SEP-10 challenge flow.' + }, + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: 'Alias for BearerAuth — used by route-level security annotations.' + }, + adminAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: 'Admin JWT — the token subject must match ADMIN_PUBLIC_KEY.' } }, schemas: { @@ -165,6 +177,28 @@ See [Sandbox Mode Documentation](../docs/SANDBOX_MODE.md) for details.`, description: 'Stream active status', example: true, }, + isPaused: { + type: 'boolean', + description: 'Whether the stream is currently paused', + example: false, + }, + pausedAt: { + type: 'integer', + nullable: true, + description: 'Ledger timestamp when the stream was last paused (Unix), null if not paused', + example: null, + }, + totalPausedDuration: { + type: 'integer', + description: 'Cumulative seconds the stream has spent paused', + example: 0, + }, + endTime: { + type: 'integer', + nullable: true, + description: 'Ledger timestamp when the stream ended (Unix), null if still active', + example: null, + }, createdAt: { type: 'string', format: 'date-time', @@ -189,7 +223,7 @@ See [Sandbox Mode Documentation](../docs/SANDBOX_MODE.md) for details.`, }, eventType: { type: 'string', - enum: ['CREATED', 'TOPPED_UP', 'WITHDRAWN', 'CANCELLED', 'COMPLETED'], + enum: ['CREATED', 'TOPPED_UP', 'WITHDRAWN', 'CANCELLED', 'COMPLETED', 'PAUSED', 'RESUMED', 'FEE_COLLECTED'], description: 'Type of stream event', example: 'TOPPED_UP', }, diff --git a/backend/src/routes/v1/stream.routes.ts b/backend/src/routes/v1/stream.routes.ts index 55255560..bfb7dd72 100644 --- a/backend/src/routes/v1/stream.routes.ts +++ b/backend/src/routes/v1/stream.routes.ts @@ -263,13 +263,56 @@ router.post('/:streamId/withdraw', requireAuth, withdrawHandler as any); /** * @openapi - * /v1/streams/{streamId}/cancel: + * /v1/streams/{streamId}/top-up: * post: * tags: * - Streams - * summary: Cancel an active payment stream + * summary: Top up a payment stream + * description: Adds additional funds to an existing active stream. Only the original sender can top up. + * parameters: + * - in: path + * name: streamId + * required: true + * schema: + * type: integer + * description: On-chain stream ID * security: * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - amount + * properties: + * amount: + * type: string + * description: Amount to add to the stream deposit (i128 as string) + * example: '5000' + * responses: + * 200: + * description: Stream topped up successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * txHash: + * type: string + * streamId: + * type: integer + * newDepositedAmount: + * type: string + * 400: + * description: Invalid request — amount missing or not a positive integer string + * 401: + * description: Unauthorized - missing or invalid authentication token + * 403: + * description: Forbidden - caller is not the stream sender + * 404: + * description: Stream not found */ router.post('/:streamId/top-up', requireAuth, topUpStreamHandler); router.post('/:streamId/cancel', requireAuth, cancelStreamHandler as any); diff --git a/backend/tests/integration/streams.test.ts b/backend/tests/integration/streams.test.ts index 153a8355..0c8e35da 100644 --- a/backend/tests/integration/streams.test.ts +++ b/backend/tests/integration/streams.test.ts @@ -11,16 +11,16 @@ import request from 'supertest'; // Bypass Stellar signature verification on POST /v1/streams. The route is // exercised here as a stand-in for the indexer worker, so we replace the auth // middleware with a stub that injects a deterministic wallet. -vi.mock('../../src/middleware/auth.js', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - requireAuth: (req: any, _res: any, next: any) => { - req.user = { publicKey: 'GTEST_USER_PUBLIC_KEY' }; - next(); - }, - }; -}); +// Simple factory — no importOriginal — reliable with pool:forks. +vi.mock('../../src/middleware/auth.js', () => ({ + requireAuth: (req: any, _res: any, next: any) => { + req.user = { publicKey: 'GTEST_USER_PUBLIC_KEY' }; + next(); + }, + requireAdmin: (_req: any, res: any, _next: any) => { + res.status(403).json({ error: 'Forbidden' }); + }, +})); // ─── Mocks (using vi.hoisted to ensure they are available to vi.mock) ───────── diff --git a/backend/tests/integration/streams/cancel.test.ts b/backend/tests/integration/streams/cancel.test.ts index 5ee167aa..15f62303 100644 --- a/backend/tests/integration/streams/cancel.test.ts +++ b/backend/tests/integration/streams/cancel.test.ts @@ -37,17 +37,17 @@ vi.mock('../../../src/lib/prisma.js', () => { }; }); -// Mock auth middleware to bypass real Stellar signature verification -vi.mock('../../../src/middleware/auth.js', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - requireAuth: (req: any, _res: any, next: any) => { - req.user = { publicKey: 'G_SENDER_123' }; - next(); - }, - }; -}); +// Mock auth middleware to bypass real Stellar signature verification. +// Uses a simple factory (no importOriginal) so it is reliable with pool:forks. +vi.mock('../../../src/middleware/auth.js', () => ({ + requireAuth: (req: any, _res: any, next: any) => { + req.user = { publicKey: 'G_SENDER_123' }; + next(); + }, + requireAdmin: (_req: any, res: any, _next: any) => { + res.status(403).json({ error: 'Forbidden' }); + }, +})); // ─── App import (after mocks) ─────────────────────────────────────────────── diff --git a/backend/tests/integration/streams/withdraw.test.ts b/backend/tests/integration/streams/withdraw.test.ts index ef97c771..fa67471e 100644 --- a/backend/tests/integration/streams/withdraw.test.ts +++ b/backend/tests/integration/streams/withdraw.test.ts @@ -32,16 +32,16 @@ vi.mock('../../../src/services/sorobanService.js', () => ({ isStale: vi.fn().mockReturnValue(false), })); -vi.mock('../../../src/middleware/auth.js', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - requireAuth: (req: any, _res: any, next: any) => { - req.user = { publicKey: currentUser.publicKey }; - next(); - }, - }; -}); +// Simple factory — no importOriginal — reliable with pool:forks. +vi.mock('../../../src/middleware/auth.js', () => ({ + requireAuth: (req: any, _res: any, next: any) => { + req.user = { publicKey: currentUser.publicKey }; + next(); + }, + requireAdmin: (_req: any, res: any, _next: any) => { + res.status(403).json({ error: 'Forbidden' }); + }, +})); import app from '../../../src/app.js'; diff --git a/backend/tests/integration/top-up.test.ts b/backend/tests/integration/top-up.test.ts index 4870b41a..e58260ed 100644 --- a/backend/tests/integration/top-up.test.ts +++ b/backend/tests/integration/top-up.test.ts @@ -58,16 +58,16 @@ vi.mock('../../src/services/sorobanService.js', () => ({ cancelStream: vi.fn(), })); -vi.mock('../../src/middleware/auth.js', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - requireAuth: vi.fn((req: any, _res: any, next: any) => { - req.user = { publicKey: req.headers['x-test-caller'] ?? SENDER }; - next(); - }), - }; -}); +// Simple factory — no importOriginal — reliable with pool:forks. +vi.mock('../../src/middleware/auth.js', () => ({ + requireAuth: vi.fn((req: any, _res: any, next: any) => { + req.user = { publicKey: req.headers['x-test-caller'] ?? SENDER }; + next(); + }), + requireAdmin: vi.fn((_req: any, res: any, _next: any) => { + res.status(403).json({ error: 'Forbidden' }); + }), +})); // App import after mocks import app from '../../src/app.js'; diff --git a/backend/tests/stream.test.ts b/backend/tests/stream.test.ts index 479ff79a..f0e3bb1c 100644 --- a/backend/tests/stream.test.ts +++ b/backend/tests/stream.test.ts @@ -1,16 +1,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import request from 'supertest'; -vi.mock('../src/middleware/auth.js', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - requireAuth: (req: any, _res: any, next: any) => { - req.user = { publicKey: 'GTEST_USER_PUBLIC_KEY' }; - next(); - }, - }; -}); +// Simple factory — no importOriginal — reliable with pool:forks. +vi.mock('../src/middleware/auth.js', () => ({ + requireAuth: (req: any, _res: any, next: any) => { + req.user = { publicKey: 'GTEST_USER_PUBLIC_KEY' }; + next(); + }, + requireAdmin: (_req: any, res: any, _next: any) => { + res.status(403).json({ error: 'Forbidden' }); + }, +})); vi.mock('../src/middleware/stream-rate-limiter.middleware.js', () => ({ streamCreationRateLimiter: (_req: any, _res: any, next: any) => next(), diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index 7444e89c..59926d3b 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -5,6 +5,12 @@ export default defineConfig({ environment: 'node', globals: true, setupFiles: [], + // Provide a stable JWT_SECRET so verifyJwt is deterministic in tests. + // The integration test mocks replace requireAuth entirely, but a known + // secret means the real middleware also works if a mock is not applied. + env: { + JWT_SECRET: 'flowfi-test-secret-do-not-use-in-production', + }, include: ['tests/**/*.{test,spec}.ts', 'src/__tests__/**/*.{test,spec}.ts'], coverage: { enabled: true,