cortexdb-connectors 0.2.12__tar.gz → 0.2.13__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/PKG-INFO +1 -1
  2. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors/base.py +1 -1
  3. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors/webhooks.py +87 -19
  4. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors.egg-info/PKG-INFO +1 -1
  5. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/pyproject.toml +1 -1
  6. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/tests/test_jira_findings_0_2_9.py +1 -1
  7. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/tests/test_tldv_findings_0_2_12.py +87 -0
  8. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/tests/test_webhooks_replay.py +40 -12
  9. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/README.md +0 -0
  10. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors/__init__.py +0 -0
  11. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors/cli.py +0 -0
  12. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors/confluence/__init__.py +0 -0
  13. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors/discord/__init__.py +0 -0
  14. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors/freshdesk/__init__.py +0 -0
  15. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors/github/__init__.py +0 -0
  16. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors/gitlab/__init__.py +0 -0
  17. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors/google_workspace/__init__.py +0 -0
  18. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors/hubspot/__init__.py +0 -0
  19. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors/insights/__init__.py +0 -0
  20. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors/insights/api.py +0 -0
  21. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors/insights/detectors.py +0 -0
  22. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors/insights/engine.py +0 -0
  23. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors/intercom/__init__.py +0 -0
  24. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors/jira/__init__.py +0 -0
  25. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors/linear/__init__.py +0 -0
  26. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors/notion/__init__.py +0 -0
  27. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors/pagerduty/__init__.py +0 -0
  28. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors/salesforce/__init__.py +0 -0
  29. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors/servicenow/__init__.py +0 -0
  30. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors/slack/__init__.py +0 -0
  31. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors/state.py +0 -0
  32. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors/teams/__init__.py +0 -0
  33. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors/tldv/__init__.py +0 -0
  34. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors/worker.py +0 -0
  35. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors/zendesk/__init__.py +0 -0
  36. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors.egg-info/SOURCES.txt +0 -0
  37. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors.egg-info/dependency_links.txt +0 -0
  38. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors.egg-info/entry_points.txt +0 -0
  39. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors.egg-info/requires.txt +0 -0
  40. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/cortexdb_connectors.egg-info/top_level.txt +0 -0
  41. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/setup.cfg +0 -0
  42. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/tests/test_base.py +0 -0
  43. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/tests/test_connector_coverage.py +0 -0
  44. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/tests/test_github.py +0 -0
  45. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/tests/test_ingest_e2e.py +0 -0
  46. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/tests/test_jira_attachments.py +0 -0
  47. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/tests/test_jira_findings_0_2_11.py +0 -0
  48. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/tests/test_product_findings_0_2_12.py +0 -0
  49. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/tests/test_slack.py +0 -0
  50. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/tests/test_streaming_cursor.py +0 -0
  51. {cortexdb_connectors-0.2.12 → cortexdb_connectors-0.2.13}/tests/test_webhooks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cortexdb-connectors
3
- Version: 0.2.12
3
+ Version: 0.2.13
4
4
  Summary: Data connectors for CortexDB — ingest from Slack, GitHub, GitLab, Jira, Linear, Confluence, Notion, PagerDuty, Discord, Teams, Google Workspace, Salesforce, HubSpot, Zendesk, Intercom, and ServiceNow into the v1 memory API.
5
5
  Author-email: CortexDB Team <team@cortexdb.ai>
6
6
  License-Expression: Apache-2.0
@@ -107,7 +107,7 @@ class VisibilityLevel(str, Enum):
107
107
  @dataclass(frozen=True)
108
108
  class Source:
109
109
  system: str
110
- connector_version: str = "0.2.12"
110
+ connector_version: str = "0.2.13"
111
111
 
112
112
 
113
113
  @dataclass(frozen=True)
@@ -536,13 +536,17 @@ def _verify_hmac_sha256(secret: str, body: bytes, signature: str) -> bool:
536
536
 
537
537
 
