diff --git a/backend/src/controllers/stream.controller.ts b/backend/src/controllers/stream.controller.ts index 5476fb6..1dff023 100644 --- a/backend/src/controllers/stream.controller.ts +++ b/backend/src/controllers/stream.controller.ts @@ -67,9 +67,26 @@ function sumStringI128(values: string[]): string { export const createStream = async (req: Request, res: Response) => { try { const { streamId, sender, recipient, tokenAddress, ratePerSecond, depositedAmount, startTime } = req.body; + const callerAddress = (req as AuthenticatedRequest).user?.publicKey; - const parsedStreamId = Number.parseInt(streamId, 10); - const parsedStartTime = Number.parseInt(startTime, 10); + if (callerAddress && callerAddress !== sender) { + return res.status(403).json({ + error: 'Forbidden', + message: 'Only the stream sender can create or reactivate the stream' + }); + } + + if ( + streamId == null + || startTime == null + || ratePerSecond == null + || depositedAmount == null + ) { + return res.status(400).json({ error: 'Missing required numeric fields in request body' }); + } + + const parsedStreamId = Number.parseInt(String(streamId), 10); + const parsedStartTime = Number.parseInt(String(startTime), 10); const parsedRatePerSecond = BigInt(ratePerSecond); const parsedDepositedAmount = BigInt(depositedAmount); @@ -113,8 +130,8 @@ export const createStream = async (req: Request, res: Response) => { return res.status(201).json(stream); } catch (error) { - if (error instanceof RangeError) { - logger.error('Range error in createStream:', error); + if (error instanceof RangeError || error instanceof SyntaxError || error instanceof TypeError) { + logger.error('Numeric parsing error in createStream:', error); return res.status(400).json({ error: 'Invalid numeric values in request body' }); } logger.error('Error creating/upserting stream:', error); diff --git a/backend/tests/stream.controller.test.ts b/backend/tests/stream.controller.test.ts index ef37fe1..48b5ac6 100644 --- a/backend/tests/stream.controller.test.ts +++ b/backend/tests/stream.controller.test.ts @@ -61,6 +61,9 @@ describe('Stream Controller', () => { }, query: {}, params: {}, + user: { + publicKey: 'GSENDER', + }, }; res = { status: vi.fn().mockReturnThis(), @@ -89,6 +92,25 @@ describe('Stream Controller', () => { await createStream(req as Request, res as Response); expect(res.status).toHaveBeenCalledWith(400); }); + + it('should return 400 for malformed numeric fields', async () => { + req.body.ratePerSecond = 'abc'; + await createStream(req as Request, res as Response); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('should return 400 when a required numeric field is missing', async () => { + delete req.body.depositedAmount; + await createStream(req as Request, res as Response); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('should return 403 when caller does not match sender on upsert', async () => { + (req as any).user = { publicKey: 'GNOTSENDER' }; + await createStream(req as Request, res as Response); + expect(res.status).toHaveBeenCalledWith(403); + expect(prisma.stream.upsert).not.toHaveBeenCalled(); + }); }); describe('listStreams', () => { diff --git a/backend/tests/stream.test.ts b/backend/tests/stream.test.ts index f5d9903..cad05ac 100644 --- a/backend/tests/stream.test.ts +++ b/backend/tests/stream.test.ts @@ -59,7 +59,7 @@ describe('POST /v1/streams', () => { const mockStream = { id: 'uuid-123', streamId: 1, - sender: 'GABC123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA', + sender: 'GTEST_USER_PUBLIC_KEY', recipient: 'GDEF456ABC789GHI012JKL345MNO678PQR901STU234VWX567YZA123BCD', tokenAddress: 'CBCD789EFG012HIJ345KLM678NOP901QRS234TUV567WXY890ZAB123CDE', ratePerSecond: '100', @@ -80,7 +80,7 @@ describe('POST /v1/streams', () => { const validData = { streamId: '1', - sender: 'GABC123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA', + sender: 'GTEST_USER_PUBLIC_KEY', recipient: 'GDEF456ABC789GHI012JKL345MNO678PQR901STU234VWX567YZA123BCD', tokenAddress: 'CBCD789EFG012HIJ345KLM678NOP901QRS234TUV567WXY890ZAB123CDE', ratePerSecond: '100', @@ -101,6 +101,68 @@ describe('POST /v1/streams', () => { }); }); + it('should return 400 for malformed numeric fields', async () => { + const invalidData = { + streamId: '2', + sender: 'GTEST_USER_PUBLIC_KEY', + recipient: 'GDEF456ABC789GHI012JKL345MNO678PQR901STU234VWX567YZA123BCD', + tokenAddress: 'CBCD789EFG012HIJ345KLM678NOP901QRS234TUV567WXY890ZAB123CDE', + ratePerSecond: 'not-a-number', + depositedAmount: 'also-not-a-number', + startTime: '1700000000', + }; + + const response = await request(app) + .post('/v1/streams') + .send(invalidData) + .set('Accept', 'application/json'); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error'); + expect(prisma.stream.upsert).not.toHaveBeenCalled(); + }); + + it('should return 400 when a required numeric field is missing', async () => { + const invalidData = { + streamId: '2', + sender: 'GTEST_USER_PUBLIC_KEY', + recipient: 'GDEF456ABC789GHI012JKL345MNO678PQR901STU234VWX567YZA123BCD', + tokenAddress: 'CBCD789EFG012HIJ345KLM678NOP901QRS234TUV567WXY890ZAB123CDE', + ratePerSecond: '100', + startTime: '1700000000', + }; + + const response = await request(app) + .post('/v1/streams') + .send(invalidData) + .set('Accept', 'application/json'); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error'); + expect(prisma.stream.upsert).not.toHaveBeenCalled(); + }); + + it('should return 403 when an authenticated non-owner attempts to reactivate a stream via upsert', async () => { + const validData = { + streamId: '2', + sender: 'GDIFFERENT_SENDER_PUBLIC_KEY', + recipient: 'GDEF456ABC789GHI012JKL345MNO678PQR901STU234VWX567YZA123BCD', + tokenAddress: 'CBCD789EFG012HIJ345KLM678NOP901QRS234TUV567WXY890ZAB123CDE', + ratePerSecond: '100', + depositedAmount: '86400', + startTime: '1700000000', + }; + + const response = await request(app) + .post('/v1/streams') + .send(validData) + .set('Accept', 'application/json'); + + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('error', 'Forbidden'); + expect(prisma.stream.upsert).not.toHaveBeenCalled(); + }); + it('should return 500 when stream creation fails (DB error)', async () => { (prisma.stream.upsert as ReturnType).mockRejectedValue( new Error('DB connection failed') @@ -108,7 +170,7 @@ describe('POST /v1/streams', () => { const validData = { streamId: '2', - sender: 'GABC123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA', + sender: 'GTEST_USER_PUBLIC_KEY', recipient: 'GDEF456ABC789GHI012JKL345MNO678PQR901STU234VWX567YZA123BCD', tokenAddress: 'CBCD789EFG012HIJ345KLM678NOP901QRS234TUV567WXY890ZAB123CDE', ratePerSecond: '100', @@ -174,9 +236,9 @@ describe('GET /v1/users/:address/summary', () => { it('returns accurate outgoing/incoming aggregates and claimable sum', async () => { vi.mocked(prisma.stream.findMany) .mockResolvedValueOnce([ - { - id: '1', - createdAt: new Date(), + { + id: '1', + createdAt: new Date(), updatedAt: new Date(), streamId: 1, sender: 'GSENDER', @@ -184,18 +246,18 @@ describe('GET /v1/users/:address/summary', () => { tokenAddress: 'TOKEN', ratePerSecond: '10', depositedAmount: '100', - withdrawnAmount: '30', + withdrawnAmount: '30', startTime: 1000, lastUpdateTime: 2000, isPaused: false, endTime: null, pausedAt: null, totalPausedDuration: 0, - isActive: true + isActive: true }, - { - id: '2', - createdAt: new Date(), + { + id: '2', + createdAt: new Date(), updatedAt: new Date(), streamId: 2, sender: 'GSENDER2', @@ -203,14 +265,14 @@ describe('GET /v1/users/:address/summary', () => { tokenAddress: 'TOKEN2', ratePerSecond: '20', depositedAmount: '200', - withdrawnAmount: '20', + withdrawnAmount: '20', startTime: 1000, lastUpdateTime: 2000, isPaused: false, endTime: null, pausedAt: null, totalPausedDuration: 0, - isActive: false + isActive: false }, ]) .mockResolvedValueOnce([ @@ -271,7 +333,7 @@ describe('GET /v1/users/:address/summary', () => { it('caches summary results for repeated requests within TTL', async () => { vi.mocked(prisma.stream.findMany) - .mockResolvedValueOnce([{ + .mockResolvedValueOnce([{ id: '5', createdAt: new Date(), updatedAt: new Date(), @@ -288,7 +350,7 @@ describe('GET /v1/users/:address/summary', () => { endTime: null, pausedAt: null, totalPausedDuration: 0, - isActive: true + isActive: true }]) .mockResolvedValueOnce([]);