Coverage for scripts / test_webhook_delivery.py: 100.00%

179 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-28 16:19 +0000

1#!/usr/bin/env python3 

2""" 

3Test script for webhook delivery. 

4 

5This script tests the webhook dispatcher by: 

61. Starting a local HTTP server to receive webhooks 

72. Creating a mock job event 

83. Dispatching the webhook 

94. Verifying the payload was received correctly 

10 

11Usage: 

12 python scripts/test_webhook_delivery.py 

13 

14For testing with a real webhook.site endpoint: 

15 python scripts/test_webhook_delivery.py --url https://webhook.site/your-uuid 

16""" 

17 

18import argparse 

19import asyncio 

20import hashlib 

21import hmac 

22import json 

23import sys 

24import threading 

25from datetime import UTC, datetime 

26from http.server import BaseHTTPRequestHandler, HTTPServer 

27from pathlib import Path 

28from typing import Any 

29from unittest.mock import MagicMock, patch 

30 

31# Add project root to path 

32sys.path.insert(0, str(Path(__file__).parent.parent)) 

33 

34# Store received webhooks 

35received_webhooks: list[dict[str, Any]] = [] 

36 

37 

38class WebhookHandler(BaseHTTPRequestHandler): 

39 """Simple HTTP handler to receive webhook requests.""" 

40 

41 def do_POST(self) -> None: 

42 content_length = int(self.headers.get("Content-Length", 0)) 

43 body = self.rfile.read(content_length) 

44 

45 webhook_data = { 

46 "path": self.path, 

47 "headers": dict(self.headers), 

48 "body": body.decode("utf-8"), 

49 "timestamp": datetime.now(UTC).isoformat(), 

50 } 

51 received_webhooks.append(webhook_data) 

52 

53 print(f"\n{'=' * 60}") 

54 print("WEBHOOK RECEIVED!") 

55 print(f"{'=' * 60}") 

56 print(f"Event: {self.headers.get('X-GCO-Event', 'unknown')}") 

57 print(f"Cluster: {self.headers.get('X-GCO-Cluster', 'unknown')}") 

58 print(f"Region: {self.headers.get('X-GCO-Region', 'unknown')}") 

59 sig = self.headers.get("X-GCO-Signature") 

60 if sig: 

61 print(f"Signature: {sig[:50]}...") 

62 

63 try: 

64 payload = json.loads(body) 

65 print("\nPayload:") 

66 print(json.dumps(payload, indent=2)) 

67 except json.JSONDecodeError: 

68 print(f"\nRaw body: {body.decode('utf-8')}") 

69 

70 print(f"{'=' * 60}\n") 

71 

72 self.send_response(200) 

73 self.send_header("Content-Type", "application/json") 

74 self.end_headers() 

75 self.wfile.write(b'{"status": "received"}') 

76 

77 def log_message(self, format: str, *args: Any) -> None: 

78 # Suppress default logging 

79 pass 

80 

81 

82def start_local_server(port: int = 8888) -> HTTPServer: 

83 """Start a local HTTP server to receive webhooks.""" 

84 server = HTTPServer(("localhost", port), WebhookHandler) 

85 thread = threading.Thread(target=server.serve_forever, daemon=True) 

86 thread.start() 

87 print(f"Local webhook server started on http://localhost:{port}") 

88 return server 

89 

90 

91def create_mock_job() -> Any: 

92 """Create a mock Kubernetes job object.""" 

93 job = MagicMock() 

94 job.metadata.name = "test-webhook-job" 

95 job.metadata.namespace = "gco-jobs" 

96 job.metadata.uid = "test-job-uid-12345" 

97 job.metadata.labels = {"app": "webhook-test", "team": "platform"} 

98 job.status.conditions = [MagicMock(type="Complete", status="True")] 

99 job.status.active = 0 

100 job.status.succeeded = 1 

101 job.status.failed = 0 

102 job.status.start_time = datetime(2026, 2, 4, 12, 0, 0, tzinfo=UTC) 

103 job.status.completion_time = datetime(2026, 2, 4, 12, 5, 0, tzinfo=UTC) 

104 return job 

105 

106 

107async def test_with_local_server() -> bool: 

108 """Test webhook delivery with a local server.""" 