538
538
  class _HmacWebhookHandler:
539
- """Shared base for HMAC-signed webhooks (Jira, Freshdesk, tl;dv).
540
-
541
- The provider is configured to sign the webhook body with HMAC-SHA256 under
542
- a shared secret, sent as ``sha256=<hex>`` in ``signature_header``. The
543
- base verifies the signature, parses JSON, calls the subclass ``normalize``
544
- (which may return ``None`` to skip an irrelevant event), and ingests the
545
- resulting Episode via ``POST /v1/experience``.
539
+ """Shared base for provider webhook receivers (Jira, Freshdesk, tl;dv).
540
+
541
+ Authentication is per-provider via ``verify()``: the default checks an
542
+ HMAC-SHA256 body signature (``sha256=<hex>``) under ``signature_header``
543
+ — but each provider names its own header (Jira signs under
544
+ ``X-Hub-Signature``, not GitHub's ``X-Hub-Signature-256``), and some
545
+ don't body-sign at all (see ``_StaticHeaderWebhookHandler``), so
546
+ subclasses override the header name or the whole scheme. Everything
547
+ after auth is scheme-agnostic and shared: parse JSON, redact on delete
548
+ events, else call the subclass ``normalize`` (which may return ``None``
549
+ to skip an irrelevant event) and ingest via ``POST /v1/experience``.
546
550
  """
547
551
 
548
552
  signature_header = "X-Hub-Signature-256"
@@ -562,11 +566,18 @@ class _HmacWebhookHandler:
562
566
  self.scope = scope
563
567
  self.actor = actor or os.environ.get("CORTEXDB_ACTOR", "")
564
568
 
569
+ def verify(self, headers: Any, body: bytes) -> bool:
570
+ """True when the delivery is authentic. Default: HMAC-SHA256 body
571
+ signature under ``signature_header``. Providers with a different
572
+ auth scheme override this; ``handle()`` is scheme-agnostic."""
573
+ return _verify_hmac_sha256(
574
+ self.secret, body, headers.get(self.signature_header, "")
575
+ )
576
+
565
577
  async def handle(self, request: Request) -> JSONResponse:
566
578
  body = await request.body()
567
- signature = request.headers.get(self.signature_header, "")
568
- if not _verify_hmac_sha256(self.secret, body, signature):
569
- logger.warning("%s signature verification failed", self.provider)
579
+ if not self.verify(request.headers, body):
580
+ logger.warning("%s webhook authentication failed", self.provider)
570
581
  return JSONResponse({"error": "invalid signature"}, status_code=401)
571
582
  try:
572
583
  payload = json.loads(body)
@@ -638,6 +649,29 @@ class _HmacWebhookHandler:
638
649
  return None
639
650
 
640
651
 
652
+ class _StaticHeaderWebhookHandler(_HmacWebhookHandler):
653
+ """Auth for providers that cannot HMAC-sign the body (T47).
654
+
655
+ tl;dv and Freshdesk send a static, user-configured header with every
656
+ delivery instead of a body signature — configure the provider to send
657
+ ``auth_header`` with the shared secret as its value (a ``Bearer ``
658
+ prefix is accepted) and set the same secret on the handler. Fail-closed:
659
+ with no secret configured every delivery is rejected, same as the HMAC
660
+ path. Static-header auth carries no body integrity or replay binding —
661
+ it is what these providers offer, not an equivalent to a signature.
662
+ """
663
+
664
+ auth_header = "Authorization"
665
+
666
+ def verify(self, headers: Any, body: bytes) -> bool:
667
+ if not self.secret:
668
+ return False
669
+ supplied = headers.get(self.auth_header, "")
670
+ if supplied.startswith("Bearer "):
671
+ supplied = supplied[len("Bearer "):]
672
+ return bool(supplied) and hmac.compare_digest(supplied, self.secret)
673
+
674
+
641
675
  def _parse_iso(value: Any) -> datetime:
642
676
  """Parse an ISO-8601 string (Z or offset) → aware datetime; fall back to now.
643
677
 
@@ -659,6 +693,11 @@ class JiraWebhookHandler(_HmacWebhookHandler):
659
693
  """Jira Cloud webhook → Episode. Configure the webhook with an HMAC secret."""
660
694
 
661
695
  provider = "jira"
696
+ # T47-family: Jira signs the body with the same sha256=<hex> HMAC scheme
697
+ # but sends it under WebSub-style X-Hub-Signature, not GitHub's
698
+ # X-Hub-Signature-256 — reading the wrong header 401'd every correctly
699
+ # signed delivery.
700
+ signature_header = "X-Hub-Signature"
662
701
 
663
702
  def deletion_entity(self, payload: dict[str, Any]) -> str | None:
664
703
  # C2: jira:issue_deleted → redact the issue (its key is what the
@@ -718,8 +757,11 @@ class JiraWebhookHandler(_HmacWebhookHandler):
718
757
  )
