Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions backend/src/controllers/stream.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand Down
22 changes: 22 additions & 0 deletions backend/tests/stream.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ describe('Stream Controller', () => {
},
query: {},
params: {},
user: {
publicKey: 'GSENDER',
},
};
res = {
status: vi.fn().mockReturnThis(),
Expand Down Expand Up @@ -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', () => {
Expand Down
92 changes: 77 additions & 15 deletions backend/tests/stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -101,14 +101,76 @@ 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<typeof vi.fn>).mockRejectedValue(
new Error('DB connection failed')
);

const validData = {
streamId: '2',
sender: 'GABC123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA',
sender: 'GTEST_USER_PUBLIC_KEY',
recipient: 'GDEF456ABC789GHI012JKL345MNO678PQR901STU234VWX567YZA123BCD',
tokenAddress: 'CBCD789EFG012HIJ345KLM678NOP901QRS234TUV567WXY890ZAB123CDE',
ratePerSecond: '100',
Expand Down Expand Up @@ -174,43 +236,43 @@ 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',
recipient: 'GRECIPIENT',
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',
recipient: 'GRECIPIENT2',
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([
Expand Down Expand Up @@ -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(),
Expand All @@ -288,7 +350,7 @@ describe('GET /v1/users/:address/summary', () => {
endTime: null,
pausedAt: null,
totalPausedDuration: 0,
isActive: true
isActive: true
}])
.mockResolvedValueOnce([]);

Expand Down
Loading