109 from gco.services.webhook_dispatcher import WebhookDispatcher, WebhookEvent 

110 

111 print("\n" + "=" * 60) 

112 print("WEBHOOK DELIVERY TEST - LOCAL SERVER") 

113 print("=" * 60 + "\n") 

114 

115 # Start local server 

116 port = 8888 

117 server = start_local_server(port) 

118 webhook_url = f"http://localhost:{port}/webhook" 

119 webhook_secret = "test-secret-key" # nosec B105 # local test fixture, not a real secret 

120 

121 # Create mock webhook store 

122 mock_store = MagicMock() 

123 mock_store.get_webhooks_for_event.return_value = [ 

124 { 

125 "id": "test-webhook-1", 

126 "url": webhook_url, 

127 "events": ["job.completed"], 

128 "namespace": "gco-jobs", 

129 "secret": webhook_secret, 

130 } 

131 ] 

132 

133 # Create dispatcher with mocked K8s config 

134 with patch("gco.services.webhook_dispatcher.config") as mock_config: 

135 mock_config.ConfigException = Exception 

136 mock_config.load_incluster_config.side_effect = Exception() 

137 mock_config.load_kube_config.return_value = None 

138 

139 with patch("gco.services.webhook_dispatcher.client"): 

140 dispatcher = WebhookDispatcher( 

141 cluster_id="test-cluster", 

142 region="us-east-1", 

143 webhook_store=mock_store, 

144 timeout=10, 

145 max_retries=1, 

146 retry_delay=1, 

147 ) 

148 

149 # Create mock job 

150 job = create_mock_job() 

151 

152 print("Dispatching webhook for job.completed event...") 

153 print(f"Target URL: {webhook_url}") 

154 print("Secret configured: Yes") 

155 print() 

156 

157 # Dispatch the event 

158 results = await dispatcher._dispatch_event(WebhookEvent.JOB_COMPLETED, job) 

159 

160 # Check results 

161 print("\n" + "-" * 40) 

162 print("DELIVERY RESULTS:") 

163 print("-" * 40) 

164 

165 for result in results: 

166 status = "✓ SUCCESS" if result.success else "✗ FAILED" 

167 print(f"{status}") 

168 print(f" Webhook ID: {result.webhook_id}") 

169 print(f" URL: {result.url}") 

170 print(f" Status Code: {result.status_code}") 

171 print(f" Attempts: {result.attempts}") 

172 print(f" Duration: {result.duration_ms:.1f}ms") 

173 if result.error: 

174 print(f" Error: {result.error}") 

175 

176 # Verify signature 

177 if received_webhooks: 

178 print("\n" + "-" * 40) 

179 print("SIGNATURE VERIFICATION:") 

180 print("-" * 40) 

181 

182 webhook = received_webhooks[-1] 

183 signature = webhook["headers"].get("X-GCO-Signature", "") 

184 body = webhook["body"] 

185 

186 expected = hmac.new( 

187 webhook_secret.encode("utf-8"), 

188 body.encode("utf-8"), 

189 hashlib.sha256, 

190 ).hexdigest() 

191 

192 if signature == f"sha256={expected}": 

193 print("✓ Signature verified successfully!") 

194 else: 

195 print("✗ Signature verification failed!") 

196 print(f" Expected: sha256={expected}") 

197 print(f" Received: {signature}") 

198 

199 # Cleanup 

200 server.shutdown() 

201 

202 print("\n" + "=" * 60) 

203 print("TEST COMPLETE") 

204 print("=" * 60 + "\n") 

205 

206 return len(results) > 0 and all(r.success for r in results) 

207 

208 

209async def test_with_external_url( 

210 url: str, 

211 # Not a collected test (pytest's testpaths is tests/): this is the manual 

212 # harness's CLI entry point, and the default mirrors its optional --secret. 

213 secret: str | None = None, # noqa: PT028 

214) -> bool: 

215 """Test webhook delivery with an external URL (e.g., webhook.site).""" 

216 from gco.services.webhook_dispatcher import WebhookDispatcher, WebhookEvent 

217 

218 print("\n" + "=" * 60) 

219 print("WEBHOOK DELIVERY TEST - EXTERNAL URL") 

220 print("=" * 60 + "\n") 

221 

222 print(f"Target URL: {url}") 