719
758
 
720
759
 
721
- class FreshdeskWebhookHandler(_HmacWebhookHandler):
722
- """Freshdesk webhook/automation → Episode (HMAC over body)."""
760
+ class FreshdeskWebhookHandler(_StaticHeaderWebhookHandler):
761
+ """Freshdesk automation webhook → Episode.
762
+
763
+ Freshdesk automations don't HMAC-sign the body; auth is a custom header
764
+ configured on the automation rule (static-header scheme, T47-family)."""
723
765
 
724
766
  provider = "freshdesk"
725
767
 
@@ -754,8 +796,13 @@ class FreshdeskWebhookHandler(_HmacWebhookHandler):
754
796
  )
755
797
 
756
798
 
757
- class TldvWebhookHandler(_HmacWebhookHandler):
758
- """tl;dv webhook (e.g. recording-ready) → Episode (HMAC over body).
799
+ class TldvWebhookHandler(_StaticHeaderWebhookHandler):
800
+ """tl;dv webhook (MeetingReady / TranscriptReady) → Episode.
801
+
802
+ tl;dv does not HMAC-sign deliveries — its webhook auth is a static
803
+ custom header configured in Settings → Webhooks (T47), so this uses the
804
+ static-header scheme: configure tl;dv to send
805
+ ``Authorization: Bearer <TLDV_WEBHOOK_SECRET>``.
759
806
 
760
807
  Builds its episode through the SAME ``build_meeting_episode`` as the
761
808
  poll connector (T3): same content shape, same versioned idempotency key
@@ -788,7 +835,26 @@ class TldvWebhookHandler(_HmacWebhookHandler):
788
835
  return None
789
836
 
790
837
  def normalize(self, payload: dict[str, Any]) -> Episode | None:
791
- from cortexdb_connectors.tldv import build_meeting_episode
838
+ from cortexdb_connectors.tldv import build_meeting_episode, parse_tldv_ts
839
+
840
+ # T46: TranscriptReady's `data` is a transcript envelope
841
+ # {id: <transcriptId>, meetingId, data: [segments]} — its `id` is the
842
+ # TRANSCRIPT id, so letting _extract_meeting treat it as the meeting
843
+ # mis-keys the episode and drops the segments. Key by the meeting id
844
+ # and feed the envelope through as the transcript instead.
845
+ data = payload.get("data")
846
+ if isinstance(data, dict) and data.get("meetingId"):
847
+ return build_meeting_episode(
848
+ {"id": data["meetingId"]},
849
+ data,
850
+ self.scope,
851
+ occurred_at=parse_tldv_ts(payload.get("executedAt")),
852
+ raw_payload=payload,
853
+ extra_metadata={
854
+ "event": payload.get("event", ""),
855
+ "transcript_id": data.get("id"),
856
+ },
857
+ )
792
858
 
793
859
  meeting = self._extract_meeting(payload)
794
860
  if meeting is None:
@@ -803,7 +869,7 @@ class TldvWebhookHandler(_HmacWebhookHandler):
803
869
  return None
