File size: 15,915 Bytes
2b6ef22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62aa98f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2b6ef22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62aa98f
2b6ef22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
from __future__ import annotations

import asyncio
import hashlib
import hmac
import json
import os
import sys
import time
from typing import AsyncGenerator

import pytest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

from app.services.webhook_socket_service import ChannelManager, sign_payload, verify_signature

API_KEY = "changeme"
AUTH_HEADER = {"Authorization": f"Bearer {API_KEY}"}
BASE = "http://localhost:7860/api/v1"


class TestChannelManager:
    def test_create_channel(self):
        mgr = ChannelManager()
        ch = mgr.create_channel()
        assert ch.channel_id is not None
        assert len(ch.channel_id) == 16
        assert ch.buffer_size == 0
        assert ch.secret is None

    def test_create_channel_custom_id(self):
        mgr = ChannelManager()
        ch = mgr.create_channel(channel_id="my-channel")
        assert ch.channel_id == "my-channel"

    def test_create_channel_with_secret_and_buffer(self):
        mgr = ChannelManager()
        ch = mgr.create_channel(channel_id="secure", secret="s3cret", buffer_size=10)
        assert ch.secret == "s3cret"
        assert ch.buffer_size == 10

    def test_get_channel(self):
        mgr = ChannelManager()
        mgr.create_channel(channel_id="abc")
        assert mgr.get_channel("abc") is not None
        assert mgr.get_channel("nonexistent") is None

    def test_delete_channel(self):
        mgr = ChannelManager()
        mgr.create_channel(channel_id="del-me")
        assert mgr.delete_channel("del-me") is True
        assert mgr.get_channel("del-me") is None
        assert mgr.delete_channel("del-me") is False

    def test_create_duplicate_id(self):
        mgr = ChannelManager()
        mgr.create_channel(channel_id="dup")
        ch2 = mgr.create_channel(channel_id="dup")
        assert mgr.get_channel("dup") is ch2

    def test_default_buffer_from_manager(self):
        mgr = ChannelManager(default_buffer=25)
        ch = mgr.create_channel()
        assert ch.buffer_size == 25

    def test_publish_nonexistent_channel(self):
        mgr = ChannelManager()
        result = asyncio.run(mgr.publish("no-such-channel", {"hello": "world"}))
        assert result == -1

    @pytest.mark.asyncio
    async def test_publish_and_buffer(self):
        mgr = ChannelManager()
        ch = mgr.create_channel(channel_id="buf-test", buffer_size=3)

        await mgr.publish("buf-test", {"n": 1})
        await mgr.publish("buf-test", {"n": 2})
        await mgr.publish("buf-test", {"n": 3})
        await mgr.publish("buf-test", {"n": 4})

        assert len(ch.history) == 3
        assert ch.history[0]["payload"]["n"] == 2
        assert ch.history[2]["payload"]["n"] == 4
        assert ch.message_count == 4

    def test_stats(self):
        mgr = ChannelManager()
        mgr.create_channel(channel_id="a")
        mgr.create_channel(channel_id="b")
        stats = mgr.stats()
        assert stats["channels"] == 2
        assert stats["total_subscribers"] == 0

    def test_sign_and_verify(self):
        secret = "my-secret"
        body = b'{"hello":"world"}'
        sig = sign_payload(secret, body)
        assert sig.startswith("sha256=")
        assert verify_signature(secret, body, sig) is True
        assert verify_signature(secret, body, "sha256=bad") is False
        assert verify_signature("wrong-secret", body, sig) is False

    def test_sign_constant_result(self):
        secret = "test"
        body = b"data"
        sig1 = sign_payload(secret, body)
        sig2 = sign_payload(secret, body)
        assert sig1 == sig2

    def test_channel_info_fields(self):
        mgr = ChannelManager()
        mgr.create_channel(channel_id="info-test", buffer_size=5)
        ch = mgr.get_channel("info-test")
        assert ch.channel_id == "info-test"
        assert ch.buffer_size == 5
        assert ch.message_count == 0
        assert ch.created_at > 0
        assert ch.last_activity > 0

    def test_publish_with_subscriber(self):
        mgr = ChannelManager()
        mgr.create_channel(channel_id="sub-test")
        ch = mgr.get_channel("sub-test")

        async def dummy():
            return ch.channel_id
        q = asyncio.Queue()
        ch.subscribers[dummy] = q

        result = asyncio.run(mgr.publish("sub-test", {"msg": "hello"}))
        assert result == 1
        assert ch.message_count == 1

    def test_multiple_publishes(self):
        mgr = ChannelManager()
        ch = mgr.create_channel(channel_id="multi-pub", buffer_size=10)
        for i in range(5):
            asyncio.run(mgr.publish("multi-pub", {"n": i}))
        assert ch.message_count == 5
        assert len(ch.history) == 5

    def test_channel_manager_defaults(self):
        mgr = ChannelManager()
        assert mgr.default_buffer == 0
        assert mgr.channels == {}


pytestmark_integration = pytest.mark.skipif(
    not os.environ.get("RUN_INTEGRATION_TESTS"),
    reason="Set RUN_INTEGRATION_TESTS=1 to run integration tests",
)