223 print(f"Secret configured: {'Yes' if secret else 'No'}") 

224 print() 

225 

226 # Create mock webhook store 

227 mock_store = MagicMock() 

228 webhook_config = { 

229 "id": "external-webhook", 

230 "url": url, 

231 "events": ["job.completed", "job.failed", "job.started"], 

232 "namespace": None, # Global webhook 

233 } 

234 if secret: 

235 webhook_config["secret"] = secret 

236 mock_store.get_webhooks_for_event.return_value = [webhook_config] 

237 

238 # Create dispatcher with mocked K8s config 

239 with patch("gco.services.webhook_dispatcher.config") as mock_config: 

240 mock_config.ConfigException = Exception 

241 mock_config.load_incluster_config.side_effect = Exception() 

242 mock_config.load_kube_config.return_value = None 

243 

244 with patch("gco.services.webhook_dispatcher.client"): 

245 dispatcher = WebhookDispatcher( 

246 cluster_id="gco-test-cluster", 

247 region="us-east-1", 

248 webhook_store=mock_store, 

249 timeout=30, 

250 max_retries=2, 

251 retry_delay=2, 

252 ) 

253 

254 # Create mock job 

255 job = create_mock_job() 

256 

257 # Test all three event types 

258 events = [ 

259 (WebhookEvent.JOB_STARTED, "job.started"), 

260 (WebhookEvent.JOB_COMPLETED, "job.completed"), 

261 (WebhookEvent.JOB_FAILED, "job.failed"), 

262 ] 

263 

264 all_success = True 

265 

266 for event, event_name in events: 

267 # Adjust job status for the event 

268 if event == WebhookEvent.JOB_STARTED: 

269 job.status.conditions = [] 

270 job.status.active = 1 

271 job.status.succeeded = 0 

272 job.status.completion_time = None 

273 elif event == WebhookEvent.JOB_COMPLETED: 

274 job.status.conditions = [MagicMock(type="Complete", status="True")] 

275 job.status.active = 0 

276 job.status.succeeded = 1 

277 job.status.completion_time = datetime(2026, 2, 4, 12, 5, 0, tzinfo=UTC) 

278 else: # WebhookEvent.JOB_FAILED — the last of the three events above 

279 job.status.conditions = [MagicMock(type="Failed", status="True")] 

280 job.status.active = 0 

281 job.status.succeeded = 0 

282 job.status.failed = 1 

283 job.status.completion_time = datetime(2026, 2, 4, 12, 5, 0, tzinfo=UTC) 

284 

285 print(f"Dispatching {event_name} event...") 

286 results = await dispatcher._dispatch_event(event, job) 

287 

288 for result in results: 

289 status = "✓" if result.success else "✗" 

290 print( 

291 f" {status} Status: {result.status_code}, " 

292 f"Attempts: {result.attempts}, " 

293 f"Duration: {result.duration_ms:.1f}ms" 

294 ) 

295 if not result.success: 

296 print(f" Error: {result.error}") 

297 all_success = False 

298 

299 # Small delay between events 

300 await asyncio.sleep(1) 

301 

302 print("\n" + "=" * 60) 

303 if all_success: 

304 print("ALL WEBHOOKS DELIVERED SUCCESSFULLY!") 

305 print(f"Check your webhook receiver at: {url}") 

306 else: 

307 print("SOME WEBHOOKS FAILED - Check errors above") 

308 print("=" * 60 + "\n") 

309 

310 return all_success 

311 

312 

313async def main() -> int: 

314 parser = argparse.ArgumentParser(description="Test webhook delivery") 

315 parser.add_argument( 

316 "--url", 

317 help="External webhook URL (e.g., https://webhook.site/your-uuid)", 

318 ) 

319 parser.add_argument( 

320 "--secret", 

321 help="HMAC secret for signature verification", 

322 ) 

323 parser.add_argument( 

324 "--local", 

325 action="store_true", 

326 help="Run test with local server (default if no URL provided)", 

327 ) 

328 

329 args = parser.parse_args() 

330 

331 if args.url: 

332 success = await test_with_external_url(args.url, args.secret) 

333 else: 

334 success = await test_with_local_server() 

335 

336 return 0 if success else 1 

337 

338 

339if __name__ == "__main__": 

340 sys.exit(asyncio.run(main()))