804
870
  return build_meeting_episode(
805
871
  meeting,
806
- None, # webhooks never carry the transcript
872
+ None, # MeetingReady-style deliveries carry no transcript
807
873
  self.scope,
808
874
  raw_payload=payload,
809
875
  extra_metadata={"event": payload.get("event", "")},
@@ -973,9 +1039,11 @@ class WebhookServer:
973
1039
  scope=self.scope,
974
1040
  actor=self.actor,
975
1041
  )
976
- # HMAC-signed handlers for the Limechat pilot sources. Each reads its
977
- # own *_WEBHOOK_SECRET; the provider must be configured to sign the
978
- # body with HMAC-SHA256 under that secret (sha256=<hex> header).
1042
+ # Handlers for the Limechat pilot sources. Each reads its own
1043
+ # *_WEBHOOK_SECRET; auth matches what the provider actually sends —
1044
+ # Jira signs the body (sha256=<hex> under X-Hub-Signature), while
1045
+ # tl;dv and Freshdesk send the secret as a static header
1046
+ # (Authorization: Bearer <secret>).
979
1047
  self.jira_handler = JiraWebhookHandler(
980
1048
  secret=os.environ.get("JIRA_WEBHOOK_SECRET", ""),
981
1049
  cortexdb_url=self.cortexdb_url,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cortexdb-connectors
3
- Version: 0.2.12
3
+ Version: 0.2.13
4
4
  Summary: Data connectors for CortexDB — ingest from Slack, GitHub, GitLab, Jira, Linear, Confluence, Notion, PagerDuty, Discord, Teams, Google Workspace, Salesforce, HubSpot, Zendesk, Intercom, and ServiceNow into the v1 memory API.
5
5
  Author-email: CortexDB Team <team@cortexdb.ai>
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cortexdb-connectors"
7
- version = "0.2.12"
7
+ version = "0.2.13"
8
8
  description = "Data connectors for CortexDB — ingest from Slack, GitHub, GitLab, Jira, Linear, Confluence, Notion, PagerDuty, Discord, Teams, Google Workspace, Salesforce, HubSpot, Zendesk, Intercom, and ServiceNow into the v1 memory API."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -286,7 +286,7 @@ class TestDeletion:
286
286
  sig = "sha256=" + hmac_mod.new(b"s", body, hashlib.sha256).hexdigest()
287
287
 
288
288
  class _Req:
289
- headers = {"X-Hub-Signature-256": sig}
289
+ headers = {"X-Hub-Signature": sig}
290
290
 
291
291
  async def body(self):
292
292
  return body
@@ -264,3 +264,90 @@ def test_jira_webhook_flattens_adf_description():
264
264
  )
265
265
  assert ep is not None
266
266
  assert "the actual body" in ep.content
