@oneuptime/common 12.0.11 → 12.0.13

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 (251) hide show
  1. package/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.ts +32 -5
  2. package/Models/DatabaseModels/NetworkDevice.ts +64 -0
  3. package/Models/DatabaseModels/StatusPage.ts +59 -0
  4. package/Models/DatabaseModels/StatusPageOidc.ts +2 -0
  5. package/Models/DatabaseModels/WorkspaceProjectAuthToken.ts +19 -2
  6. package/Server/API/AIChatAPI.ts +55 -56
  7. package/Server/API/CommonAPI.ts +89 -0
  8. package/Server/API/MicrosoftTeamsAPI.ts +20 -9
  9. package/Server/API/SlackAPI.ts +13 -10
  10. package/Server/API/StatusPageAPI.ts +2197 -2128
  11. package/Server/API/TelemetryAPI.ts +72 -3
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/1787500000000-AddEnableSearchEngineIndexingToStatusPage.ts +30 -0
  13. package/Server/Infrastructure/Postgres/SchemaMigrations/1787600000000-AddNetworkDeviceReachabilityColumns.ts +47 -0
  14. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  15. package/Server/Middleware/UserAuthorization.ts +11 -2
  16. package/Server/Services/ApiKeyPermissionService.ts +116 -2
  17. package/Server/Services/DatabaseService.ts +17 -6
  18. package/Server/Services/LogAggregationService.ts +34 -0
  19. package/Server/Services/LogService.ts +21 -0
  20. package/Server/Services/NetworkSiteService.ts +12 -0
  21. package/Server/Services/SpanService.ts +21 -0
  22. package/Server/Services/TraceAggregationService.ts +15 -0
  23. package/Server/Services/WorkspaceNotificationRuleService.ts +172 -220
  24. package/Server/Types/AnalyticsDatabase/ModelPermission.ts +9 -0
  25. package/Server/Types/Database/Permissions/AccessControlPermission.ts +47 -16
  26. package/Server/Types/Workflow/Components/Conditions/IfElse.ts +9 -0
  27. package/Server/Types/Workflow/Components/JavaScript.ts +9 -0
  28. package/Server/Utils/APIKey/AccessPermission.ts +16 -40
  29. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +144 -0
  30. package/Server/Utils/LogRedaction.ts +576 -0
  31. package/Server/Utils/Logger.ts +123 -46
  32. package/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.ts +94 -23
  33. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +12 -0
  34. package/Server/Utils/Monitor/MonitorResource.ts +29 -9
  35. package/Server/Utils/Monitor/NetworkInventoryUtil.ts +28 -7
  36. package/Server/Utils/SessionReplay/SessionReplayGateCacheStore.ts +56 -10
  37. package/Server/Utils/StartServer.ts +30 -9
  38. package/Server/Utils/StatusPageSearchEngineIndexing.ts +33 -0
  39. package/Server/Utils/Telemetry/ResourceEntityFilter.ts +441 -0
  40. package/Server/Utils/VM/VMRunner.ts +693 -874
  41. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +488 -46
  42. package/Tests/App/Dashboard/MonitorTypePicker.test.tsx +300 -0
  43. package/Tests/App/StatusPage/StatusPageIndexPageRobotsMeta.test.ts +130 -0
  44. package/Tests/Models/DatabaseModels/DatabaseBaseModelToJSONObjectCache.test.ts +180 -0
  45. package/Tests/Models/StatusPageEnableSearchEngineIndexing.test.ts +157 -0
  46. package/Tests/Server/API/AIChatCancelAndFeedback.test.ts +23 -1
  47. package/Tests/Server/API/AIChatRouteAuthorization.test.ts +785 -0
  48. package/Tests/Server/API/BaseAPIApiKeyAuth.test.ts +21 -8
  49. package/Tests/Server/API/CommonAPIAuthGuard.test.ts +290 -0
  50. package/Tests/Server/API/MicrosoftTeamsManifest.test.ts +7 -1
  51. package/Tests/Server/API/StatusPageOverviewCache.test.ts +474 -0
  52. package/Tests/Server/API/StatusPageSeoSearchEngineIndexing.test.ts +189 -0
  53. package/Tests/Server/Infrastructure/InMemoryTTLCache.test.ts +158 -0
  54. package/Tests/Server/Infrastructure/Postgres/DeviceRoleAndDeclaredLinkParentMigration.test.ts +9 -60
  55. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +24 -15
  56. package/Tests/Server/Middleware/UserAuthorization.test.ts +79 -0
  57. package/Tests/Server/Services/AddNetworkDeviceReachabilityColumnsMigration.test.ts +332 -0
  58. package/Tests/Server/Services/ApiKeyPermissionService.test.ts +347 -0
  59. package/Tests/Server/Services/DatabaseServiceUpdateDebugLogging.test.ts +282 -0
  60. package/Tests/Server/Services/NetworkSiteService.test.ts +56 -2
  61. package/Tests/Server/Services/TelemetryResourceFacetFilters.test.ts +267 -0
  62. package/Tests/Server/Services/WorkspaceNotificationRuleTestRuleChannels.test.ts +702 -0
  63. package/Tests/Server/Types/Database/Permissions/AccessControlPermission.test.ts +165 -0
  64. package/Tests/Server/Types/Workflow/Components/CustomCodeScriptError.test.ts +121 -0
  65. package/Tests/Server/Utils/APIKey/AccessPermission.test.ts +48 -36
  66. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +213 -0
  67. package/Tests/Server/Utils/LogRedaction.test.ts +415 -0
  68. package/Tests/Server/Utils/LoggerCredentialLeak.test.ts +245 -0
  69. package/Tests/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.test.ts +374 -4
  70. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorJavascriptExpression.test.ts +92 -0
  71. package/Tests/Server/Utils/Monitor/MonitorResourceIngestedStepSelection.test.ts +455 -0
  72. package/Tests/Server/Utils/Monitor/NetworkDeviceReachabilityRoundTrip.test.ts +369 -0
  73. package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +86 -8
  74. package/Tests/Server/Utils/PrivacyFilterUtil.test.ts +64 -0
  75. package/Tests/Server/Utils/SessionReplay/SessionReplayGateCacheStore.test.ts +207 -0
  76. package/Tests/Server/Utils/StartServerBodyVerify.test.ts +335 -0
  77. package/Tests/Server/Utils/StatusPageSearchEngineIndexing.test.ts +60 -0
  78. package/Tests/Server/Utils/Telemetry/ResourceEntityFilter.test.ts +435 -0
  79. package/Tests/Server/Utils/VM/VMRunnerIsolation.test.ts +385 -0
  80. package/Tests/Server/Utils/VM/VMRunnerSsrf.test.ts +3 -0
  81. package/Tests/Server/Utils/Workspace/MicrosoftTeamsChannelSend.test.ts +607 -62
  82. package/Tests/Server/Utils/Workspace/MicrosoftTeamsInstalledTeamIdSpace.test.ts +778 -0
  83. package/Tests/Server/Utils/Workspace/MicrosoftTeamsTeamInstalls.test.ts +44 -10
  84. package/Tests/Types/API/HostnameFromAuthority.test.ts +164 -0
  85. package/Tests/Types/Database/AccessControl/ColumnAccessControlCache.test.ts +344 -0
  86. package/Tests/Types/Database/DatabasePropertyFindOperator.test.ts +518 -0
  87. package/Tests/Types/Database/TableColumnMetadataCache.test.ts +286 -0
  88. package/Tests/Types/Monitor/MonitorStep.test.ts +100 -0
  89. package/Tests/Types/Monitor/MonitorTypeKeywords.test.ts +360 -0
  90. package/Tests/Types/StatusPage/SearchEngineIndexing.test.ts +86 -0
  91. package/Tests/Types/Telemetry/ResourceEntityFacet.test.ts +254 -0
  92. package/Tests/UI/Components/CardSelect.test.tsx +1021 -0
  93. package/Tests/UI/Components/MasterPage.test.tsx +36 -1
  94. package/Tests/Utils/Monitor/MonitorMetricType.test.ts +66 -1
  95. package/Tests/Utils/Monitor/NetworkTopologyUtil.test.ts +115 -6
  96. package/Tests/Utils/NetworkDevice/DeviceReachabilityUtil.test.ts +610 -0
  97. package/Tests/Utils/NetworkSite/SiteStatusRollupUtil.test.ts +114 -45
  98. package/Types/API/Hostname.ts +79 -0
  99. package/Types/Database/AccessControl/ColumnAccessControl.ts +33 -11
  100. package/Types/Database/AccessControl/ColumnBillingAccessControl.ts +33 -11
  101. package/Types/Database/DatabaseProperty.ts +38 -0
  102. package/Types/Database/TableColumn.ts +34 -7
  103. package/Types/Events/Recurring.ts +16 -1
  104. package/Types/Monitor/CustomCodeMonitor/CustomCodeMonitorResponse.ts +14 -2
  105. package/Types/Monitor/MonitorStep.ts +13 -1
  106. package/Types/Monitor/MonitorType.ts +345 -40
  107. package/Types/Monitor/SSLMonitor/SslMonitorResponse.ts +43 -0
  108. package/Types/StatusPage/SearchEngineIndexing.ts +61 -0
  109. package/Types/Telemetry/ResourceEntityFacet.ts +211 -0
  110. package/UI/Components/CardSelect/CardSelect.tsx +714 -105
  111. package/UI/Components/EmptyState/EmptyState.tsx +8 -1
  112. package/UI/Components/Footer/Footer.tsx +32 -6
  113. package/UI/Components/Forms/Fields/FormField.tsx +3 -0
  114. package/UI/Components/Forms/Types/Field.ts +8 -0
  115. package/UI/Components/Loader/PageLoader.tsx +29 -8
  116. package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +26 -4
  117. package/UI/Components/Markdown.tsx/LazyMarkdownViewer.tsx +17 -1
  118. package/UI/Components/MasterPage/MasterPage.tsx +24 -11
  119. package/Utils/Monitor/MonitorMetricType.ts +16 -1
  120. package/Utils/Monitor/NetworkTopologyUtil.ts +60 -8
  121. package/Utils/NetworkDevice/DeviceReachabilityUtil.ts +262 -0
  122. package/Utils/NetworkSite/SiteStatusRollupUtil.ts +41 -24
  123. package/Utils/Telemetry/CrossSignalScope.ts +75 -4
  124. package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js +20 -4
  125. package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js.map +1 -1
  126. package/build/dist/Models/DatabaseModels/NetworkDevice.js +66 -0
  127. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  128. package/build/dist/Models/DatabaseModels/StatusPage.js +60 -0
  129. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  130. package/build/dist/Models/DatabaseModels/StatusPageOidc.js +2 -0
  131. package/build/dist/Models/DatabaseModels/StatusPageOidc.js.map +1 -1
  132. package/build/dist/Models/DatabaseModels/WorkspaceProjectAuthToken.js.map +1 -1
  133. package/build/dist/Server/API/AIChatAPI.js +48 -35
  134. package/build/dist/Server/API/AIChatAPI.js.map +1 -1
  135. package/build/dist/Server/API/CommonAPI.js +59 -0
  136. package/build/dist/Server/API/CommonAPI.js.map +1 -1
  137. package/build/dist/Server/API/MicrosoftTeamsAPI.js +20 -6
  138. package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
  139. package/build/dist/Server/API/SlackAPI.js +13 -10
  140. package/build/dist/Server/API/SlackAPI.js.map +1 -1
  141. package/build/dist/Server/API/StatusPageAPI.js +745 -673
  142. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  143. package/build/dist/Server/API/TelemetryAPI.js +34 -3
  144. package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
  145. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787500000000-AddEnableSearchEngineIndexingToStatusPage.js +23 -0
  146. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787500000000-AddEnableSearchEngineIndexingToStatusPage.js.map +1 -0
  147. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787600000000-AddNetworkDeviceReachabilityColumns.js +34 -0
  148. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787600000000-AddNetworkDeviceReachabilityColumns.js.map +1 -0
  149. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  150. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  151. package/build/dist/Server/Middleware/UserAuthorization.js +6 -2
  152. package/build/dist/Server/Middleware/UserAuthorization.js.map +1 -1
  153. package/build/dist/Server/Services/ApiKeyPermissionService.js +90 -1
  154. package/build/dist/Server/Services/ApiKeyPermissionService.js.map +1 -1
  155. package/build/dist/Server/Services/DatabaseService.js +15 -6
  156. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  157. package/build/dist/Server/Services/LogAggregationService.js +2 -0
  158. package/build/dist/Server/Services/LogAggregationService.js.map +1 -1
  159. package/build/dist/Server/Services/LogService.js +16 -0
  160. package/build/dist/Server/Services/LogService.js.map +1 -1
  161. package/build/dist/Server/Services/NetworkSiteService.js +12 -0
  162. package/build/dist/Server/Services/NetworkSiteService.js.map +1 -1
  163. package/build/dist/Server/Services/SpanService.js +16 -0
  164. package/build/dist/Server/Services/SpanService.js.map +1 -1
  165. package/build/dist/Server/Services/TraceAggregationService.js +2 -0
  166. package/build/dist/Server/Services/TraceAggregationService.js.map +1 -1
  167. package/build/dist/Server/Services/WorkspaceNotificationRuleService.js +137 -164
  168. package/build/dist/Server/Services/WorkspaceNotificationRuleService.js.map +1 -1
  169. package/build/dist/Server/Types/AnalyticsDatabase/ModelPermission.js +9 -0
  170. package/build/dist/Server/Types/AnalyticsDatabase/ModelPermission.js.map +1 -1
  171. package/build/dist/Server/Types/Database/Permissions/AccessControlPermission.js +21 -13
  172. package/build/dist/Server/Types/Database/Permissions/AccessControlPermission.js.map +1 -1
  173. package/build/dist/Server/Types/Workflow/Components/Conditions/IfElse.js +8 -0
  174. package/build/dist/Server/Types/Workflow/Components/Conditions/IfElse.js.map +1 -1
  175. package/build/dist/Server/Types/Workflow/Components/JavaScript.js +8 -0
  176. package/build/dist/Server/Types/Workflow/Components/JavaScript.js.map +1 -1
  177. package/build/dist/Server/Utils/APIKey/AccessPermission.js +9 -32
  178. package/build/dist/Server/Utils/APIKey/AccessPermission.js.map +1 -1
  179. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +87 -0
  180. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  181. package/build/dist/Server/Utils/LogRedaction.js +449 -0
  182. package/build/dist/Server/Utils/LogRedaction.js.map +1 -0
  183. package/build/dist/Server/Utils/Logger.js +98 -45
  184. package/build/dist/Server/Utils/Logger.js.map +1 -1
  185. package/build/dist/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.js +65 -13
  186. package/build/dist/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.js.map +1 -1
  187. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +12 -1
  188. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  189. package/build/dist/Server/Utils/Monitor/MonitorResource.js +20 -3
  190. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  191. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js +27 -7
  192. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js.map +1 -1
  193. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCacheStore.js +29 -10
  194. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCacheStore.js.map +1 -1
  195. package/build/dist/Server/Utils/StartServer.js +10 -7
  196. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  197. package/build/dist/Server/Utils/StatusPageSearchEngineIndexing.js +24 -0
  198. package/build/dist/Server/Utils/StatusPageSearchEngineIndexing.js.map +1 -0
  199. package/build/dist/Server/Utils/Telemetry/ResourceEntityFilter.js +311 -0
  200. package/build/dist/Server/Utils/Telemetry/ResourceEntityFilter.js.map +1 -0
  201. package/build/dist/Server/Utils/VM/VMRunner.js +402 -525
  202. package/build/dist/Server/Utils/VM/VMRunner.js.map +1 -1
  203. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +366 -37
  204. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  205. package/build/dist/Types/API/Hostname.js +63 -0
  206. package/build/dist/Types/API/Hostname.js.map +1 -1
  207. package/build/dist/Types/Database/AccessControl/ColumnAccessControl.js +20 -6
  208. package/build/dist/Types/Database/AccessControl/ColumnAccessControl.js.map +1 -1
  209. package/build/dist/Types/Database/AccessControl/ColumnBillingAccessControl.js +20 -6
  210. package/build/dist/Types/Database/AccessControl/ColumnBillingAccessControl.js.map +1 -1
  211. package/build/dist/Types/Database/DatabaseProperty.js +38 -0
  212. package/build/dist/Types/Database/DatabaseProperty.js.map +1 -1
  213. package/build/dist/Types/Database/TableColumn.js +24 -6
  214. package/build/dist/Types/Database/TableColumn.js.map +1 -1
  215. package/build/dist/Types/Events/Recurring.js +13 -1
  216. package/build/dist/Types/Events/Recurring.js.map +1 -1
  217. package/build/dist/Types/Monitor/MonitorStep.js +9 -1
  218. package/build/dist/Types/Monitor/MonitorStep.js.map +1 -1
  219. package/build/dist/Types/Monitor/MonitorType.js +330 -31
  220. package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
  221. package/build/dist/Types/StatusPage/SearchEngineIndexing.js +55 -0
  222. package/build/dist/Types/StatusPage/SearchEngineIndexing.js.map +1 -0
  223. package/build/dist/Types/Telemetry/ResourceEntityFacet.js +156 -0
  224. package/build/dist/Types/Telemetry/ResourceEntityFacet.js.map +1 -0
  225. package/build/dist/UI/Components/CardSelect/CardSelect.js +360 -40
  226. package/build/dist/UI/Components/CardSelect/CardSelect.js.map +1 -1
  227. package/build/dist/UI/Components/EmptyState/EmptyState.js +1 -1
  228. package/build/dist/UI/Components/EmptyState/EmptyState.js.map +1 -1
  229. package/build/dist/UI/Components/Footer/Footer.js +7 -4
  230. package/build/dist/UI/Components/Footer/Footer.js.map +1 -1
  231. package/build/dist/UI/Components/Forms/Fields/FormField.js +1 -1
  232. package/build/dist/UI/Components/Forms/Fields/FormField.js.map +1 -1
  233. package/build/dist/UI/Components/Loader/PageLoader.js +15 -5
  234. package/build/dist/UI/Components/Loader/PageLoader.js.map +1 -1
  235. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +16 -3
  236. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -1
  237. package/build/dist/UI/Components/Markdown.tsx/LazyMarkdownViewer.js +13 -1
  238. package/build/dist/UI/Components/Markdown.tsx/LazyMarkdownViewer.js.map +1 -1
  239. package/build/dist/UI/Components/MasterPage/MasterPage.js +10 -2
  240. package/build/dist/UI/Components/MasterPage/MasterPage.js.map +1 -1
  241. package/build/dist/Utils/Monitor/MonitorMetricType.js +13 -1
  242. package/build/dist/Utils/Monitor/MonitorMetricType.js.map +1 -1
  243. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +34 -7
  244. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  245. package/build/dist/Utils/NetworkDevice/DeviceReachabilityUtil.js +177 -0
  246. package/build/dist/Utils/NetworkDevice/DeviceReachabilityUtil.js.map +1 -0
  247. package/build/dist/Utils/NetworkSite/SiteStatusRollupUtil.js +23 -27
  248. package/build/dist/Utils/NetworkSite/SiteStatusRollupUtil.js.map +1 -1
  249. package/build/dist/Utils/Telemetry/CrossSignalScope.js +44 -4
  250. package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -1
  251. package/package.json +1 -1
