diff --git a/bot/src/bridge.teams-persistence.test.ts b/bot/src/bridge.teams-persistence.test.ts new file mode 100644 index 00000000..a54bde48 --- /dev/null +++ b/bot/src/bridge.teams-persistence.test.ts @@ -0,0 +1,171 @@ +import { afterEach, beforeEach, describe, it } from "node:test"; +import assert from "node:assert"; +import { + recordTeamsConversation, + seedTeamsKnownTeamIds, + clearTeamsKnownTeamIdsForTest, +} from "./bridge.js"; + +// ── Helpers ────────────────────────────────────────────────────────────────── +// `recordTeamsConversation` fires a write-through POST to the backend whenever +// it observes a new aadGroupId. We don't want test cases to hit a real network +// — so we stub `fetch` with a recorder. Calls are captured for assertion; the +// stub resolves with a synthetic 204 No Content so the helper takes its +// success path. + +interface CapturedFetch { + url: string; + init: { method?: string; headers?: Record; body?: string }; +} + +const captured: CapturedFetch[] = []; +let originalFetch: typeof fetch; + +function installFetchStub(status: number = 204): void { + originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + captured.push({ + url: input.toString(), + init: { + method: init?.method, + headers: init?.headers as Record | undefined, + body: init?.body ? String(init.body) : undefined, + }, + }); + return new Response(null, { status }); + }) as typeof fetch; +} + +function restoreFetch(): void { + globalThis.fetch = originalFetch; +} + +const VALID_AAD_GROUP_ID = "85e9fb0c-6cf9-4e94-9cc4-eb81ea6cd9de"; +const ANOTHER_AAD_GROUP_ID = "11111111-2222-3333-4444-555555555555"; +const CONN_ID = "test-conn-aabbccdd"; +const OTHER_CONN_ID = "other-conn-eeff0011"; + +const installActivity = (aadGroupId: string, conversationId: string = "19:abcdef@thread.tacv2") => ({ + conversation: { id: conversationId, conversationType: "channel" }, + channelData: { + team: { id: "team-internal-id", name: "Beever Atlas", aadGroupId }, + channel: { id: conversationId, name: "tech-discussion" }, + }, + serviceUrl: "https://smba.trafficmanager.net/amer/", +}); + +// ── seedTeamsKnownTeamIds ──────────────────────────────────────────────────── + +describe("seedTeamsKnownTeamIds", () => { + beforeEach(() => { + clearTeamsKnownTeamIdsForTest(); + captured.length = 0; + installFetchStub(); + }); + afterEach(restoreFetch); + + it("hydrates the in-memory Map from a Mongo-supplied list", () => { + seedTeamsKnownTeamIds(CONN_ID, [VALID_AAD_GROUP_ID, ANOTHER_AAD_GROUP_ID]); + + // After seeding, a subsequent webhook for the SAME id must NOT fire a + // write-through — the value is already considered known. + recordTeamsConversation(CONN_ID, installActivity(VALID_AAD_GROUP_ID)); + assert.strictEqual( + captured.length, + 0, + "seeded ids must dedup against incoming webhooks (no POST expected)", + ); + }); + + it("is a no-op for an empty list (preserves legacy cold-start scan path)", () => { + seedTeamsKnownTeamIds(CONN_ID, []); + // A subsequent observation must STILL fire the write-through because the + // empty-seed call did not pre-populate any ids. + recordTeamsConversation(CONN_ID, installActivity(VALID_AAD_GROUP_ID)); + assert.strictEqual(captured.length, 1); + }); + + it("scopes seeded ids per-connection (no cross-connection leak)", () => { + seedTeamsKnownTeamIds(CONN_ID, [VALID_AAD_GROUP_ID]); + + // A different connection observing the same team-id must fire its own + // write-through; ids are not shared across connections (Redis cache is + // global but Mongo persistence is per-row). + recordTeamsConversation(OTHER_CONN_ID, installActivity(VALID_AAD_GROUP_ID)); + assert.strictEqual(captured.length, 1); + assert.ok( + captured[0].url.includes(encodeURIComponent(OTHER_CONN_ID)), + "write-through URL must target the OTHER connection", + ); + }); + + it("is idempotent across repeated calls (adapter rebuild path)", () => { + seedTeamsKnownTeamIds(CONN_ID, [VALID_AAD_GROUP_ID]); + seedTeamsKnownTeamIds(CONN_ID, [VALID_AAD_GROUP_ID]); + seedTeamsKnownTeamIds(CONN_ID, [VALID_AAD_GROUP_ID, ANOTHER_AAD_GROUP_ID]); + + // A webhook for either id is now a no-op write-through. + recordTeamsConversation(CONN_ID, installActivity(VALID_AAD_GROUP_ID)); + recordTeamsConversation(CONN_ID, installActivity(ANOTHER_AAD_GROUP_ID)); + assert.strictEqual(captured.length, 0); + }); +}); + +// ── Write-through on observed aadGroupId ───────────────────────────────────── + +describe("recordTeamsConversation write-through", () => { + beforeEach(() => { + clearTeamsKnownTeamIdsForTest(); + captured.length = 0; + installFetchStub(); + }); + afterEach(restoreFetch); + + it("POSTs to the backend the first time a new aadGroupId is observed", () => { + recordTeamsConversation(CONN_ID, installActivity(VALID_AAD_GROUP_ID)); + + assert.strictEqual(captured.length, 1); + const { url, init } = captured[0]; + assert.ok( + url.includes(`/api/internal/connections/${encodeURIComponent(CONN_ID)}/teams-known-team-ids`), + `unexpected URL: ${url}`, + ); + assert.strictEqual(init.method, "POST"); + assert.deepStrictEqual(JSON.parse(init.body || "{}"), { + aad_group_id: VALID_AAD_GROUP_ID, + }); + }); + + it("dedups subsequent observations of the same id (no second POST)", () => { + recordTeamsConversation(CONN_ID, installActivity(VALID_AAD_GROUP_ID)); + recordTeamsConversation(CONN_ID, installActivity(VALID_AAD_GROUP_ID)); + recordTeamsConversation(CONN_ID, installActivity(VALID_AAD_GROUP_ID)); + assert.strictEqual(captured.length, 1, "duplicate observations must not refire"); + }); + + it("fires a separate POST for a DIFFERENT aadGroupId on the same connection", () => { + recordTeamsConversation(CONN_ID, installActivity(VALID_AAD_GROUP_ID)); + recordTeamsConversation(CONN_ID, installActivity(ANOTHER_AAD_GROUP_ID)); + assert.strictEqual(captured.length, 2); + const ids = captured.map((c) => JSON.parse(c.init.body || "{}").aad_group_id).sort(); + assert.deepStrictEqual(ids, [ANOTHER_AAD_GROUP_ID, VALID_AAD_GROUP_ID].sort()); + }); + + it("rejects malformed aadGroupId without firing a POST", () => { + const activity = installActivity("not-a-guid-at-all"); + recordTeamsConversation(CONN_ID, activity); + assert.strictEqual(captured.length, 0, "non-GUID aadGroupId must be filtered client-side"); + }); + + it("does not fire when aadGroupId is absent (regular channel-message activity)", () => { + const activity = { + conversation: { id: "19:abcdef@thread.tacv2", conversationType: "channel" }, + channelData: { + team: { id: "team-internal-id", name: "Beever Atlas" }, // no aadGroupId + channel: { id: "19:abcdef@thread.tacv2", name: "tech-discussion" }, + }, + }; + recordTeamsConversation(CONN_ID, activity); + assert.strictEqual(captured.length, 0); + }); +}); diff --git a/bot/src/bridge.ts b/bot/src/bridge.ts index 6de7cc4a..c82418dd 100644 --- a/bot/src/bridge.ts +++ b/bot/src/bridge.ts @@ -1249,24 +1249,140 @@ const teamsConversationRegistry = new Map>(); /** * H3/M1: one-shot guard — tracks which connectionIds have already completed * the cold-start Redis SCAN. Prevents re-scanning on every `listChannels` call - * and avoids the blocking `KEYS` pattern entirely. + * and avoids the blocking `KEYS` pattern entirely. Also flipped by + * `seedTeamsKnownTeamIds` so a Mongo-hydrated connection never falls back to + * the Redis scan on its first call. */ const teamsColdStartScanned = new Set(); +/** Seed `teamsKnownTeamIds` for a connection from the durable Mongo record. + * Called by the bot's startup loader (one call per Teams connection) and + * again on each adapter rebuild — idempotent. A no-op when the list is + * empty so legacy connections without persisted team-ids still benefit + * from the Redis cold-start scan. */ +export function seedTeamsKnownTeamIds(connectionId: string, teamIds: string[]): void { + if (!connectionId || !teamIds || teamIds.length === 0) return; + let teamSet = teamsKnownTeamIds.get(connectionId); + if (!teamSet) { + teamSet = new Set(); + teamsKnownTeamIds.set(connectionId, teamSet); + } + for (const id of teamIds) { + if (id) teamSet.add(id); + } + // Mongo is now authoritative for this connection; suppress the Redis scan + // path entirely so a stale/wiped cache can't race the hydrated state. + teamsColdStartScanned.add(connectionId); +} + +/** Test-only: drop cached state for a connection (or all). Clears the + * in-memory team-id Map, the cold-start scan guard, AND the backend + * write-through dedup Set so tests don't bleed across cases. Production + * code prunes the registry via `pruneStaleTeamsConversations`. */ +export function clearTeamsKnownTeamIdsForTest(connectionId?: string): void { + if (connectionId) { + teamsKnownTeamIds.delete(connectionId); + teamsColdStartScanned.delete(connectionId); + // Drop write-through dedup entries scoped to this connection so the + // next observation re-fires the POST. Keyed `${connId}:${aadId}`. + for (const key of teamsWriteThroughInFlight) { + if (key.startsWith(`${connectionId}:`)) { + teamsWriteThroughInFlight.delete(key); + } + } + } else { + teamsKnownTeamIds.clear(); + teamsColdStartScanned.clear(); + teamsWriteThroughInFlight.clear(); + } +} + +/** Backend write-through dedup: keyed by `${connectionId}:${aadGroupId}` so a + * burst of webhooks for the same team doesn't fire N concurrent POSTs. On + * success the entry stays in place for the process lifetime (Mongo already + * has the value). On transient failure (5xx/network) we clear the entry so + * the next observation re-attempts. */ +const teamsWriteThroughInFlight = new Set(); + +/** Fire-and-forget POST that persists an observed AAD group id to Mongo so + * it survives bot restart AND a Redis cache wipe. Called from + * `recordTeamsConversation` whenever a NEW team-id surfaces for a + * connection. Errors are swallowed at the call site — the in-memory Map + * is the source of truth for the live process; the POST just hydrates + * Mongo for next startup. */ +async function persistTeamsKnownTeamIdToBackend( + connectionId: string, + aadGroupId: string, +): Promise { + const dedupKey = `${connectionId}:${aadGroupId}`; + if (teamsWriteThroughInFlight.has(dedupKey)) return; + teamsWriteThroughInFlight.add(dedupKey); + + const backendUrl = process.env.BACKEND_URL || "http://localhost:8000"; + const bridgeKey = process.env.BRIDGE_API_KEY || ""; + const headers: Record = { "Content-Type": "application/json" }; + if (bridgeKey) headers["Authorization"] = `Bearer ${bridgeKey}`; + + try { + const resp = await fetch( + `${backendUrl}/api/internal/connections/${encodeURIComponent(connectionId)}/teams-known-team-ids`, + { + method: "POST", + headers, + body: JSON.stringify({ aad_group_id: aadGroupId }), + signal: AbortSignal.timeout(5000), + }, + ); + if (resp.ok) return; + // 4xx is terminal (404 unknown connection, 422 wrong platform, 400 bad + // GUID). Leave the dedup entry in place so we don't loop on the same + // bad value. + if (resp.status >= 400 && resp.status < 500) { + console.warn( + `TeamsBridge: backend rejected aadGroupId persist (${resp.status}) for connection ${connectionId}`, + ); + return; + } + // 5xx → transient; allow retry on next observation. + console.warn( + `TeamsBridge: backend returned ${resp.status} persisting aadGroupId for ${connectionId}; will retry`, + ); + teamsWriteThroughInFlight.delete(dedupKey); + } catch (err) { + // Constant format string + %s args (not template interpolation in the + // first arg) — console.warn treats arg[0] as printf-style when more args + // follow, so a connectionId containing %s/%j would hijack substitution + // (CodeQL js/tainted-format-string). + console.warn( + "TeamsBridge: failed to persist aadGroupId for %s: %s", + connectionId, + safeErrorMessage(err), + ); + teamsWriteThroughInFlight.delete(dedupKey); + } +} + /** * A Microsoft Graph team-id is the team's AAD group object id — a GUID. Used to * validate teamIds sourced from the shared Redis channelContext cache before @@ -1377,14 +1493,28 @@ export function recordTeamsConversation( // TeamsBridge.listChannels can enumerate channels via Graph without any // prior @mention. Bot Framework install/conversationUpdate activities carry // `channelData.team.aadGroupId`; regular channel-message activities may not. + // + // Two-layer persistence: + // • In-memory Map — covers the live process; lost on bot restart. + // • Mongo via fire-and-forget POST — survives bot restart AND a Redis + // cache wipe. Only fires when the id is NEW for this connection so + // a steady stream of channel-message webhooks doesn't hammer the + // backend with no-op upserts. Backend dedups via `$addToSet`. const aadGroupId = activity.channelData?.team?.aadGroupId; - if (aadGroupId) { + if (aadGroupId && TEAMS_AAD_GROUP_ID_RE.test(aadGroupId)) { let teamSet = teamsKnownTeamIds.get(connectionId); if (!teamSet) { teamSet = new Set(); teamsKnownTeamIds.set(connectionId, teamSet); } + const isNew = !teamSet.has(aadGroupId); teamSet.add(aadGroupId); + if (isNew) { + // Fire-and-forget — never block webhook processing on the backend + // round-trip. The helper handles its own errors + dedup so an + // unhandled rejection can't escape this scope. + void persistTeamsKnownTeamIdToBackend(connectionId, aadGroupId); + } } } @@ -1538,7 +1668,17 @@ class TeamsBridge implements PlatformBridge { teamSet = new Set(); teamsKnownTeamIds.set(this.connectionId, teamSet); } + const wasNew = !teamSet.has(tId); teamSet.add(tId); + // Persist any id we recover from the Redis-cache cold-start + // path too — otherwise an EXISTING connection that was + // already populated via a prior webhook never seeds Mongo + // (the in-memory dedup in `recordTeamsConversation` would + // suppress the write-through forever). Fire-and-forget; + // backend `$addToSet` keeps it idempotent. + if (wasNew) { + void persistTeamsKnownTeamIdToBackend(this.connectionId, tId); + } } } } catch { diff --git a/bot/src/index.ts b/bot/src/index.ts index 5e8bb874..66a89141 100644 --- a/bot/src/index.ts +++ b/bot/src/index.ts @@ -7,7 +7,7 @@ config({ path: resolve(import.meta.dirname, "../../.env") }); import { Chat } from "chat"; import { formatBlockKit } from "./formatter.js"; import { consumeSSEStream } from "./sse-client.js"; -import { registerBridgeRoutes, recordTelegramChat, recordTeamsConversation, warmTeamsGraphToken } from "./bridge.js"; +import { registerBridgeRoutes, recordTelegramChat, recordTeamsConversation, warmTeamsGraphToken, seedTeamsKnownTeamIds } from "./bridge.js"; import { jsonResponse, readBody, MAX_BODY_SIZE, BodyTooLargeError, safeErrorMessage } from "./http-utils.js"; import { ChatManager } from "./chat-manager.js"; @@ -161,6 +161,11 @@ async function syncConnectionsFromBackend(chatManager: ChatManager, label: strin platform: string; credentials: Record; status: string; + // Mongo-persisted AAD group ids for Teams connections; empty for other + // platforms. Used to seed `teamsKnownTeamIds` so listChannels works on + // the first call without requiring a webhook or warm Redis cache. See + // `seedTeamsKnownTeamIds` in bridge.ts. + teams_known_team_ids?: string[]; }>; if (connections.length === 0) { @@ -194,6 +199,18 @@ async function syncConnectionsFromBackend(chatManager: ChatManager, label: strin } console.log(`${label}: registering ${conn.platform} adapter (connection: ${conn.connection_id || "legacy"})`); await chatManager.register(conn.platform, normalizedCreds, conn.connection_id); + + // Hydrate the in-memory Teams team-id Map from the durable Mongo record + // so the first `listChannels` call after a bot restart (or Redis cache + // wipe) returns the workspace's channels without needing an inbound + // webhook to reseed identity. This is the parity path with Slack/ + // Discord/Mattermost (whose tokens already live in Mongo). + if (conn.platform === "teams" && conn.connection_id && conn.teams_known_team_ids?.length) { + seedTeamsKnownTeamIds(conn.connection_id, conn.teams_known_team_ids); + console.log( + `${label}: seeded ${conn.teams_known_team_ids.length} known team id(s) for Teams connection ${conn.connection_id}`, + ); + } } console.log(`${label}: loaded ${connections.length} connection(s) from backend`); diff --git a/src/beever_atlas/api/connections.py b/src/beever_atlas/api/connections.py index 885c1794..202db357 100644 --- a/src/beever_atlas/api/connections.py +++ b/src/beever_atlas/api/connections.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import re from typing import Any import httpx @@ -226,6 +227,12 @@ class _InternalConnectionItem(BaseModel): platform: str credentials: dict[str, str] status: str + # Mirrored from the Mongo document so the bot can seed its in-memory + # `teamsKnownTeamIds` Map on startup, removing the dependency on + # webhook reseeding or the @chat-adapter Redis cache (which is wiped + # on Redis restart). Always present in the response for shape + # stability; non-Teams platforms get an empty list. + teams_known_team_ids: list[str] = [] @internal_router.get("/api/internal/connections/credentials") @@ -249,6 +256,7 @@ async def list_connections_with_credentials() -> list[_InternalConnectionItem]: platform=conn.platform, credentials=creds, status=conn.status, + teams_known_team_ids=list(getattr(conn, "teams_known_team_ids", None) or []), ) ) except Exception as e: @@ -256,6 +264,61 @@ async def list_connections_with_credentials() -> list[_InternalConnectionItem]: return result +class _RecordTeamsKnownTeamRequest(BaseModel): + """Body for the bot's write-through of an observed AAD group id.""" + + aad_group_id: str + + +# A Microsoft Graph team-id is the team's AAD group object id — a GUID. +# Validate the shape here AND in the bot before persistence so a poisoned +# webhook (or a future Bot Framework change) can't inject an arbitrary +# value into Mongo or downstream Graph API paths. +_TEAMS_AAD_GROUP_ID_RE = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + re.IGNORECASE, +) + + +@internal_router.post( + "/api/internal/connections/{connection_id}/teams-known-team-ids", + status_code=204, +) +async def record_teams_known_team_id( + connection_id: str, + body: _RecordTeamsKnownTeamRequest, +) -> None: + """Bot write-through: persist an AAD group id observed from a webhook. + + Called fire-and-forget from ``bridge.ts:recordTeamsConversation`` so + that the team-id survives a Redis cache wipe AND a bot restart. + Idempotent via ``$addToSet`` in the store. Returns 404 when the + connection id is unknown, 400 when the AAD group id doesn't match + the expected shape, and 422 when the connection isn't a Teams row. + + Secured by the router-level ``require_bridge`` dependency. Never + expose to end users. + """ + aad_group_id = body.aad_group_id.strip() + if not _TEAMS_AAD_GROUP_ID_RE.match(aad_group_id): + raise HTTPException( + status_code=400, + detail="aad_group_id must be a Microsoft AAD group GUID", + ) + + stores = get_stores() + existing = await stores.platform.get_connection(connection_id) + if existing is None: + raise HTTPException(status_code=404, detail="connection not found") + if existing.platform != "teams": + raise HTTPException( + status_code=422, + detail=f"connection {connection_id} is not a Teams connection", + ) + + await stores.platform.add_teams_known_team_id(connection_id, aad_group_id) + + @router.post("/api/connections", response_model=ConnectionResponse, status_code=201) async def create_connection( body: CreateConnectionRequest, diff --git a/src/beever_atlas/models/platform_connection.py b/src/beever_atlas/models/platform_connection.py index 3021b1d8..e873e92e 100644 --- a/src/beever_atlas/models/platform_connection.py +++ b/src/beever_atlas/models/platform_connection.py @@ -19,6 +19,14 @@ class PlatformConnection(BaseModel): credential_iv: bytes credential_tag: bytes selected_channels: list[str] = Field(default_factory=list) + # Microsoft Graph team-ids (AAD group GUIDs) observed for this Teams + # connection. The bot has no app-only Graph endpoint to enumerate + # "teams this app is installed in", so identity is discovered from + # Bot Framework webhooks and persisted here for parity with the + # token-based bootstrap of Slack/Discord/Mattermost. Empty for every + # non-Teams platform. Pydantic default — no DB migration required; + # rows pre-dating this field decode as `[]`. + teams_known_team_ids: list[str] = Field(default_factory=list) status: Literal["connected", "disconnected", "error"] = "connected" error_message: str | None = None created_at: datetime = Field(default_factory=lambda: datetime.now(tz=UTC)) diff --git a/src/beever_atlas/stores/platform_store.py b/src/beever_atlas/stores/platform_store.py index ad98a05a..1c78042c 100644 --- a/src/beever_atlas/stores/platform_store.py +++ b/src/beever_atlas/stores/platform_store.py @@ -168,6 +168,35 @@ async def update_connection( return None return self._from_doc(result) + async def add_teams_known_team_id( + self, + connection_id: str, + aad_group_id: str, + ) -> PlatformConnection | None: + """Idempotently union ``aad_group_id`` into ``teams_known_team_ids``. + + Uses Mongo's ``$addToSet`` so concurrent writes from multiple webhook + deliveries can't produce duplicate entries even without holding a + lock on the document. Returns the updated connection or ``None`` when + the connection id doesn't exist. + + Callers must validate ``aad_group_id`` matches the Graph team-id + shape (AAD group GUID) before invoking — see + ``TEAMS_AAD_GROUP_ID_RE`` on the bot side and the matching guard + in the API endpoint. + """ + result = await self._col.find_one_and_update( + {"id": connection_id}, + { + "$addToSet": {"teams_known_team_ids": aad_group_id}, + "$set": {"updated_at": datetime.now(tz=UTC)}, + }, + return_document=True, + ) + if result is None: + return None + return self._from_doc(result) + async def get_connection_by_platform(self, platform: str) -> PlatformConnection | None: """Return a PlatformConnection by platform name, or None.""" doc = await self._col.find_one({"platform": platform}) diff --git a/tests/test_platform_store.py b/tests/test_platform_store.py index 05b33765..d4b8cff8 100644 --- a/tests/test_platform_store.py +++ b/tests/test_platform_store.py @@ -8,7 +8,7 @@ from __future__ import annotations import secrets -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest @@ -200,3 +200,91 @@ def test_tampered_credentials_raise_invalid_tag(self, monkeypatch): with pytest.raises(InvalidTag): store.decrypt_connection_credentials(conn) + + +class TestTeamsKnownTeamIdsField: + """Coverage for the persistent ``teams_known_team_ids`` field added so + Teams connections can bootstrap their team list from Mongo (parity with + Slack/Discord/Mattermost bootstrapping from tokens) instead of relying + on the chat-adapter's Redis cache surviving every container restart.""" + + def test_defaults_to_empty_list(self, monkeypatch): + _patch_key(monkeypatch) + conn = _encrypted_conn(monkeypatch) + assert conn.teams_known_team_ids == [] + + def test_round_trip_preserves_team_ids(self, monkeypatch): + _patch_key(monkeypatch) + store = _make_store(monkeypatch) + ids = [ + "85e9fb0c-6cf9-4e94-9cc4-eb81ea6cd9de", + "11111111-2222-3333-4444-555555555555", + ] + conn = _encrypted_conn(monkeypatch, platform="teams", teams_known_team_ids=ids) + + doc = store._to_doc(conn) + assert doc["teams_known_team_ids"] == ids + + # Pre-migration Mongo docs are missing the field entirely; the + # Pydantic default must fill in cleanly so they decode without error. + legacy_doc = dict(doc) + legacy_doc.pop("teams_known_team_ids") + legacy_conn = store._from_doc(legacy_doc) + assert legacy_conn.teams_known_team_ids == [] + + @pytest.mark.asyncio + async def test_add_teams_known_team_id_uses_addToSet_and_dedups(self, monkeypatch): + """The store helper must use ``$addToSet`` so concurrent writes from + multiple webhook deliveries don't double-insert. We mock the Motor + call and assert the operator + payload shape so a future refactor + can't silently regress to ``$push``.""" + _patch_key(monkeypatch) + from beever_atlas.stores.platform_store import PlatformStore + + existing = _encrypted_conn( + monkeypatch, + platform="teams", + teams_known_team_ids=["85e9fb0c-6cf9-4e94-9cc4-eb81ea6cd9de"], + ) + + mock_col = MagicMock() + # Motor returns the updated doc (we asked for return_document=True). + mock_col.find_one_and_update = AsyncMock( + return_value=PlatformStore(mock_col)._to_doc(existing), + ) + store = PlatformStore(mock_col) + + result = await store.add_teams_known_team_id( + existing.id, + "85e9fb0c-6cf9-4e94-9cc4-eb81ea6cd9de", + ) + + # Inspect the call shape — filter by `id`, $addToSet operator. + mock_col.find_one_and_update.assert_awaited_once() + call_args, call_kwargs = mock_col.find_one_and_update.call_args + # First positional: filter; second: update document. + filter_doc, update_doc = call_args[0], call_args[1] + assert filter_doc == {"id": existing.id} + assert "$addToSet" in update_doc + assert update_doc["$addToSet"] == { + "teams_known_team_ids": "85e9fb0c-6cf9-4e94-9cc4-eb81ea6cd9de", + } + # `updated_at` must be touched so callers see a fresh timestamp. + assert "updated_at" in update_doc["$set"] + # Hands back the deserialised connection. + assert isinstance(result, PlatformConnection) + assert result.id == existing.id + + @pytest.mark.asyncio + async def test_add_teams_known_team_id_returns_none_when_missing(self, monkeypatch): + _patch_key(monkeypatch) + from beever_atlas.stores.platform_store import PlatformStore + + mock_col = MagicMock() + mock_col.find_one_and_update = AsyncMock(return_value=None) + + result = await PlatformStore(mock_col).add_teams_known_team_id( + "missing-conn", + "85e9fb0c-6cf9-4e94-9cc4-eb81ea6cd9de", + ) + assert result is None