267
+
268
+
269
+ # ── T46: TranscriptReady keyed by meeting id, transcript kept ────────────────
270
+
271
+ _TRANSCRIPT_READY = {
272
+ "event": "TranscriptReady",
273
+ "data": {
274
+ "id": "TRANSCRIPT-999",
275
+ "meetingId": "MEETING-123",
276
+ "data": [{"startTime": 0, "endTime": 18, "speaker": "A", "text": "the real transcript"}],
277
+ },
278
+ "id": "web-job-x",
279
+ "executedAt": "2026-07-29T10:27:14.729Z",
280
+ }
281
+
282
+
283
+ def _tldv_handler():
284
+ from cortexdb_connectors.webhooks import TldvWebhookHandler
285
+
286
+ return TldvWebhookHandler(
287
+ secret="whsec_test", cortexdb_url="u", cortexdb_api_key="k", scope="s", actor="a"
288
+ )
289
+
290
+
291
+ def test_transcript_ready_keys_by_meeting_id_and_keeps_transcript():
292
+ ep = _tldv_handler().normalize(_TRANSCRIPT_READY)
293
+ assert ep is not None
294
+ # Keyed by the MEETING id, never the transcript id.
295
+ assert ep.entities[0].entity_id == "MEETING-123"
296
+ assert ep.idempotency_key.startswith("tldv:MEETING-123:")
297
+ assert "TRANSCRIPT-999" not in ep.idempotency_key
298
+ # The segments land in the ingested content.
299
+ assert "A: the real transcript" in ep.content
300
+ # Chronology from the delivery's executedAt, not now().
301
+ assert ep.occurred_at == datetime(2026, 7, 29, 10, 27, 14, 729000, tzinfo=timezone.utc)
302
+ assert ep.metadata["transcript_id"] == "TRANSCRIPT-999"
303
+
304
+
305
+ def test_transcript_ready_without_segments_still_keys_by_meeting_id():
306
+ payload = {
307
+ "event": "TranscriptReady",
308
+ "data": {"id": "TRANSCRIPT-1", "meetingId": "MEETING-7"},
309
+ "executedAt": "2026-07-29T10:27:14.729Z",
310
+ }
311
+ ep = _tldv_handler().normalize(payload)
312
+ assert ep is not None
313
+ assert ep.idempotency_key.startswith("tldv:MEETING-7:")
314
+
315
+
316
+ # ── T47: provider auth matches what the provider actually sends ──────────────
317
+
318
+
319
+ def test_tldv_static_header_auth():
320
+ h = _tldv_handler()
321
+ body = b"{}"
322
+ assert h.verify({"Authorization": "Bearer whsec_test"}, body)
323
+ assert h.verify({"Authorization": "whsec_test"}, body)
324
+ assert not h.verify({"Authorization": "Bearer wrong"}, body)
325
+ assert not h.verify({}, body) # real tl;dv sends no X-Hub-Signature-256
326
+ assert not h.verify({"X-Hub-Signature-256": "sha256=deadbeef"}, body)
327
+
328
+
329
+ def test_tldv_no_secret_fails_closed():
330
+ from cortexdb_connectors.webhooks import TldvWebhookHandler
331
+
332
+ h = TldvWebhookHandler(
333
+ secret="", cortexdb_url="u", cortexdb_api_key="k", scope="s", actor="a"
334
+ )
335
+ assert not h.verify({"Authorization": "Bearer anything"}, b"{}")
336
+ assert not h.verify({}, b"{}")
337
+
338
+
339
+ def test_jira_signs_under_x_hub_signature():
340
+ import hashlib
341
+ import hmac as hmac_mod
342
+
343
+ from cortexdb_connectors.webhooks import JiraWebhookHandler
344
+
345
+ h = JiraWebhookHandler(
346
+ secret="s", cortexdb_url="u", cortexdb_api_key="k", scope="s", actor="a"
347
+ )
348
+ body = b'{"webhookEvent": "jira:issue_updated"}'
349
+ sig = "sha256=" + hmac_mod.new(b"s", body, hashlib.sha256).hexdigest()
350
+ # Jira sends WebSub-style X-Hub-Signature — that must verify...
351
+ assert h.verify({"X-Hub-Signature": sig}, body)
352
+ # ...and GitHub's X-Hub-Signature-256 (which Jira never sends) must not.
353
+ assert not h.verify({"X-Hub-Signature-256": sig}, body)
@@ -1,11 +1,14 @@
1
- """Webhook replay tests for the HMAC-signed handlers (jira, freshdesk, tldv).
1
+ """Webhook replay tests for the provider handlers (jira, freshdesk, tldv).
2
2
 
3
3
  Drives the real WebhookServer ASGI app with a Starlette TestClient and
