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
48 changes: 45 additions & 3 deletions backend/src/controllers/stream.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,35 @@ function sumStringI128(values: string[]): string {
return total.toString();
}

/**
* Thrown when a request body field fails presence/format validation. Kept
* distinct from generic errors so createStream can reliably map it to a 400
* response instead of falling through to the catch-all 500.
*/
class StreamValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'StreamValidationError';
}
}

/**
* Validate presence and integer format of a required i128-style field, then
* coerce it to a BigInt. Any missing value or conversion failure (SyntaxError
* from a non-numeric string, TypeError from undefined/null/objects, etc.) is
* normalized into a StreamValidationError so the caller can map it to 400.
*/
function parseRequiredBigIntField(fieldName: string, value: unknown): bigint {
if (value === undefined || value === null || value === '') {
throw new StreamValidationError(`Missing required field: ${fieldName}`);
}
try {
return BigInt(value as bigint | number | string | boolean);
} catch {
throw new StreamValidationError(`Invalid ${fieldName}: must be a valid integer`);
}
}

/**
* Create a new stream (stub for on-chain indexing)
*/
Expand All @@ -70,8 +99,6 @@ export const createStream = async (req: Request, res: Response) => {

const parsedStreamId = Number.parseInt(streamId, 10);
const parsedStartTime = Number.parseInt(startTime, 10);
const parsedRatePerSecond = BigInt(ratePerSecond);
const parsedDepositedAmount = BigInt(depositedAmount);

if (!Number.isFinite(parsedStreamId)) {
return res.status(400).json({ error: 'Invalid streamId: must be a valid integer' });
Expand All @@ -81,6 +108,21 @@ export const createStream = async (req: Request, res: Response) => {
return res.status(400).json({ error: 'Invalid startTime: must be a non-negative integer' });
}

// Presence/format validation happens here, before any BigInt coercion,
// so a malformed or missing numeric field always yields 400 rather than
// an uncaught SyntaxError/TypeError falling through to 500.
let parsedRatePerSecond: bigint;
let parsedDepositedAmount: bigint;
try {
parsedRatePerSecond = parseRequiredBigIntField('ratePerSecond', ratePerSecond);
parsedDepositedAmount = parseRequiredBigIntField('depositedAmount', depositedAmount);
} catch (validationError) {
if (validationError instanceof StreamValidationError) {
return res.status(400).json({ error: validationError.message });
}
throw validationError;
}

if (parsedRatePerSecond <= 0n) {
return res.status(400).json({ error: 'Invalid ratePerSecond: must be greater than zero' });
}
Expand Down Expand Up @@ -774,4 +816,4 @@ export const resumeStream = async (req: Request, res: Response) => {
logger.error('Error resuming stream:', error);
return res.status(500).json({ error: 'Internal server error' });
}
};
};
40 changes: 40 additions & 0 deletions backend/tests/stream.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,46 @@ describe('Stream Controller', () => {
await createStream(req as Request, res as Response);
expect(res.status).toHaveBeenCalledWith(400);
});

it('should return 400 with a validation error for non-numeric ratePerSecond', async () => {
req.body.ratePerSecond = 'abc';
await createStream(req as Request, res as Response);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.status).not.toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.stringContaining('ratePerSecond') })
);
});

it('should return 400 with a validation error for non-numeric depositedAmount', async () => {
req.body.depositedAmount = 'xyz';
await createStream(req as Request, res as Response);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.status).not.toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.stringContaining('depositedAmount') })
);
});

it('should return 400, not 500, when ratePerSecond is missing', async () => {
delete req.body.ratePerSecond;
await createStream(req as Request, res as Response);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.status).not.toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.stringContaining('ratePerSecond') })
);
});

it('should return 400, not 500, when depositedAmount is missing', async () => {
delete req.body.depositedAmount;
await createStream(req as Request, res as Response);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.status).not.toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.stringContaining('depositedAmount') })
);
});
});

describe('listStreams', () => {
Expand Down
Loading