@@ -0,0 +1,778 @@
1
+ import { describe, expect, test, afterEach, beforeEach } from "@jest/globals";
2
+
3
+ /*
4
+ * The seam between "we saw the app get installed" and "we are allowed to post".
5
+ *
6
+ * A Microsoft Teams team has TWO ids and they are not interchangeable:
7
+ *
8
+ * channelData.team.id "19:...@thread.tacv2" what bot activities carry
9
+ * channelData.team.aadGroupId a GUID what Graph calls the team id
10
+ *
11
+ * Installs were captured under the thread id and looked up under the Graph id,
12
+ * so the lookup could never hit. Nothing caught it, because the write side and
13
+ * the read side were tested in separate files that each picked their own
14
+ * placeholder id and then wrote AND read with it — internally consistent, and
15
+ * blind to the fact that production feeds one side's output into the other.
16
+ *
17
+ * So these tests never name an id twice. Every one of them drives the real
18
+ * capture path with a realistic activity, keeps whatever it chose to store, and
19
+ * feeds that straight into the send path using only the id a notification rule
20
+ * would actually hold — the Graph team id from the team picker. If the two sides
21
+ * ever disagree about id space again, these fail.
22
+ */
23
+
24
+ jest.mock("../../../../Server/EnvironmentConfig", () => {
25
+ return {
26
+ ...(jest.requireActual("../../../../Server/EnvironmentConfig") as Record<
27
+ string,
28
+ unknown
29
+ >),
30
+ MicrosoftTeamsAppClientId: "11111111-2222-3333-4444-555555555555",
31
+ MicrosoftTeamsAppClientSecret: "test-secret",
32
+ MicrosoftTeamsAppTenantId: "test-tenant",
33
+ };
34
+ });
35
+
36
+ jest.mock("botbuilder", () => {
37
+ return {
38
+ CloudAdapter: class CloudAdapter {},
39
+ ConfigurationBotFrameworkAuthentication: class ConfigurationBotFrameworkAuthentication {},
40
+ TeamsActivityHandler: class TeamsActivityHandler {},
41
+ TurnContext: class TurnContext {},
42
+ ActivityHandler: class ActivityHandler {},
43
+ MessageFactory: {
44
+ text: jest.fn(),
45
+ attachment: jest.fn((attachment: unknown) => {
46
+ return { type: "message", attachments: [attachment] };
47
+ }),
48
+ },
49
+ CardFactory: { heroCard: jest.fn() },
50
+ TeamsInfo: {
51
+ getMembers: jest.fn(),
52
+ getPagedMembers: jest.fn(),
53
+ getTeamDetails: jest.fn(),
54
+ },
55
+ };
56
+ });
57
+
58
+ import MicrosoftTeamsUtil, {
59
+ MicrosoftTeamsAppInstallState,
60
+ } from "../../../../Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams";
61
+ import WorkspaceProjectAuthTokenService from "../../../../Server/Services/WorkspaceProjectAuthTokenService";
62
+ import WorkspaceProjectAuthToken, {
63
+ MicrosoftTeamsInstalledTeam,
64
+ MicrosoftTeamsMiscData,
65
+ } from "../../../../Models/DatabaseModels/WorkspaceProjectAuthToken";
66
+ import WorkspaceType from "../../../../Types/Workspace/WorkspaceType";
67
+ import ObjectID from "../../../../Types/ObjectID";
68
+ import { JSONObject } from "../../../../Types/JSON";
69
+ import { WorkspaceChannel } from "../../../../Server/Utils/Workspace/WorkspaceBase";
70
+ import { TeamsInfo, type ConversationReference } from "botbuilder";
71
+
72
+ /*
73
+ * Realistic ids. The two forms are deliberately unrelated strings so that a
74
+ * mix-up cannot accidentally pass — this is exactly what the previous fixtures,
75
+ * where both sides used the same placeholder, could not detect.
76
+ */
77
+ const TENANT_ID: string = "aaaaaaaa-1111-2222-3333-444444444444";
78
+ const GRAPH_TEAM_ID: string = "bbbbbbbb-5555-6666-7777-888888888888";
79
+ const TEAM_THREAD_ID: string = "19:qWeRtY_teamThread@thread.tacv2";
80
+ const CHANNEL_ID: string = "19:aSdFgH_channelThread@thread.tacv2";
81
+ const ACTIVITY_SERVICE_URL: string = "https://smba.trafficmanager.net/in/";
82
+ const FALLBACK_SERVICE_URL: string = "https://smba.trafficmanager.net/teams/";
83
+
84
+ const ADAPTIVE_CARD: JSONObject = { type: "AdaptiveCard", body: [] };
85
+
86
+ interface CapturedSend {
87
+ refs: Array<ConversationReference>;
88
+ continueConversationAsync: jest.Mock;
89
+ }
90
+
91
+ /*
92
+ * A channel-scoped bot activity as Microsoft actually sends it — both team ids
93
+ * present, tenant on channelData, regional serviceUrl.
94
+ */
95
+ function teamChannelActivity(overrides?: {
96
+ team?: JSONObject | undefined;
97
+ serviceUrl?: string | undefined;
98
+ }): JSONObject {
99
+ return {
100
+ type: "conversationUpdate",
101
+ serviceUrl:
102
+ "serviceUrl" in (overrides || {})
103
+ ? overrides!.serviceUrl
104
+ : ACTIVITY_SERVICE_URL,
105
+ conversation: { conversationType: "channel", id: CHANNEL_ID },
106
+ channelData: {
107
+ team:
108
+ overrides && "team" in overrides
109
+ ? overrides.team
110
+ : {
111
+ id: TEAM_THREAD_ID,
112
+ name: "WBNOC",
113
+ aadGroupId: GRAPH_TEAM_ID,
114
+ },
115
+ tenant: { id: TENANT_ID },
116
+ channel: { id: CHANNEL_ID },
117
+ },
118
+ };
119
+ }
120
+
121
+ function buildTurnContext(serviceUrl?: string): any {
122
+ return {
123
+ activity: {
124
+ serviceUrl: serviceUrl === undefined ? ACTIVITY_SERVICE_URL : serviceUrl,
125
+ },
126
+ };
127
+ }
128
+
129
+ /*
130
+ * Runs the real capture path and returns exactly what it decided to persist. No
131
+ * assumption is made here about the key or the field layout — that is the thing
132
+ * under test.
133
+ */
134
+ async function captureInstall(
135
+ activity: JSONObject,
136
+ ): Promise<Record<string, MicrosoftTeamsInstalledTeam>> {
137
+ let stored: Record<string, MicrosoftTeamsInstalledTeam> = {};
138
+
139
+ jest
140
+ .spyOn(MicrosoftTeamsUtil, "saveTeamToProjectAuthTokens")
141
+ .mockImplementation(
142
+ async (data: {
143
+ tenantId: string;
144
+ team: MicrosoftTeamsInstalledTeam;
145
+ }): Promise<void> => {
146
+ stored = { [data.team.id]: data.team };
147
+ },
148
+ );
149
+
150
+ await MicrosoftTeamsUtil.captureTeamFromBotActivity({
151
+ activity: activity,
152
+ turnContext: buildTurnContext(),
153
+ });
154
+
155
+ return stored;
156
+ }
157
+
158
+ function mockProjectAuthWith(
159
+ installedTeams: Record<string, MicrosoftTeamsInstalledTeam>,
160
+ ): void {
161
+ const miscData: MicrosoftTeamsMiscData = {
162
+ tenantId: TENANT_ID,
163
+ teamId: GRAPH_TEAM_ID,
164
+ teamName: "WBNOC",
165
+ botId: "11111111-2222-3333-4444-555555555555",
166
+ installedTeams: installedTeams,
167
+ } as MicrosoftTeamsMiscData;
168
+
169
+ jest
170
+ .spyOn(WorkspaceProjectAuthTokenService, "getProjectAuth")
171
+ .mockResolvedValue({
172
+ id: ObjectID.generate(),
173
+ workspaceProjectId: TENANT_ID,
174
+ miscData: miscData,
175
+ } as unknown as WorkspaceProjectAuthToken);
176
+ }
177
+
178
+ function installFakeBotAdapter(): CapturedSend {
179
+ const refs: Array<ConversationReference> = [];
180
+ const continueConversationAsync: jest.Mock = jest.fn(
181
+ async (
182
+ _appId: string,
183
+ ref: ConversationReference,
184
+ cb: (context: any) => Promise<void>,
185
+ ): Promise<void> => {
186
+ refs.push(ref);
187
+ await cb({
188
+ sendActivity: async (): Promise<JSONObject> => {
189
+ return { id: "msg-1" };
190
+ },
191
+ });
192
+ },
193
+ );
194
+
195
+ jest.spyOn(MicrosoftTeamsUtil as any, "getBotAdapter").mockReturnValue({
196
+ continueConversationAsync: continueConversationAsync,
197
+ });
198
+
199
+ return { refs: refs, continueConversationAsync: continueConversationAsync };
200
+ }
201
+
202
+ function channel(): WorkspaceChannel {
203
+ return {
204
+ id: CHANNEL_ID,
205
+ name: "WBNOC Test Channel",
206
+ workspaceType: WorkspaceType.MicrosoftTeams,
207
+ teamId: GRAPH_TEAM_ID,
208
+ };
209
+ }
210
+
211
+ /*
212
+ * The send as a notification rule triggers it: teamId is the value the team
213
+ * picker stored, which comes from Graph GET /teams — never a thread id.
214
+ */
215
+ function sendAsNotificationRuleWould(): Promise<unknown> {
216
+ return MicrosoftTeamsUtil.sendAdaptiveCardToChannel({
217
+ authToken: "auth-token",
218
+ teamId: GRAPH_TEAM_ID,
219
+ workspaceChannel: channel(),
220
+ adaptiveCard: ADAPTIVE_CARD,
221
+ projectId: ObjectID.generate(),
222
+ });
223
+ }
224
+
225
+ beforeEach(() => {
226
+ (TeamsInfo.getTeamDetails as jest.Mock).mockReset();
227
+ /*
228
+ * Force the Graph fallback to fail so nothing in this file can pass by way of
229
+ * the install check. A send that succeeds here succeeded because the captured
230
+ * record matched, which is the only thing being asserted.
231
+ */
232
+ jest
233
+ .spyOn(MicrosoftTeamsUtil, "isAppInstalledInTeam")
234
+ .mockResolvedValue(MicrosoftTeamsAppInstallState.NotInstalled);
235
+ });
236
+
237
+ afterEach(() => {
238
+ jest.restoreAllMocks();
239
+ });
240
+
241
+ describe("Microsoft Teams install capture and channel send agree on the team id space", () => {
242
+ test("an install captured from a bot activity permits the send a notification rule performs", async () => {
243
+ /*
244
+ * THE regression test for this bug. The app is installed; Teams tells us so;
245
+ * the rule then posts using the Graph team id. Before the fix the captured
246
+ * record was keyed by the thread id, the lookup missed, and the admin was
247
+ * told to install an app that was already installed.
248
+ */
249
+ const installedTeams: Record<string, MicrosoftTeamsInstalledTeam> =
250
+ await captureInstall(teamChannelActivity());
251
+
252
+ mockProjectAuthWith(installedTeams);
253
+ const adapter: CapturedSend = installFakeBotAdapter();
254
+
255
+ await sendAsNotificationRuleWould();
256
+
257
+ expect(adapter.continueConversationAsync).toHaveBeenCalledTimes(1);
258
+ });
259
+
260
+ test("the captured record is reachable by the Graph team id, which is what every caller holds", async () => {
261
+ const installedTeams: Record<string, MicrosoftTeamsInstalledTeam> =
262
+ await captureInstall(teamChannelActivity());
263
+
264
+ const index: Record<string, MicrosoftTeamsInstalledTeam> =
265
+ MicrosoftTeamsUtil.indexInstalledTeamsByGraphTeamId(installedTeams);
266
+
267
+ expect(Object.keys(index)).toEqual([GRAPH_TEAM_ID]);
268
+ });
269
+
270
+ test("the captured record keeps the thread id too, so uninstall can still find it", async () => {
271
+ const installedTeams: Record<string, MicrosoftTeamsInstalledTeam> =
272
+ await captureInstall(teamChannelActivity());
273
+
274
+ const record: MicrosoftTeamsInstalledTeam =
275
+ Object.values(installedTeams)[0]!;
276
+
277
+ expect(record.teamsThreadId).toBe(TEAM_THREAD_ID);
278
+ expect(record.graphTeamId).toBe(GRAPH_TEAM_ID);
279
+ });
280
+
281
+ test("a captured install is never keyed by the thread id when the Graph id is known", async () => {
282
+ const installedTeams: Record<string, MicrosoftTeamsInstalledTeam> =
283
+ await captureInstall(teamChannelActivity());
284
+
285
+ expect(Object.keys(installedTeams)).not.toContain(TEAM_THREAD_ID);
286
+ });
287
+
288
+ test("the serviceUrl captured on install reaches the send, instead of the global fallback", async () => {
289
+ /*
290
+ * This never worked either: the serviceUrl was captured but read through the
291
+ * same broken lookup, so `installedTeam?.serviceUrl` was always undefined
292
+ * and every proactive post went to the commercial-cloud endpoint. That is
293
+ * the wrong host for a GCC/DoD tenant, and for a regional one it is the
294
+ * hardcoded guess Microsoft's docs tell you not to make.
295
+ */
296
+ const installedTeams: Record<string, MicrosoftTeamsInstalledTeam> =
297
+ await captureInstall(teamChannelActivity());
298
+
299
+ mockProjectAuthWith(installedTeams);
300
+ const adapter: CapturedSend = installFakeBotAdapter();
301
+
302
+ await sendAsNotificationRuleWould();
303
+
304
+ expect(adapter.refs[0]!.serviceUrl).toBe(ACTIVITY_SERVICE_URL);
305
+ expect(adapter.refs[0]!.serviceUrl).not.toBe(FALLBACK_SERVICE_URL);
306
+ });
307
+
308
+ test("an activity without aadGroupId falls back to TeamsInfo, and the send still works", async () => {
309
+ (TeamsInfo.getTeamDetails as jest.Mock).mockResolvedValue({
310
+ id: TEAM_THREAD_ID,
311
+ name: "WBNOC",
312
+ aadGroupId: GRAPH_TEAM_ID,
313
+ });
314
+
315
+ const installedTeams: Record<string, MicrosoftTeamsInstalledTeam> =
316
+ await captureInstall(
317
+ teamChannelActivity({
318
+ team: { id: TEAM_THREAD_ID, name: "WBNOC" },
319
+ }),
320
+ );
321
+
322
+ mockProjectAuthWith(installedTeams);
323
+ const adapter: CapturedSend = installFakeBotAdapter();
324
+
325
+ await sendAsNotificationRuleWould();
326
+
327
+ expect(TeamsInfo.getTeamDetails as jest.Mock).toHaveBeenCalledTimes(1);
328
+ expect(adapter.continueConversationAsync).toHaveBeenCalledTimes(1);
329
+ });
330
+
331
+ test("an unresolvable Graph id keeps the install on record but does not vouch for the team", async () => {
332
+ /*
333
+ * getTeamDetails is what fails on an uninstall, and can fail on a transient
334
+ * error. The record is still worth keeping — it is how uninstall finds the
335
+ * team — but it must not be silently treated as an install of some Graph
336
+ * team id, which is the class of confusion this whole change removes.
337
+ */
338
+ (TeamsInfo.getTeamDetails as jest.Mock).mockRejectedValue(
339
+ new Error("The bot is not part of the conversation roster."),
340
+ );
341
+
342
+ const installedTeams: Record<string, MicrosoftTeamsInstalledTeam> =
343
+ await captureInstall(
344
+ teamChannelActivity({
345
+ team: { id: TEAM_THREAD_ID, name: "WBNOC" },
346
+ }),
347
+ );
348
+
349
+ expect(Object.keys(installedTeams)).toEqual([TEAM_THREAD_ID]);
350
+ expect(
351
+ MicrosoftTeamsUtil.indexInstalledTeamsByGraphTeamId(installedTeams),
352
+ ).toEqual({});
353
+ });
354
+
355
+ test("re-capturing a team that was stored under its thread id leaves exactly one record", async () => {
356
+ /*
357
+ * The upgrade path. A workspace that ran the old code has thread-id-keyed
358
+ * records; the @mention backfill re-captures them, now with a Graph id. Two
359
+ * records for one team would mean one that can never be matched or removed.
360
+ */
361
+ const legacyRecord: MicrosoftTeamsInstalledTeam = {
362
+ id: TEAM_THREAD_ID,
363
+ name: "WBNOC",
364
+ serviceUrl: ACTIVITY_SERVICE_URL,
365
+ addedAt: "2026-08-01T00:00:00.000Z",
366
+ };
367
+
368
+ let stored: Record<string, MicrosoftTeamsInstalledTeam> = {
369
+ [TEAM_THREAD_ID]: legacyRecord,
370
+ };
371
+
372
+ jest.spyOn(WorkspaceProjectAuthTokenService, "findBy").mockResolvedValue([
373
+ {
374
+ id: ObjectID.generate(),
375
+ miscData: { installedTeams: stored } as MicrosoftTeamsMiscData,
376
+ },
377
+ ] as unknown as Array<WorkspaceProjectAuthToken>);
378
+
379
+ jest
380
+ .spyOn(WorkspaceProjectAuthTokenService, "updateOneById")
381
+ .mockImplementation(async (args: any): Promise<number> => {
382
+ stored = args.data.miscData.installedTeams;
383
+ return 1;
384
+ });
385
+
386
+ await MicrosoftTeamsUtil.captureTeamFromBotActivity({
387
+ activity: teamChannelActivity(),
388
+ turnContext: buildTurnContext(),
389
+ });
390
+
391
+ expect(Object.keys(stored)).toEqual([GRAPH_TEAM_ID]);
392
+ expect(stored[GRAPH_TEAM_ID]!.teamsThreadId).toBe(TEAM_THREAD_ID);
393
+ });
394
+
395
+ test("a team installed in one tenant does not vouch for a same-named team elsewhere", async () => {
396
+ const installedTeams: Record<string, MicrosoftTeamsInstalledTeam> =
397
+ await captureInstall(teamChannelActivity());
398
+
399
+ mockProjectAuthWith(installedTeams);
400
+ installFakeBotAdapter();
401
+
402
+ // A different Graph team id — the picker's other team — must not match.
403
+ await expect(
404
+ MicrosoftTeamsUtil.sendAdaptiveCardToChannel({
405
+ authToken: "auth-token",
406
+ teamId: "cccccccc-9999-0000-1111-222222222222",
407
+ workspaceChannel: channel(),
408
+ adaptiveCard: ADAPTIVE_CARD,
409
+ projectId: ObjectID.generate(),
410
+ }),
411
+ ).rejects.toThrow(/is not installed in the Microsoft Teams team/);
412
+ });
413
+ });
414
+
415
+ describe("MicrosoftTeamsUtil.indexInstalledTeamsByGraphTeamId", () => {
416
+ test("undefined and empty maps produce an empty index", () => {
417
+ expect(
418
+ MicrosoftTeamsUtil.indexInstalledTeamsByGraphTeamId(undefined),
419
+ ).toEqual({});
420
+ expect(MicrosoftTeamsUtil.indexInstalledTeamsByGraphTeamId({})).toEqual({});
421
+ });
422
+
423
+ test("records are keyed by graphTeamId regardless of the map key they were stored under", () => {
424
+ const record: MicrosoftTeamsInstalledTeam = {
425
+ id: "whatever-key",
426
+ graphTeamId: GRAPH_TEAM_ID,
427
+ teamsThreadId: TEAM_THREAD_ID,
428
+ };
429
+
430
+ expect(
431
+ MicrosoftTeamsUtil.indexInstalledTeamsByGraphTeamId({
432
+ "whatever-key": record,
433
+ }),
434
+ ).toEqual({ [GRAPH_TEAM_ID]: record });
435
+ });
436
+
437
+ test("records with no graphTeamId are dropped rather than indexed by a thread id", () => {
438
+ expect(
439
+ MicrosoftTeamsUtil.indexInstalledTeamsByGraphTeamId({
440
+ [TEAM_THREAD_ID]: {
441
+ id: TEAM_THREAD_ID,
442
+ } as MicrosoftTeamsInstalledTeam,
443
+ }),
444
+ ).toEqual({});
445
+ });
446
+
447
+ test("resolved and unresolved records coexist; only the resolved one is indexed", () => {
448
+ const resolved: MicrosoftTeamsInstalledTeam = {
449
+ id: GRAPH_TEAM_ID,
450
+ graphTeamId: GRAPH_TEAM_ID,
451
+ };
452
+
453
+ expect(
454
+ MicrosoftTeamsUtil.indexInstalledTeamsByGraphTeamId({
455
+ [GRAPH_TEAM_ID]: resolved,
456
+ "19:other@thread.tacv2": {
457
+ id: "19:other@thread.tacv2",
458
+ } as MicrosoftTeamsInstalledTeam,
459
+ }),
460
+ ).toEqual({ [GRAPH_TEAM_ID]: resolved });
461
+ });
462
+ });
463
+
464
+ describe("MicrosoftTeamsUtil.isSameInstalledTeam", () => {
465
+ test("matches on graphTeamId", () => {
466
+ expect(
467
+ MicrosoftTeamsUtil.isSameInstalledTeam({
468
+ team: { id: GRAPH_TEAM_ID, graphTeamId: GRAPH_TEAM_ID },
469
+ graphTeamId: GRAPH_TEAM_ID,
470
+ }),
471
+ ).toBe(true);
472
+ });
473
+
474
+ test("matches on teamsThreadId", () => {
475
+ expect(
476
+ MicrosoftTeamsUtil.isSameInstalledTeam({
477
+ team: { id: GRAPH_TEAM_ID, teamsThreadId: TEAM_THREAD_ID },
478
+ teamsThreadId: TEAM_THREAD_ID,
479
+ }),
480
+ ).toBe(true);
481
+ });
482
+
483
+ test("matches a legacy record whose thread id is only in the key field", () => {
484
+ expect(
485
+ MicrosoftTeamsUtil.isSameInstalledTeam({
486
+ team: { id: TEAM_THREAD_ID },
487
+ teamsThreadId: TEAM_THREAD_ID,
488
+ }),
489
+ ).toBe(true);
490
+ });
491
+
492
+ test("matches a legacy record by graph id when that is what it was keyed by", () => {
493
+ expect(
494
+ MicrosoftTeamsUtil.isSameInstalledTeam({
495
+ team: { id: GRAPH_TEAM_ID },
496
+ graphTeamId: GRAPH_TEAM_ID,
497
+ }),
498
+ ).toBe(true);
499
+ });
500
+
501
+ test("does not match a different team", () => {
502
+ expect(
503
+ MicrosoftTeamsUtil.isSameInstalledTeam({
504
+ team: { id: GRAPH_TEAM_ID, graphTeamId: GRAPH_TEAM_ID },
505
+ graphTeamId: "cccccccc-9999-0000-1111-222222222222",
506
+ teamsThreadId: "19:someone-else@thread.tacv2",
507
+ }),
508
+ ).toBe(false);
509
+ });
510
+
511
+ test("does not cross id spaces — a thread id never matches a graph id field", () => {
512
+ expect(
513
+ MicrosoftTeamsUtil.isSameInstalledTeam({
514
+ team: { id: GRAPH_TEAM_ID, graphTeamId: GRAPH_TEAM_ID },
515
+ teamsThreadId: GRAPH_TEAM_ID,
516
+ }),
517
+ ).toBe(true); // id equals the value, so this is a legitimate key match
518
+ expect(
519
+ MicrosoftTeamsUtil.isSameInstalledTeam({
520
+ team: { id: "some-key", graphTeamId: GRAPH_TEAM_ID },
521
+ teamsThreadId: GRAPH_TEAM_ID,
522
+ }),
523
+ ).toBe(false);
524
+ });
525
+
526
+ test("matching is case-sensitive, as Microsoft's ids are", () => {
527
+ expect(
528
+ MicrosoftTeamsUtil.isSameInstalledTeam({
529
+ team: { id: GRAPH_TEAM_ID, graphTeamId: GRAPH_TEAM_ID },
530
+ graphTeamId: GRAPH_TEAM_ID.toUpperCase(),
531
+ }),
532
+ ).toBe(false);
533
+ });
534
+
535
+ test("an undefined record never matches", () => {
536
+ expect(
537
+ MicrosoftTeamsUtil.isSameInstalledTeam({
538
+ team: undefined,
539
+ graphTeamId: GRAPH_TEAM_ID,
540
+ }),
541
+ ).toBe(false);
542
+ });
543
+
544
+ test("no ids to match on means no match, rather than matching everything", () => {
545
+ expect(
546
+ MicrosoftTeamsUtil.isSameInstalledTeam({
547
+ team: { id: GRAPH_TEAM_ID, graphTeamId: GRAPH_TEAM_ID },
548
+ }),
549
+ ).toBe(false);
550
+ });
551
+ });
552
+
553
+ /*
554
+ * The message-driven backfill fires on EVERY inbound channel message, so it has
555
+ * to be cheap once the team is on record. Install events themselves always
556
+ * re-record: that is when the serviceUrl and the group id are freshest.
557
+ */
558
+ describe("captureTeamFromBotActivity onlyIfMissingOrStale", () => {
559
+ function mockStoredTeams(
560
+ installedTeams: Record<string, MicrosoftTeamsInstalledTeam>,
561
+ ): { saveSpy: jest.SpyInstance } {
562
+ jest.spyOn(WorkspaceProjectAuthTokenService, "findBy").mockResolvedValue([
563
+ {
564
+ id: ObjectID.generate(),
565
+ miscData: { installedTeams: installedTeams } as MicrosoftTeamsMiscData,
566
+ },
567
+ ] as unknown as Array<WorkspaceProjectAuthToken>);
568
+
569
+ const saveSpy: jest.SpyInstance = jest
570
+ .spyOn(MicrosoftTeamsUtil, "saveTeamToProjectAuthTokens")
571
+ .mockResolvedValue(undefined as never);
572
+
573
+ return { saveSpy: saveSpy };
574
+ }
575
+
576
+ function fullyRecordedTeam(): MicrosoftTeamsInstalledTeam {
577
+ return {
578
+ id: GRAPH_TEAM_ID,
579
+ graphTeamId: GRAPH_TEAM_ID,
580
+ teamsThreadId: TEAM_THREAD_ID,
581
+ serviceUrl: ACTIVITY_SERVICE_URL,
582
+ addedAt: "2026-08-01T00:00:00.000Z",
583
+ };
584
+ }
585
+
586
+ test("an already-recorded team is not rewritten", async () => {
587
+ const { saveSpy } = mockStoredTeams({
588
+ [GRAPH_TEAM_ID]: fullyRecordedTeam(),
589
+ });
590
+
591
+ await MicrosoftTeamsUtil.captureTeamFromBotActivity({
592
+ activity: teamChannelActivity(),
593
+ turnContext: buildTurnContext(),
594
+ onlyIfMissingOrStale: true,
595
+ });
596
+
597
+ expect(saveSpy).not.toHaveBeenCalled();
598
+ });
599
+
600
+ test("an already-recorded team does not trigger a Bot Framework lookup", async () => {
601
+ /*
602
+ * The expensive case: an activity with no aadGroupId. Resolving it on every
603
+ * message would be one Bot Framework call per message in a busy channel.
604
+ */
605
+ mockStoredTeams({ [GRAPH_TEAM_ID]: fullyRecordedTeam() });
606
+
607
+ await MicrosoftTeamsUtil.captureTeamFromBotActivity({
608
+ activity: teamChannelActivity({
609
+ team: { id: TEAM_THREAD_ID, name: "WBNOC" },
610
+ }),
611
+ turnContext: buildTurnContext(),
612
+ onlyIfMissingOrStale: true,
613
+ });
614
+
615
+ expect(TeamsInfo.getTeamDetails as jest.Mock).not.toHaveBeenCalled();
616
+ });
617
+
618
+ test("a team that is not on record yet IS written", async () => {
619
+ const { saveSpy } = mockStoredTeams({});
620
+
621
+ await MicrosoftTeamsUtil.captureTeamFromBotActivity({
622
+ activity: teamChannelActivity(),
623
+ turnContext: buildTurnContext(),
624
+ onlyIfMissingOrStale: true,
625
+ });
626
+
627
+ expect(saveSpy).toHaveBeenCalledTimes(1);
628
+ });
629
+
630
+ test("a record missing its graph id is refreshed, so the upgrade path still works", async () => {
631
+ const { saveSpy } = mockStoredTeams({
632
+ [TEAM_THREAD_ID]: {
633
+ id: TEAM_THREAD_ID,
634
+ serviceUrl: ACTIVITY_SERVICE_URL,
635
+ } as MicrosoftTeamsInstalledTeam,
636
+ });
637
+
638
+ await MicrosoftTeamsUtil.captureTeamFromBotActivity({
639
+ activity: teamChannelActivity(),
640
+ turnContext: buildTurnContext(),
641
+ onlyIfMissingOrStale: true,
642
+ });
643
+
644
+ expect(saveSpy).toHaveBeenCalledTimes(1);
645
+ const saved: MicrosoftTeamsInstalledTeam = (
646
+ saveSpy.mock.calls[0]?.[0] as { team: MicrosoftTeamsInstalledTeam }
647
+ ).team;
648
+ expect(saved.graphTeamId).toBe(GRAPH_TEAM_ID);
649
+ });
650
+
651
+ test("a moved serviceUrl is refreshed", async () => {
652
+ /*
653
+ * Microsoft's docs say to re-verify the stored serviceUrl when a new message
654
+ * arrives; a stale one sends proactive posts to the wrong regional host.
655
+ */
656
+ const { saveSpy } = mockStoredTeams({
657
+ [GRAPH_TEAM_ID]: {
658
+ ...fullyRecordedTeam(),
659
+ serviceUrl: "https://smba.trafficmanager.net/emea/",
660
+ },
661
+ });
662
+
663
+ await MicrosoftTeamsUtil.captureTeamFromBotActivity({
664
+ activity: teamChannelActivity(),
665
+ turnContext: buildTurnContext(),
666
+ onlyIfMissingOrStale: true,
667
+ });
668
+
669
+ expect(saveSpy).toHaveBeenCalledTimes(1);
670
+ const saved: MicrosoftTeamsInstalledTeam = (
671
+ saveSpy.mock.calls[0]?.[0] as { team: MicrosoftTeamsInstalledTeam }
672
+ ).team;
673
+ expect(saved.serviceUrl).toBe(ACTIVITY_SERVICE_URL);
674
+ });
675
+
676
+ test("without the flag, an install event always re-records", async () => {
677
+ const { saveSpy } = mockStoredTeams({
678
+ [GRAPH_TEAM_ID]: fullyRecordedTeam(),
679
+ });
680
+
681
+ await MicrosoftTeamsUtil.captureTeamFromBotActivity({
682
+ activity: teamChannelActivity(),
683
+ turnContext: buildTurnContext(),
684
+ });
685
+
686
+ expect(saveSpy).toHaveBeenCalledTimes(1);
687
+ });
688
+ });
689
+
690
+ describe("MicrosoftTeamsUtil.getInstalledTeamForTenant", () => {
691
+ function mockRows(
692
+ installedTeams: Record<string, MicrosoftTeamsInstalledTeam>,
693
+ ): void {
694
+ jest.spyOn(WorkspaceProjectAuthTokenService, "findBy").mockResolvedValue([
695
+ {
696
+ id: ObjectID.generate(),
697
+ miscData: { installedTeams: installedTeams } as MicrosoftTeamsMiscData,
698
+ },
699
+ ] as unknown as Array<WorkspaceProjectAuthToken>);
700
+ }
701
+
702
+ test("finds a record by graph team id", async () => {
703
+ const team: MicrosoftTeamsInstalledTeam = {
704
+ id: GRAPH_TEAM_ID,
705
+ graphTeamId: GRAPH_TEAM_ID,
706
+ };
707
+ mockRows({ [GRAPH_TEAM_ID]: team });
708
+
709
+ await expect(
710
+ MicrosoftTeamsUtil.getInstalledTeamForTenant({
711
+ tenantId: TENANT_ID,
712
+ graphTeamId: GRAPH_TEAM_ID,
713
+ }),
714
+ ).resolves.toEqual(team);
715
+ });
716
+
717
+ test("finds a legacy record by thread id", async () => {
718
+ const team: MicrosoftTeamsInstalledTeam = { id: TEAM_THREAD_ID };
719
+ mockRows({ [TEAM_THREAD_ID]: team });
720
+
721
+ await expect(
722
+ MicrosoftTeamsUtil.getInstalledTeamForTenant({
723
+ tenantId: TENANT_ID,
724
+ teamsThreadId: TEAM_THREAD_ID,
725
+ }),
726
+ ).resolves.toEqual(team);
727
+ });
728
+
729
+ test("returns null when the tenant has no matching team", async () => {
730
+ mockRows({
731
+ "other-team": {
732
+ id: "other-team",
733
+ graphTeamId: "other-team",
734
+ },
735
+ });
736
+
737
+ await expect(
738
+ MicrosoftTeamsUtil.getInstalledTeamForTenant({
739
+ tenantId: TENANT_ID,
740
+ graphTeamId: GRAPH_TEAM_ID,
741
+ teamsThreadId: TEAM_THREAD_ID,
742
+ }),
743
+ ).resolves.toBeNull();
744
+ });
745
+
746
+ test("returns null when the tenant has no rows at all", async () => {
747
+ jest
748
+ .spyOn(WorkspaceProjectAuthTokenService, "findBy")
749
+ .mockResolvedValue([]);
750
+
751
+ await expect(
752
+ MicrosoftTeamsUtil.getInstalledTeamForTenant({
753
+ tenantId: TENANT_ID,
754
+ graphTeamId: GRAPH_TEAM_ID,
755
+ }),
756
+ ).resolves.toBeNull();
757
+ });
758
+
759
+ test("queries by MicrosoftTeams workspace type and the tenant id", async () => {
760
+ const findBySpy: jest.SpyInstance = jest
761
+ .spyOn(WorkspaceProjectAuthTokenService, "findBy")
762
+ .mockResolvedValue([]);
763
+
764
+ await MicrosoftTeamsUtil.getInstalledTeamForTenant({
765
+ tenantId: TENANT_ID,
766
+ graphTeamId: GRAPH_TEAM_ID,
767
+ });
768
+
769
+ expect(findBySpy).toHaveBeenCalledWith(
770
+ expect.objectContaining({
771
+ query: {
772
+ workspaceType: WorkspaceType.MicrosoftTeams,
773
+ workspaceProjectId: TENANT_ID,
774
+ },
775
+ }),
776
+ );
777
+ });
778
+ });