4
- synthetic provider payloads (built from public sample shapes). Asserts:
5
- * a bad/missing signature is rejected 401
6
- * a correctly HMAC-signed body is accepted 200 and the event lands in the sink
7
-
8
- No live credentials; the only secret is the test's own HMAC key.
4
+ synthetic provider payloads (built from public sample shapes), using each
5
+ provider's REAL auth scheme (T47): Jira HMAC-signs the body under
6
+ X-Hub-Signature; tl;dv and Freshdesk send a static Authorization header and
7
+ no signature at all. Asserts:
8
+ * bad/missing auth is rejected 401
9
+ * a correctly authenticated delivery is accepted 200 and lands in the sink
10
+
11
+ No live credentials; the only secret is the test's own key.
9
12
  """
10
13
 
11
14
  from __future__ import annotations
@@ -27,6 +30,20 @@ def _sign(body: bytes) -> str:
27
30
  return "sha256=" + hmac.new(_SECRET.encode(), body, hashlib.sha256).hexdigest()
28
31
 
29
32
 
33
+ def _good_auth(provider: str, body: bytes) -> dict[str, str]:
34
+ """Each provider's real auth: Jira = body HMAC under X-Hub-Signature;
35
+ tl;dv/Freshdesk = static Authorization header, no signature."""
36
+ if provider == "jira":
37
+ return {"X-Hub-Signature": _sign(body)}
38
+ return {"Authorization": f"Bearer {_SECRET}"}
39
+
40
+
41
+ def _bad_auth(provider: str) -> dict[str, str]:
42
+ if provider == "jira":
43
+ return {"X-Hub-Signature": "sha256=deadbeef"}
44
+ return {"Authorization": "Bearer wrong-secret"}
45
+
46
+
30
47
  # provider -> (secret_env_var, route, scope, payload, distinctive content word)
31
48
  CASES = {
32
49
  "jira": (
@@ -98,28 +115,39 @@ def _client(provider: str, sink, monkeypatch) -> tuple[TestClient, str, dict, st
98
115
 
99
116
 
100
117
  @pytest.mark.parametrize("provider", list(CASES), ids=list(CASES))
101
- def test_bad_signature_rejected(provider, cortex_sink, monkeypatch) -> None:
118
+ def test_bad_auth_rejected(provider, cortex_sink, monkeypatch) -> None:
102
119
  client, route, payload, _ = _client(provider, cortex_sink, monkeypatch)
103
120
  body = json.dumps(payload).encode()
104
121
  r = client.post(
105
122
  route,
106
123
  content=body,
107
- headers={"X-Hub-Signature-256": "sha256=deadbeef", "Content-Type": "application/json"},
124
+ headers={**_bad_auth(provider), "Content-Type": "application/json"},
125
+ )
126
+ assert r.status_code == 401, f"{provider}: bad auth should be 401, got {r.status_code}"
127
+
128
+
129
+ @pytest.mark.parametrize("provider", list(CASES), ids=list(CASES))
130
+ def test_missing_auth_rejected(provider, cortex_sink, monkeypatch) -> None:
131
+ client, route, payload, _ = _client(provider, cortex_sink, monkeypatch)
132
+ r = client.post(
133
+ route,
134
+ content=json.dumps(payload).encode(),
135
+ headers={"Content-Type": "application/json"},
108
136
  )
109
- assert r.status_code == 401, f"{provider}: bad signature should be 401, got {r.status_code}"
137
+ assert r.status_code == 401, f"{provider}: missing auth should be 401, got {r.status_code}"
110
138
 
111
139
 
112
140
  @pytest.mark.parametrize("provider", list(CASES), ids=list(CASES))
113
- def test_good_signature_ingests(provider, cortex_sink, monkeypatch) -> None:
141
+ def test_good_auth_ingests(provider, cortex_sink, monkeypatch) -> None:
114
142
  client, route, payload, scope = _client(provider, cortex_sink, monkeypatch)
115
143
  word = CASES[provider][4]
116
144
  body = json.dumps(payload).encode()
117
145
  r = client.post(
118
146
  route,
119
147
  content=body,
120
- headers={"X-Hub-Signature-256": _sign(body), "Content-Type": "application/json"},
148
+ headers={**_good_auth(provider, body), "Content-Type": "application/json"},
121
149
  )
122
- assert r.status_code == 200, f"{provider}: good signature should be 200, got {r.status_code} {r.text}"
150
+ assert r.status_code == 200, f"{provider}: good auth should be 200, got {r.status_code} {r.text}"
123
151
 
124
152
  items = []
125
153
  for _ in range(12):