def _sign(body: bytes, secret: str) -> str:
    return "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()


@pytest.mark.skipif(
    not os.environ.get("RUN_INTEGRATION_TESTS"),
    reason="Set RUN_INTEGRATION_TESTS=1 to run integration tests",
)
class TestWebhookSocketIntegration:
    @pytest.fixture(autouse=True)
    async def _setup(self):
        import httpx

        async with httpx.AsyncClient(base_url=BASE) as client:
            self.client = client
            yield

    async def _create_channel(self, **kwargs) -> dict:
        resp = await self.client.post("/channels", json=kwargs, headers=AUTH_HEADER)
        assert resp.status_code == 201
        return resp.json()

    async def _delete_channel(self, channel_id: str):
        resp = await self.client.delete(f"/channels/{channel_id}", headers=AUTH_HEADER)
        return resp.status_code == 200

    async def test_health(self):
        resp = await self.client.get("/health")
        assert resp.status_code == 200
        data = resp.json()
        assert data["success"] is True

    async def test_create_and_list_channels(self):
        ch = await self._create_channel(channel_id="test-list", buffer_size=5)
        assert ch["channel_id"] == "test-list"

        resp = await self.client.get("/channels", headers=AUTH_HEADER)
        assert resp.status_code == 200
        data = resp.json()
        assert any(c["channel_id"] == "test-list" for c in data["channels"])

        await self._delete_channel("test-list")

    async def test_create_channel_no_auth(self):
        resp = await self.client.post("/channels", json={})
        assert resp.status_code in (401, 403)

    async def test_create_duplicate_channel(self):
        await self._create_channel(channel_id="dup-test")
        resp = await self.client.post("/channels", json={"channel_id": "dup-test"}, headers=AUTH_HEADER)
        assert resp.status_code == 409
        await self._delete_channel("dup-test")

    async def test_channel_info(self):
        await self._create_channel(channel_id="info-test")
        resp = await self.client.get("/channels/info-test", headers=AUTH_HEADER)
        assert resp.status_code == 200
        data = resp.json()
        assert data["channel_id"] == "info-test"
        assert data["subscribers"] == 0
        assert data["messages"] == 0
        await self._delete_channel("info-test")

    async def test_channel_info_not_found(self):
        resp = await self.client.get("/channels/no-such", headers=AUTH_HEADER)
        assert resp.status_code == 404

    async def test_delete_channel(self):
        await self._create_channel(channel_id="delete-me")
        resp = await self.client.delete("/channels/delete-me", headers=AUTH_HEADER)
        assert resp.status_code == 200
        assert resp.json()["deleted"] == "delete-me"

        resp = await self.client.get("/channels/delete-me", headers=AUTH_HEADER)
        assert resp.status_code == 404

    async def test_delete_channel_not_found(self):
        resp = await self.client.delete("/channels/no-such", headers=AUTH_HEADER)
        assert resp.status_code == 404

    async def test_webhook_delivers_to_ws(self):
        ch = await self._create_channel(channel_id="ws-deliver", buffer_size=5)
        cid = ch["channel_id"]

        import websockets

        ws_url = f"ws://localhost:7860/api/v1/ws/{cid}"

        async with websockets.connect(ws_url) as ws:
            connected = json.loads(await ws.recv())
            assert connected["event"] == "connected"
            assert connected["channel"] == cid

            payload = {"msg": "hello from webhook", "num": 42}
            resp = await self.client.post(f"/webhook/{cid}", json=payload)
            assert resp.status_code == 200
            wh_data = resp.json()
            assert wh_data["status"] == "delivered"
            assert wh_data["subscribers_notified"] == 1

            received = json.loads(await ws.recv())
            assert received["event"] == "message"
            assert received["channel"] == cid
            assert received["payload"] == payload

        await self._delete_channel(cid)

    async def test_webhook_no_subscribers(self):
        ch = await self._create_channel(channel_id="no-subs")
        resp = await self.client.post(f"/webhook/{ch['channel_id']}", json={"data": 1})
        assert resp.status_code == 200
        assert resp.json()["subscribers_notified"] == 0
        await self._delete_channel(ch["channel_id"])

    async def test_webhook_not_found(self):
        resp = await self.client.post("/webhook/no-such", json={"x": 1})
        assert resp.status_code == 404

    async def test_hook_alias(self):
        ch = await self._create_channel(channel_id="hook-alias")
        resp = await self.client.post(f"/hook/{ch['channel_id']}", json={"test": True})
        assert resp.status_code == 200
        assert resp.json()["status"] == "delivered"
        await self._delete_channel(ch["channel_id"])

    async def test_hmac_signed_webhook(self):
        secret = "hmac-test-secret"
        ch = await self._create_channel(channel_id="hmac-test", secret=secret)
        cid = ch["channel_id"]

        payload = b'{"signed": "data"}'
        sig = _sign(payload, secret)

        resp = await self.client.post(
            f"/webhook/{cid}",
            content=payload,
            headers={"Content-Type": "application/json", "X-Signature-256": sig},
        )
        assert resp.status_code == 200, resp.text

        resp_bad = await self.client.post(
            f"/webhook/{cid}",
            content=payload,
            headers={"Content-Type": "application/json", "X-Signature-256": "sha256=bad"},
        )
        assert resp_bad.status_code == 401

        resp_no_sig = await self.client.post(
            f"/webhook/{cid}",
            content=payload,
            headers={"Content-Type": "application/json"},
        )
        assert resp_no_sig.status_code == 401

        await self._delete_channel(cid)

    async def test_webhook_form_urlencoded(self):
        ch = await self._create_channel(channel_id="form-test")
        cid = ch["channel_id"]

        resp = await self.client.post(
            f"/webhook/{cid}",
            data={"field1": "value1", "field2": "value2"},
            headers={"Content-Type": "application/x-www-form-urlencoded"},
        )
        assert resp.status_code == 200
        assert resp.json()["status"] == "delivered"

        await self._delete_channel(cid)

    async def test_webhook_raw_text(self):
        ch = await self._create_channel(channel_id="raw-test")
        cid = ch["channel_id"]

        resp = await self.client.post(
            f"/webhook/{cid}",
            content="just some raw text",
            headers={"Content-Type": "text/plain"},
        )
        assert resp.status_code == 200

        await self._delete_channel(cid)

    async def test_ws_replay_buffer(self):
        ch = await self._create_channel(channel_id="replay-test", buffer_size=3)
        cid = ch["channel_id"]

        await self.client.post(f"/webhook/{cid}", json={"n": 1})
        await self.client.post(f"/webhook/{cid}", json={"n": 2})
        await self.client.post(f"/webhook/{cid}", json={"n": 3})

        import websockets

        ws_url = f"ws://localhost:7860/api/v1/ws/{cid}"
        async with websockets.connect(ws_url) as ws:
            connected = json.loads(await ws.recv())
            assert connected["event"] == "connected"
            assert connected["buffered"] == 3

            for expected_n in [1, 2, 3]:
                msg = json.loads(await ws.recv())
                assert msg["payload"]["n"] == expected_n

        await self._delete_channel(cid)

    async def test_ws_auth_with_secret(self):
        ch = await self._create_channel(channel_id="ws-auth-test", secret="topsecret")
        cid = ch["channel_id"]

        import websockets

        ws_url = f"ws://localhost:7860/api/v1/ws/{cid}"

        async with websockets.connect(f"{ws_url}?secret=topsecret") as ws:
            msg = json.loads(await ws.recv())
            assert msg["event"] == "connected"

        async with websockets.connect(ws_url) as ws:
            msg = json.loads(await ws.recv())
            assert msg["event"] == "error"

        await self._delete_channel(cid)

    async def test_ws_channel_not_found(self):
        import websockets

        async with websockets.connect("ws://localhost:7860/api/v1/ws/does-not-exist") as ws:
            msg = json.loads(await ws.recv())
            assert msg["event"] == "error"
            assert "channel not found" in msg["message"]

    async def test_stats_endpoint(self):
        resp = await self.client.get("/webhook-socket/stats", headers=AUTH_HEADER)
        assert resp.status_code == 200
        data = resp.json()
        assert "channels" in data
        assert "total_messages" in data
        assert "total_subscribers" in data

    async def test_full_lifecycle(self):
        cid = "lifecycle-test"

        ch = await self._create_channel(channel_id=cid, buffer_size=10, secret="life-secret")
        assert ch["channel_id"] == cid

        info_resp = await self.client.get(f"/channels/{cid}", headers=AUTH_HEADER)
        assert info_resp.status_code == 200
        assert info_resp.json()["messages"] == 0

        import websockets

        ws_url = f"ws://localhost:7860/api/v1/ws/{cid}?secret=life-secret"
        async with websockets.connect(ws_url) as ws:
            connected = json.loads(await ws.recv())
            assert connected["event"] == "connected"

            payload = {"event_type": "push", "data": {"ref": "main"}}
            sig = _sign(json.dumps(payload).encode(), "life-secret")
            resp = await self.client.post(
                f"/webhook/{cid}",
                json=payload,
                headers={"X-Signature-256": sig, "X-GitHub-Event": "push"},
            )
            assert resp.status_code == 200
            wh = resp.json()
            assert wh["subscribers_notified"] == 1

            received = json.loads(await ws.recv())
            assert received["event"] == "message"
            assert received["payload"]["event_type"] == "push"
            assert received["headers"]["X-GitHub-Event"] == "push"

        stats_resp = await self.client.get(f"/channels/{cid}", headers=AUTH_HEADER)
        assert stats_resp.json()["messages"] == 1

        await self._delete_channel(cid)

        not_found = await self.client.get(f"/channels/{cid}", headers=AUTH_HEADER)
        assert not_found.status_code == 404


if __name__ == "__main__":
    import subprocess
    import sys as _sys

    os.environ["RUN_INTEGRATION_TESTS"] = "1"
    _sys.exit(
        subprocess.run(
            [_sys.executable, "-m", "pytest", __file__, "-v", "--tb=short"],
            cwd=os.path.join(os.path.dirname(__file__), ".."),
        ).returncode
    )