Skip to content

Commit 1abe705

Browse files
authored
Merge branch 'main' into test/815-stream-post-negative-cases
2 parents 15a5581 + 254b057 commit 1abe705

13 files changed

Lines changed: 369 additions & 33 deletions

File tree

backend/.dockerignore

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
node_modules
22
dist
33
coverage
4+
5+
# Omit secrets and logs
46
.env*
57
*.log
6-
src/generated
8+
9+
# Omit code generated
10+
src/generated

backend/src/controllers/stream.controller.ts

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -100,16 +100,33 @@ function parseRequiredBigIntField(fieldName: string, value: unknown): bigint {
100100
*/
101101
export const createStream = async (req: Request, res: Response) => {
102102
try {
103-
const {
104-
streamId,
105-
sender,
106-
recipient,
107-
tokenAddress,
108-
ratePerSecond,
109-
depositedAmount,
110-
startTime,
111-
} = req.body;
112-
const callerAddress = (req as AuthenticatedRequest).user?.publicKey;
103+
const callerPublicKey = (req as AuthenticatedRequest).user?.publicKey;
104+
if (!callerPublicKey) {
105+
return res.status(401).json({ error: 'Unauthorized', message: 'Authentication required' });
106+
}
107+
108+
const { streamId, sender, recipient, tokenAddress, ratePerSecond, depositedAmount, startTime } = req.body;
109+
110+
// Issue #809: validate identity fields before any DB write.
111+
if (typeof sender !== 'string' || sender.length === 0) {
112+
return res.status(400).json({ error: 'Invalid sender: must be a non-empty string' });
113+
}
114+
if (typeof recipient !== 'string' || recipient.length === 0) {
115+
return res.status(400).json({ error: 'Invalid recipient: must be a non-empty string' });
116+
}
117+
if (typeof tokenAddress !== 'string' || tokenAddress.length === 0) {
118+
return res.status(400).json({ error: 'Invalid tokenAddress: must be a non-empty string' });
119+
}
120+
121+
// Issue #809: the authenticated wallet may only create/modify streams it owns.
122+
// Without this, any logged-in wallet could POST an arbitrary `sender` and have
123+
// it persisted, or flip another owner's cancelled stream back to active.
124+
if (sender !== callerPublicKey) {
125+
return res.status(403).json({
126+
error: 'Forbidden',
127+
message: 'sender must match the authenticated wallet',
128+
});
129+
}
113130

114131
const parsedStreamId = Number.parseInt(streamId, 10);
115132
const parsedStartTime = Number.parseInt(startTime, 10);
@@ -162,9 +179,18 @@ export const createStream = async (req: Request, res: Response) => {
162179
const endTime =
163180
parsedStartTime + Number(parsedDepositedAmount / parsedRatePerSecond);
164181

165-
if (callerAddress && sender && callerAddress !== sender) {
166-
return res.status(403).json({ error: "Forbidden" });
182+
// Issue #809: never let the upsert update branch touch a stream owned by a
183+
// different wallet. The caller is already proven to equal `sender` above, so
184+
// reject any existing row whose sender differs — this blocks reactivating or
185+
// overwriting someone else's (e.g. cancelled) stream.
186+
const existing = await prisma.stream.findUnique({ where: { streamId: parsedStreamId } });
187+
if (existing && existing.sender !== callerPublicKey) {
188+
return res.status(403).json({
189+
error: 'Forbidden',
190+
message: 'Cannot modify a stream owned by another wallet',
191+
});
167192
}
193+
168194
const stream = await prisma.stream.upsert({
169195
where: { streamId: parsedStreamId },
170196
update: {
@@ -699,6 +725,13 @@ export const topUpStreamHandler = async (req: Request, res: Response) => {
699725
.json({ error: "Only the stream sender may top up this stream" });
700726
}
701727

728+
if (!stream.isActive) {
729+
return res.status(409).json({ error: 'Conflict', message: 'Cannot top up an inactive stream' });
730+
}
731+
if (stream.isPaused) {
732+
return res.status(409).json({ error: 'Conflict', message: 'Cannot top up a paused stream' });
733+
}
734+
702735
const txHash = await topUpStream(streamId, amount, callerAddress);
703736

704737
const newDeposited = (BigInt(stream.depositedAmount) + amount).toString();

backend/src/routes/v1/stream.routes.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,8 @@ router.get('/:streamId', getStream);
9191
* type: integer
9292
* default: 50
9393
* minimum: 1
94-
* maximum: 500
95-
* description: "Number of events to return per page (default: 50, max: 500)"
94+
* maximum: 200
95+
* description: "Number of events to return per page (default: 50, max: 200)"
9696
* - in: query
9797
* name: offset
9898
* schema:

backend/tests/integration/stream-lifecycle.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,72 @@ describe("Stream Lifecycle Integration Tests", () => {
557557
});
558558
});
559559

560+
describe("StreamEvent @@unique([transactionHash, eventType]) deduplication", () => {
561+
it("rejects a duplicate (transactionHash, eventType) insert at the database level", async () => {
562+
const streamId = 11;
563+
const txHash = "unique-constraint-tx-hash";
564+
565+
await testPrisma.stream.create({
566+
data: {
567+
streamId,
568+
sender: SENDER,
569+
recipient: RECIPIENT,
570+
tokenAddress: TOKEN,
571+
ratePerSecond: "10",
572+
depositedAmount: "86400",
573+
withdrawnAmount: "0",
574+
startTime: 1700000000,
575+
endTime: 1700000000 + 8640,
576+
lastUpdateTime: 1700000000,
577+
isActive: true,
578+
isPaused: false,
579+
},
580+
});
581+
582+
const eventData = {
583+
streamId,
584+
eventType: "CREATED",
585+
transactionHash: txHash,
586+
ledgerSequence: 12345,
587+
timestamp: 1700000000,
588+
};
589+
590+
await testPrisma.streamEvent.create({ data: eventData });
591+
592+
await expect(
593+
testPrisma.streamEvent.create({
594+
data: {
595+
...eventData,
596+
ledgerSequence: 12346,
597+
timestamp: 1700000001,
598+
},
599+
}),
600+
).rejects.toMatchObject({ code: "P2002" });
601+
602+
const count = await testPrisma.streamEvent.count({
603+
where: { transactionHash: txHash, eventType: "CREATED" },
604+
});
605+
expect(count).toBe(1);
606+
});
607+
608+
it("dedupes worker replay of the same event without creating a second row", async () => {
609+
const streamId = 12;
610+
const txHash = "worker-replay-dedup-tx-hash";
611+
const event = { ...createStreamCreatedEvent(streamId), txHash };
612+
613+
await worker.processEvent(event);
614+
await expect(worker.processEvent(event)).resolves.toBeUndefined();
615+
616+
const count = await testPrisma.streamEvent.count({
617+
where: {
618+
transactionHash: event.txHash,
619+
eventType: "CREATED",
620+
},
621+
});
622+
expect(count).toBe(1);
623+
});
624+
});
625+
560626
describe("SSE client receives broadcast for each stream event", () => {
561627
let eventSource: EventSource;
562628

backend/tests/integration/top-up.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,4 +161,28 @@ describe('POST /v1/streams/:streamId/top-up', () => {
161161
}),
162162
);
163163
});
164+
165+
it('returns 409 when stream is inactive', async () => {
166+
vi.mocked(mockPrisma.stream.findUnique).mockResolvedValue({ ...mockStream, isActive: false } as any);
167+
168+
const res = await request(app)
169+
.post('/v1/streams/42/top-up')
170+
.set('Authorization', 'Bearer dummy')
171+
.send({ amount: '1000' });
172+
173+
expect(res.status).toBe(409);
174+
expect(res.body.message).toMatch(/inactive stream/);
175+
});
176+
177+
it('returns 409 when stream is paused', async () => {
178+
vi.mocked(mockPrisma.stream.findUnique).mockResolvedValue({ ...mockStream, isPaused: true } as any);
179+
180+
const res = await request(app)
181+
.post('/v1/streams/42/top-up')
182+
.set('Authorization', 'Bearer dummy')
183+
.send({ amount: '1000' });
184+
185+
expect(res.status).toBe(409);
186+
expect(res.body.message).toMatch(/paused stream/);
187+
});
164188
});

backend/tests/stream.controller.test.ts

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,17 @@ describe("Stream Controller", () => {
7878
publicKey: "GSENDER",
7979
},
8080
};
81+
// Authenticated caller matches body.sender by default (Issue #809).
82+
(req as any).user = { publicKey: 'GSENDER' };
8183
res = {
8284
status: vi.fn().mockReturnThis(),
8385
json: vi.fn().mockReturnThis(),
8486
};
8587
});
8688

87-
describe("createStream", () => {
88-
it("should create a stream successfully", async () => {
89+
describe('createStream', () => {
90+
it('should create a stream successfully', async () => {
91+
(prisma.stream.findUnique as any).mockResolvedValue(null);
8992
(prisma.stream.upsert as any).mockResolvedValue({ streamId: 123 });
9093

9194
await createStream(req as Request, res as Response);
@@ -94,20 +97,54 @@ describe("Stream Controller", () => {
9497
expect(prisma.stream.upsert).toHaveBeenCalled();
9598
});
9699

97-
it("should return 400 for invalid streamId", async () => {
98-
req.body.streamId = "abc";
100+
it('should return 401 when the request is unauthenticated', async () => {
101+
(req as any).user = undefined;
102+
99103
await createStream(req as Request, res as Response);
100-
expect(res.status).toHaveBeenCalledWith(400);
104+
105+
expect(res.status).toHaveBeenCalledWith(401);
106+
expect(prisma.stream.upsert).not.toHaveBeenCalled();
107+
});
108+
109+
it('should return 403 when the caller is not the body sender (Issue #809)', async () => {
110+
(req as any).user = { publicKey: 'GATTACKER' };
111+
112+
await createStream(req as Request, res as Response);
113+
114+
expect(res.status).toHaveBeenCalledWith(403);
115+
expect(prisma.stream.upsert).not.toHaveBeenCalled();
101116
});
102117

103-
it("should return 400 for non-positive ratePerSecond", async () => {
104-
req.body.ratePerSecond = "0";
118+
it('should reject 400 when sender is missing (Issue #809)', async () => {
119+
delete req.body.sender;
120+
(req as any).user = { publicKey: 'GSENDER' };
121+
105122
await createStream(req as Request, res as Response);
123+
106124
expect(res.status).toHaveBeenCalledWith(400);
125+
expect(prisma.stream.upsert).not.toHaveBeenCalled();
126+
});
127+
128+
it('should return 403 and not reactivate a cancelled stream owned by another wallet (Issue #809)', async () => {
129+
// A victim previously created (and cancelled) this stream.
130+
(prisma.stream.findUnique as any).mockResolvedValue({
131+
streamId: 123,
132+
sender: 'GVICTIM',
133+
isActive: false,
134+
});
135+
// Attacker authenticates as themselves and sets sender to their own key,
136+
// trying to hijack the victim's streamId.
137+
req.body.sender = 'GATTACKER';
138+
(req as any).user = { publicKey: 'GATTACKER' };
139+
140+
await createStream(req as Request, res as Response);
141+
142+
expect(res.status).toHaveBeenCalledWith(403);
143+
expect(prisma.stream.upsert).not.toHaveBeenCalled();
107144
});
108145

109-
it("should return 400 for malformed numeric fields", async () => {
110-
req.body.ratePerSecond = "abc";
146+
it('should return 400 for invalid streamId', async () => {
147+
req.body.streamId = 'abc';
111148
await createStream(req as Request, res as Response);
112149
expect(res.status).toHaveBeenCalledWith(400);
113150
});

backend/tests/stream.repository.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ describe('Stream Repository', () => {
2323
});
2424
});
2525

26+
it('should update isActive to false for COMPLETED', async () => {
27+
await updateStatus(123, 'COMPLETED');
28+
expect(prisma.stream.update).toHaveBeenCalledWith({
29+
where: { streamId: 123 },
30+
data: { isActive: false },
31+
});
32+
});
33+
2634
it('should update isActive to true for ACTIVE', async () => {
2735
await updateStatus(123, 'ACTIVE');
2836
expect(prisma.stream.update).toHaveBeenCalledWith({

contracts/stream_contract/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
name = "stream_contract"
33
version = "0.1.0"
44
edition = "2021"
5+
description = "Soroban payment-streaming contract with protocol fees"
56

67
[lib]
78
crate-type = ["cdylib"]

0 commit comments

Comments
 (0)