@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
@@ -63,7 +63,9 @@ jest.mock("botbuilder", () => {
63
63
  };
64
64
  });
65
65
 
66
- import MicrosoftTeamsUtil from "../../../../Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams";
66
+ import MicrosoftTeamsUtil, {
67
+ MicrosoftTeamsAppInstallState,
68
+ } from "../../../../Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams";
67
69
  import WorkspaceProjectAuthTokenService from "../../../../Server/Services/WorkspaceProjectAuthTokenService";
68
70
  import WorkspaceProjectAuthToken, {
69
71
  MicrosoftTeamsInstalledTeam,
@@ -73,6 +75,9 @@ import WorkspaceType from "../../../../Types/Workspace/WorkspaceType";
73
75
  import ObjectID from "../../../../Types/ObjectID";
74
76
  import BadDataException from "../../../../Types/Exception/BadDataException";
75
77
  import { JSONObject } from "../../../../Types/JSON";
78
+ import API from "../../../../Utils/API";
79
+ import HTTPErrorResponse from "../../../../Types/API/HTTPErrorResponse";
80
+ import HTTPResponse from "../../../../Types/API/HTTPResponse";
76
81
  import WorkspaceMessagePayload, {
77
82
  WorkspacePayloadMarkdown,
78
83
  } from "../../../../Types/Workspace/WorkspaceMessagePayload";
@@ -135,13 +140,24 @@ function baseMiscData(
135
140
  } as MicrosoftTeamsMiscData;
136
141
  }
137
142
 
143
+ /*
144
+ * A captured install, as captureTeamFromBotActivity writes it: keyed by the
145
+ * GRAPH team id, with the Bot Framework thread id kept alongside. `id` is the
146
+ * Graph id here because that is the only form the send path can match — see
147
+ * MicrosoftTeamsInstalledTeamIdSpace.test.ts, which derives these fixtures from
148
+ * the real capture path instead of hand-writing them.
149
+ */
138
150
  function buildInstalledTeam(data: {
139
151
  id: string;
140
152
  name?: string | undefined;
141
153
  serviceUrl?: string | undefined;
154
+ graphTeamId?: string | undefined;
155
+ teamsThreadId?: string | undefined;
142
156
  }): MicrosoftTeamsInstalledTeam {
143
157
  const installedTeam: MicrosoftTeamsInstalledTeam = {
144
158
  id: data.id,
159
+ graphTeamId: "graphTeamId" in data ? data.graphTeamId : data.id,
160
+ teamsThreadId: data.teamsThreadId || `19:${data.id}@thread.tacv2`,
145
161
  name: data.name || "Engineering",
146
162
  addedAt: "2026-07-27T00:00:00.000Z",
147
163
  };
@@ -151,6 +167,33 @@ function buildInstalledTeam(data: {
151
167
  return installedTeam;
152
168
  }
153
169
 
170
+ /*
171
+ * Pin the Graph installed-apps check. The send path consults it whenever it has
172
+ * no local install record, so leaving it live would put every such test on the
173
+ * network. Unknown is the honest default for a tenant that has not granted the
174
+ * permission, and it is also the pre-existing behaviour (fall through to
175
+ * Microsoft), which keeps these suites about one thing at a time.
176
+ */
177
+ let installStateSpy: jest.SpyInstance | undefined = undefined;
178
+
179
+ function mockInstallState(
180
+ state: MicrosoftTeamsAppInstallState = MicrosoftTeamsAppInstallState.Unknown,
181
+ ): jest.SpyInstance {
182
+ installStateSpy = jest
183
+ .spyOn(MicrosoftTeamsUtil, "isAppInstalledInTeam")
184
+ .mockResolvedValue(state);
185
+ return installStateSpy;
186
+ }
187
+
188
+ /*
189
+ * The suite that tests isAppInstalledInTeam itself has to undo the file-wide
190
+ * stub, or it would only ever assert against the stub.
191
+ */
192
+ function useRealInstallStateCheck(): void {
193
+ installStateSpy?.mockRestore();
194
+ installStateSpy = undefined;
195
+ }
196
+
154
197
  function buildChannel(data?: {
155
198
  id?: string | undefined;
156
199
  name?: string | undefined;
@@ -276,6 +319,7 @@ afterEach(() => {
276
319
  * mockResolvedValueOnce queues leak between tests unless reset here.
277
320
  */
278
321
  beforeEach(() => {
322
+ mockInstallState();
279
323
  (MessageFactory.text as jest.Mock).mockReset();
280
324
  (MessageFactory.attachment as jest.Mock).mockReset();
281
325
  (MessageFactory.attachment as jest.Mock).mockImplementation(
@@ -541,19 +585,46 @@ describe("MicrosoftTeamsUtil.sendAdaptiveCardToChannel - shared channel refusal"
541
585
  });
542
586
  });
543
587
 
588
+ /*
589
+ * The preflight decides whether to refuse locally or hand the send to Microsoft.
590
+ *
591
+ * It used to refuse purely on the absence of a local install record, which is
592
+ * not evidence: installedTeams is only written from bot activities, so a team
593
+ * the app was added to while OneUptime was unreachable — or before install
594
+ * capture shipped — looks identical to a team it was never added to. Admins who
595
+ * had followed every documented step were told to follow them again.
596
+ *
597
+ * A refusal now requires a POSITIVE answer from Graph that the app is absent.
598
+ * Installed and Unknown both proceed and let Microsoft, the only real authority
599
+ * on roster membership, decide.
600
+ */
544
601
  describe("MicrosoftTeamsUtil.sendAdaptiveCardToChannel - install preflight", () => {
545
- test("installedTeams undefined (legacy workspace) does NOT block the send", async () => {
546
- mockProjectAuth({ miscData: baseMiscData() });
602
+ test("a captured install for the target team proceeds without asking Graph", async () => {
603
+ mockProjectAuth({
604
+ installedTeams: {
605
+ [TEAM_ID]: buildInstalledTeam({ id: TEAM_ID }),
606
+ },
607
+ });
608
+ const installSpy: jest.SpyInstance = mockInstallState(
609
+ MicrosoftTeamsAppInstallState.NotInstalled,
610
+ );
547
611
  const adapter: FakeBotAdapter = installFakeBotAdapter();
548
612
 
549
- const thread: WorkspaceThread = await sendCardToChannel();
613
+ await sendCardToChannel();
550
614
 
551
615
  expect(adapter.continueConversationAsync).toHaveBeenCalledTimes(1);
552
- expect(thread.threadId).toBe("msg-123");
616
+ // The local record is the fast path; no Graph round trip per notification.
617
+ expect(installSpy).not.toHaveBeenCalled();
553
618
  });
554
619
 
555
- test("an EMPTY installedTeams map does NOT block the send", async () => {
556
- mockProjectAuth({ installedTeams: {} });
620
+ test("several installed teams, one of which is the target, proceeds", async () => {
621
+ mockProjectAuth({
622
+ installedTeams: {
623
+ "team-0": buildInstalledTeam({ id: "team-0" }),
624
+ [TEAM_ID]: buildInstalledTeam({ id: TEAM_ID }),
625
+ "team-2": buildInstalledTeam({ id: "team-2" }),
626
+ },
627
+ });
557
628
  const adapter: FakeBotAdapter = installFakeBotAdapter();
558
629
 
559
630
  await sendCardToChannel();
@@ -561,12 +632,13 @@ describe("MicrosoftTeamsUtil.sendAdaptiveCardToChannel - install preflight", ()
561
632
  expect(adapter.continueConversationAsync).toHaveBeenCalledTimes(1);
562
633
  });
563
634
 
564
- test("installedTeams containing ONLY a different team refuses before the Bot Framework call", async () => {
635
+ test("no local record + Graph says NOT installed refuses before the Bot Framework call", async () => {
565
636
  mockProjectAuth({
566
637
  installedTeams: {
567
638
  "team-2": buildInstalledTeam({ id: "team-2", name: "Marketing" }),
568
639
  },
569
640
  });
641
+ mockInstallState(MicrosoftTeamsAppInstallState.NotInstalled);
570
642
  const adapter: FakeBotAdapter = installFakeBotAdapter();
571
643
 
572
644
  await expect(
@@ -584,12 +656,25 @@ describe("MicrosoftTeamsUtil.sendAdaptiveCardToChannel - install preflight", ()
584
656
  expect(adapter.continueConversationAsync).not.toHaveBeenCalled();
585
657
  });
586
658
 
587
- test("installedTeams containing this team proceeds to the send", async () => {
588
- mockProjectAuth({
589
- installedTeams: {
590
- [TEAM_ID]: buildInstalledTeam({ id: TEAM_ID }),
591
- },
592
- });
659
+ test("the verified refusal is checked against the team the caller asked for", async () => {
660
+ mockProjectAuth({ installedTeams: {} });
661
+ const installSpy: jest.SpyInstance = mockInstallState(
662
+ MicrosoftTeamsAppInstallState.NotInstalled,
663
+ );
664
+ installFakeBotAdapter();
665
+
666
+ await expect(sendCardToChannel({ teamId: "team-99" })).rejects.toThrow(
667
+ /is not installed in the Microsoft Teams team/,
668
+ );
669
+
670
+ expect(installSpy).toHaveBeenCalledWith(
671
+ expect.objectContaining({ teamId: "team-99" }),
672
+ );
673
+ });
674
+
675
+ test("no local record + Graph says installed proceeds to the send", async () => {
676
+ mockProjectAuth({ installedTeams: {} });
677
+ mockInstallState(MicrosoftTeamsAppInstallState.Installed);
593
678
  const adapter: FakeBotAdapter = installFakeBotAdapter();
594
679
 
595
680
  await sendCardToChannel();
@@ -597,14 +682,31 @@ describe("MicrosoftTeamsUtil.sendAdaptiveCardToChannel - install preflight", ()
597
682
  expect(adapter.continueConversationAsync).toHaveBeenCalledTimes(1);
598
683
  });
599
684
 
600
- test("several installed teams, one of which is the target, proceeds", async () => {
685
+ test("no local record + Graph cannot tell us proceeds rather than accusing", async () => {
601
686
  mockProjectAuth({
602
687
  installedTeams: {
603
- "team-0": buildInstalledTeam({ id: "team-0" }),
604
- [TEAM_ID]: buildInstalledTeam({ id: TEAM_ID }),
605
688
  "team-2": buildInstalledTeam({ id: "team-2" }),
606
689
  },
607
690
  });
691
+ mockInstallState(MicrosoftTeamsAppInstallState.Unknown);
692
+ const adapter: FakeBotAdapter = installFakeBotAdapter();
693
+
694
+ await sendCardToChannel({ teamId: TEAM_ID });
695
+
696
+ expect(adapter.continueConversationAsync).toHaveBeenCalledTimes(1);
697
+ });
698
+
699
+ test("installedTeams undefined (legacy workspace) with an unknown install state does NOT block the send", async () => {
700
+ mockProjectAuth({ miscData: baseMiscData() });
701
+ const adapter: FakeBotAdapter = installFakeBotAdapter();
702
+
703
+ await sendCardToChannel();
704
+
705
+ expect(adapter.continueConversationAsync).toHaveBeenCalledTimes(1);
706
+ });
707
+
708
+ test("an EMPTY installedTeams map with an unknown install state does NOT block the send", async () => {
709
+ mockProjectAuth({ installedTeams: {} });
608
710
  const adapter: FakeBotAdapter = installFakeBotAdapter();
609
711
 
610
712
  await sendCardToChannel();
@@ -612,13 +714,23 @@ describe("MicrosoftTeamsUtil.sendAdaptiveCardToChannel - install preflight", ()
612
714
  expect(adapter.continueConversationAsync).toHaveBeenCalledTimes(1);
613
715
  });
614
716
 
615
- test("several installed teams, none of which is the target, refuses", async () => {
717
+ test("a record written before graph ids were captured cannot vouch for a team, so Graph decides", async () => {
718
+ /*
719
+ * The legacy on-disk shape: keyed by the Bot Framework thread id, with no
720
+ * graphTeamId. It must not be mistaken for an install of the Graph team id
721
+ * that happens to be the rule's value, and it must not be treated as proof
722
+ * of anything either — Graph is asked, and here it says no.
723
+ */
616
724
  mockProjectAuth({
617
725
  installedTeams: {
618
- "team-0": buildInstalledTeam({ id: "team-0" }),
619
- "team-2": buildInstalledTeam({ id: "team-2" }),
726
+ "19:team-aaa@thread.tacv2": {
727
+ id: "19:team-aaa@thread.tacv2",
728
+ name: "Engineering",
729
+ addedAt: "2026-07-27T00:00:00.000Z",
730
+ } as MicrosoftTeamsInstalledTeam,
620
731
  },
621
732
  });
733
+ mockInstallState(MicrosoftTeamsAppInstallState.NotInstalled);
622
734
  const adapter: FakeBotAdapter = installFakeBotAdapter();
623
735
 
624
736
  await expect(sendCardToChannel({ teamId: TEAM_ID })).rejects.toThrow(
@@ -627,12 +739,29 @@ describe("MicrosoftTeamsUtil.sendAdaptiveCardToChannel - install preflight", ()
627
739
  expect(adapter.continueConversationAsync).not.toHaveBeenCalled();
628
740
  });
629
741
 
630
- test("the team id lookup is exact a differently-cased key does not count as installed", async () => {
742
+ test("a legacy record does not block a send Graph is willing to allow", async () => {
743
+ mockProjectAuth({
744
+ installedTeams: {
745
+ "19:team-aaa@thread.tacv2": {
746
+ id: "19:team-aaa@thread.tacv2",
747
+ } as MicrosoftTeamsInstalledTeam,
748
+ },
749
+ });
750
+ mockInstallState(MicrosoftTeamsAppInstallState.Installed);
751
+ const adapter: FakeBotAdapter = installFakeBotAdapter();
752
+
753
+ await sendCardToChannel({ teamId: TEAM_ID });
754
+
755
+ expect(adapter.continueConversationAsync).toHaveBeenCalledTimes(1);
756
+ });
757
+
758
+ test("the graph team id match is exact — a differently-cased record is not this team", async () => {
631
759
  mockProjectAuth({
632
760
  installedTeams: {
633
761
  "TEAM-1": buildInstalledTeam({ id: "TEAM-1" }),
634
762
  },
635
763
  });
764
+ mockInstallState(MicrosoftTeamsAppInstallState.NotInstalled);
636
765
  const adapter: FakeBotAdapter = installFakeBotAdapter();
637
766
 
638
767
  await expect(sendCardToChannel({ teamId: "team-1" })).rejects.toThrow(
@@ -641,12 +770,9 @@ describe("MicrosoftTeamsUtil.sendAdaptiveCardToChannel - install preflight", ()
641
770
  expect(adapter.continueConversationAsync).not.toHaveBeenCalled();
642
771
  });
643
772
 
644
- test("the preflight refusal for a PRIVATE channel gives the channel-install instructions", async () => {
645
- mockProjectAuth({
646
- installedTeams: {
647
- "team-2": buildInstalledTeam({ id: "team-2" }),
648
- },
649
- });
773
+ test("the verified refusal for a PRIVATE channel gives the channel-install instructions", async () => {
774
+ mockProjectAuth({ installedTeams: {} });
775
+ mockInstallState(MicrosoftTeamsAppInstallState.NotInstalled);
650
776
  installFakeBotAdapter();
651
777
 
652
778
  await expect(
@@ -666,29 +792,25 @@ describe("MicrosoftTeamsUtil.sendAdaptiveCardToChannel - install preflight", ()
666
792
  );
667
793
  });
668
794
 
669
- test("the preflight refusal is a BadDataException", async () => {
670
- mockProjectAuth({
671
- installedTeams: {
672
- "team-2": buildInstalledTeam({ id: "team-2" }),
673
- },
674
- });
795
+ test("the verified refusal is a BadDataException", async () => {
796
+ mockProjectAuth({ installedTeams: {} });
797
+ mockInstallState(MicrosoftTeamsAppInstallState.NotInstalled);
675
798
  installFakeBotAdapter();
676
799
 
677
800
  await expect(sendCardToChannel()).rejects.toBeInstanceOf(BadDataException);
678
801
  });
679
802
 
680
803
  test("the preflight never runs when the integration is missing entirely", async () => {
681
- jest
682
- .spyOn(WorkspaceProjectAuthTokenService, "getProjectAuth")
683
- .mockResolvedValue(null);
684
- const adapter: FakeBotAdapter = installFakeBotAdapter();
804
+ mockProjectAuth({ miscData: null });
805
+ const installSpy: jest.SpyInstance = mockInstallState(
806
+ MicrosoftTeamsAppInstallState.NotInstalled,
807
+ );
808
+ installFakeBotAdapter();
685
809
 
686
810
  await expect(sendCardToChannel()).rejects.toThrow(
687
- new BadDataException(
688
- "Microsoft Teams integration not found for this project",
689
- ),
811
+ /Microsoft Teams integration not found/,
690
812
  );
691
- expect(adapter.continueConversationAsync).not.toHaveBeenCalled();
813
+ expect(installSpy).not.toHaveBeenCalled();
692
814
  });
693
815
  });
694
816
 
@@ -822,9 +944,26 @@ describe("MicrosoftTeamsUtil.sendAdaptiveCardToChannel - conversation reference"
822
944
  });
823
945
  });
824
946
 
947
+ /*
948
+ * How Microsoft's roster rejection is worded back to the admin.
949
+ *
950
+ * The rewrite used to assert "the app is not installed in the team" for every
951
+ * roster rejection. That is one cause among several, and for the customer who
952
+ * reported this it was the wrong one — the app was in the team's app list and
953
+ * the bot answered @mentions in the very channel that failed. The rewrite is
954
+ * now conditioned on what the preflight actually established: only a verified
955
+ * absence gets the install instructions, and anything else gets the causes that
956
+ * apply to an app which IS present, with Microsoft's own words attached.
957
+ */
825
958
  describe("MicrosoftTeamsUtil.sendAdaptiveCardToChannel - roster error translation", () => {
826
- test("Microsoft's roster rejection is replaced with the parent-team install instructions", async () => {
827
- mockProjectAuth();
959
+ test("a VERIFIED absent install still gets the parent-team install instructions", async () => {
960
+ /*
961
+ * Graph is asked before the send here and says the app is absent, but the
962
+ * refusal happens up front, so this asserts the message an admin who really
963
+ * has not added the app sees.
964
+ */
965
+ mockProjectAuth({ installedTeams: {} });
966
+ mockInstallState(MicrosoftTeamsAppInstallState.NotInstalled);
828
967
  installFakeBotAdapter({ adapterError: new Error(ROSTER_ERROR_TEXT) });
829
968
 
830
969
  await expect(
@@ -844,41 +983,156 @@ describe("MicrosoftTeamsUtil.sendAdaptiveCardToChannel - roster error translatio
844
983
  );
845
984
  });
846
985
 
847
- test("the raw Microsoft text never reaches the caller", async () => {
986
+ test("an UNVERIFIED roster rejection does not claim the app is missing", async () => {
848
987
  mockProjectAuth();
988
+ mockInstallState(MicrosoftTeamsAppInstallState.Unknown);
849
989
  installFakeBotAdapter({ adapterError: new Error(ROSTER_ERROR_TEXT) });
850
990
 
851
991
  let thrown: unknown = undefined;
852
992
  try {
853
- await sendCardToChannel();
993
+ await sendCardToChannel({
994
+ workspaceChannel: buildChannel({ name: "General" }),
995
+ });
854
996
  } catch (err) {
855
997
  thrown = err;
856
998
  }
857
999
 
858
1000
  expect(thrown).toBeInstanceOf(BadDataException);
859
- expect((thrown as Error).message).not.toContain("conversation roster");
860
- expect((thrown as Error).message).toContain("Manage team > Apps");
1001
+ expect((thrown as Error).message).not.toContain(
1002
+ "The OneUptime app is not installed in the Microsoft Teams team",
1003
+ );
1004
+ expect((thrown as Error).message).toContain("Likely causes");
861
1005
  });
862
1006
 
863
- test("a roster rejection on a private channel gets the channel-install instructions", async () => {
1007
+ test("an unverified rejection names the channel and quotes Microsoft", async () => {
864
1008
  mockProjectAuth();
1009
+ mockInstallState(MicrosoftTeamsAppInstallState.Unknown);
1010
+ installFakeBotAdapter({ adapterError: new Error(ROSTER_ERROR_TEXT) });
1011
+
1012
+ let thrown: unknown = undefined;
1013
+ try {
1014
+ await sendCardToChannel({
1015
+ workspaceChannel: buildChannel({ name: "test public channel" }),
1016
+ });
1017
+ } catch (err) {
1018
+ thrown = err;
1019
+ }
1020
+
1021
+ expect((thrown as Error).message).toContain('"test public channel"');
1022
+ // The raw error is the one thing support needs and the rewrite used to eat.
1023
+ expect((thrown as Error).message).toContain(ROSTER_ERROR_TEXT);
1024
+ });
1025
+
1026
+ test("when the app IS known installed, the rejection points at the bot id instead", async () => {
1027
+ /*
1028
+ * The reporter's exact situation: a real install, and a roster rejection
1029
+ * anyway. The actionable cause is that the installed package's bot is not
1030
+ * the one this deployment authenticates as.
1031
+ */
1032
+ mockProjectAuth({
1033
+ installedTeams: {
1034
+ [TEAM_ID]: buildInstalledTeam({ id: TEAM_ID }),
1035
+ },
1036
+ });
1037
+ mockInstallState(MicrosoftTeamsAppInstallState.Installed);
1038
+ installFakeBotAdapter({ adapterError: new Error(ROSTER_ERROR_TEXT) });
1039
+
1040
+ let thrown: unknown = undefined;
1041
+ try {
1042
+ await sendCardToChannel({ teamId: TEAM_ID });
1043
+ } catch (err) {
1044
+ thrown = err;
1045
+ }
1046
+
1047
+ expect((thrown as Error).message).toContain(
1048
+ "MICROSOFT_TEAMS_APP_CLIENT_ID",
1049
+ );
1050
+ expect((thrown as Error).message).not.toContain(
1051
+ "The OneUptime app is not installed in the Microsoft Teams team",
1052
+ );
1053
+ });
1054
+
1055
+ test("a STALE local install record is re-checked, and a confirmed removal gets the install instructions", async () => {
1056
+ /*
1057
+ * The local record let this send skip the preflight, but the app has since
1058
+ * been removed from the team and no uninstall event reached us. Wording the
1059
+ * error off the stale record would blame the bot id for a genuinely missing
1060
+ * install, so Graph is asked again on the failure path.
1061
+ */
1062
+ mockProjectAuth({
1063
+ installedTeams: {
1064
+ [TEAM_ID]: buildInstalledTeam({ id: TEAM_ID }),
1065
+ },
1066
+ });
1067
+ const installSpy: jest.SpyInstance = mockInstallState(
1068
+ MicrosoftTeamsAppInstallState.NotInstalled,
1069
+ );
865
1070
  installFakeBotAdapter({ adapterError: new Error(ROSTER_ERROR_TEXT) });
866
1071
 
867
1072
  await expect(
868
1073
  sendCardToChannel({
869
- workspaceChannel: buildChannel({
870
- name: "Ops War Room",
871
- membershipType: "private",
872
- }),
1074
+ teamId: TEAM_ID,
1075
+ workspaceChannel: buildChannel({ name: "General" }),
873
1076
  }),
874
1077
  ).rejects.toThrow(
875
1078
  new BadDataException(
876
- MicrosoftTeamsUtil.getBotNotInTeamMessage({
877
- channelName: "Ops War Room",
878
- membershipType: "private",
879
- }),
1079
+ MicrosoftTeamsUtil.getBotNotInTeamMessage({ channelName: "General" }),
880
1080
  ),
881
1081
  );
1082
+
1083
+ // Not consulted on the happy path; consulted once the send actually failed.
1084
+ expect(installSpy).toHaveBeenCalledTimes(1);
1085
+ });
1086
+
1087
+ test("the Graph check is not repeated when the preflight already made it", async () => {
1088
+ mockProjectAuth({ installedTeams: {} });
1089
+ const installSpy: jest.SpyInstance = mockInstallState(
1090
+ MicrosoftTeamsAppInstallState.Unknown,
1091
+ );
1092
+ installFakeBotAdapter({ adapterError: new Error(ROSTER_ERROR_TEXT) });
1093
+
1094
+ await expect(sendCardToChannel()).rejects.toThrow(/Likely causes/);
1095
+
1096
+ expect(installSpy).toHaveBeenCalledTimes(1);
1097
+ });
1098
+
1099
+ test("an unverified rejection on a private channel still leads with the channel install", async () => {
1100
+ mockProjectAuth();
1101
+ mockInstallState(MicrosoftTeamsAppInstallState.Unknown);
1102
+ installFakeBotAdapter({ adapterError: new Error(ROSTER_ERROR_TEXT) });
1103
+
1104
+ let thrown: unknown = undefined;
1105
+ try {
1106
+ await sendCardToChannel({
1107
+ workspaceChannel: buildChannel({
1108
+ name: "Ops War Room",
1109
+ membershipType: "private",
1110
+ }),
1111
+ });
1112
+ } catch (err) {
1113
+ thrown = err;
1114
+ }
1115
+
1116
+ expect((thrown as Error).message).toContain("Manage channel > Apps");
1117
+ });
1118
+
1119
+ test("every roster rejection mentions the Azure Bot Teams channel", async () => {
1120
+ /*
1121
+ * A bot whose Azure resource has no Microsoft Teams channel enabled is
1122
+ * rejected exactly like an uninstalled app, and nothing in OneUptime can
1123
+ * detect it — so it has to be in the message.
1124
+ */
1125
+ mockProjectAuth();
1126
+ installFakeBotAdapter({ adapterError: new Error(ROSTER_ERROR_TEXT) });
1127
+
1128
+ let thrown: unknown = undefined;
1129
+ try {
1130
+ await sendCardToChannel();
1131
+ } catch (err) {
1132
+ thrown = err;
1133
+ }
1134
+
1135
+ expect((thrown as Error).message).toContain("Microsoft Teams channel");
882
1136
  });
883
1137
 
884
1138
  test("a roster rejection raised inside the proactive callback is translated too", async () => {
@@ -887,9 +1141,7 @@ describe("MicrosoftTeamsUtil.sendAdaptiveCardToChannel - roster error translatio
887
1141
  sendActivityError: new Error(ROSTER_ERROR_TEXT),
888
1142
  });
889
1143
 
890
- await expect(sendCardToChannel()).rejects.toThrow(
891
- /is not installed in the Microsoft Teams team/,
892
- );
1144
+ await expect(sendCardToChannel()).rejects.toThrow(/Likely causes/);
893
1145
  });
894
1146
 
895
1147
  test("a roster rejection thrown as a plain string is translated too", async () => {
@@ -898,9 +1150,7 @@ describe("MicrosoftTeamsUtil.sendAdaptiveCardToChannel - roster error translatio
898
1150
  adapterError: "The bot is not part of the conversation roster.",
899
1151
  });
900
1152
 
901
- await expect(sendCardToChannel()).rejects.toThrow(
902
- /is not installed in the Microsoft Teams team/,
903
- );
1153
+ await expect(sendCardToChannel()).rejects.toThrow(/Likely causes/);
904
1154
  });
905
1155
 
906
1156
  test("unrelated adapter errors propagate untouched", async () => {
@@ -1149,6 +1399,7 @@ describe("MicrosoftTeamsUtil.sendMessage - unresolvable channel destinations are
1149
1399
  "team-2": buildInstalledTeam({ id: "team-2" }),
1150
1400
  },
1151
1401
  });
1402
+ mockInstallState(MicrosoftTeamsAppInstallState.NotInstalled);
1152
1403
  const adapter: FakeBotAdapter = installFakeBotAdapter();
1153
1404
 
1154
1405
  const response: WorkspaceSendMessageResponse = await callSendMessage(
@@ -1167,3 +1418,297 @@ describe("MicrosoftTeamsUtil.sendMessage - unresolvable channel destinations are
1167
1418
  );
1168
1419
  });
1169
1420
  });
1421
+
1422
+ describe("MicrosoftTeamsUtil.getRosterRejectionMessage", () => {
1423
+ test("names the channel that was refused", () => {
1424
+ const message: string = MicrosoftTeamsUtil.getRosterRejectionMessage({
1425
+ channelName: "test public channel",
1426
+ installState: MicrosoftTeamsAppInstallState.Unknown,
1427
+ });
1428
+
1429
+ expect(message).toContain('"test public channel"');
1430
+ });
1431
+
1432
+ test("never tells an admin the app is not installed", () => {
1433
+ /*
1434
+ * The whole point of this message: it is used when we do NOT know, and the
1435
+ * old wording turned "we do not know" into a false statement of fact.
1436
+ */
1437
+ for (const installState of [
1438
+ MicrosoftTeamsAppInstallState.Unknown,
1439
+ MicrosoftTeamsAppInstallState.Installed,
1440
+ ]) {
1441
+ const message: string = MicrosoftTeamsUtil.getRosterRejectionMessage({
1442
+ channelName: "General",
1443
+ installState: installState,
1444
+ });
1445
+
1446
+ expect(message).not.toContain(
1447
+ "The OneUptime app is not installed in the Microsoft Teams team",
1448
+ );
1449
+ }
1450
+ });
1451
+
1452
+ test("a known install points at a bot id mismatch, not a missing install", () => {
1453
+ const message: string = MicrosoftTeamsUtil.getRosterRejectionMessage({
1454
+ channelName: "General",
1455
+ installState: MicrosoftTeamsAppInstallState.Installed,
1456
+ });
1457
+
1458
+ expect(message).toContain("MICROSOFT_TEAMS_APP_CLIENT_ID");
1459
+ expect(message).toContain("the app is installed in this team");
1460
+ });
1461
+
1462
+ test("an unknown install state still suggests adding the app", () => {
1463
+ const message: string = MicrosoftTeamsUtil.getRosterRejectionMessage({
1464
+ channelName: "General",
1465
+ installState: MicrosoftTeamsAppInstallState.Unknown,
1466
+ });
1467
+
1468
+ expect(message).toContain("Manage team > Apps");
1469
+ });
1470
+
1471
+ test("a private channel leads with the channel-level install", () => {
1472
+ const message: string = MicrosoftTeamsUtil.getRosterRejectionMessage({
1473
+ channelName: "Ops War Room",
1474
+ membershipType: "private",
1475
+ installState: MicrosoftTeamsAppInstallState.Unknown,
1476
+ });
1477
+
1478
+ expect(message).toContain("Manage channel > Apps");
1479
+ expect(message).not.toContain("Manage team > Apps");
1480
+ });
1481
+
1482
+ test("Microsoft's own wording is quoted when we have it", () => {
1483
+ const message: string = MicrosoftTeamsUtil.getRosterRejectionMessage({
1484
+ channelName: "General",
1485
+ installState: MicrosoftTeamsAppInstallState.Unknown,
1486
+ microsoftError: new Error(ROSTER_ERROR_TEXT),
1487
+ });
1488
+
1489
+ expect(message).toContain(ROSTER_ERROR_TEXT);
1490
+ });
1491
+
1492
+ test("a non-Error microsoftError is stringified rather than dropped", () => {
1493
+ const message: string = MicrosoftTeamsUtil.getRosterRejectionMessage({
1494
+ channelName: "General",
1495
+ installState: MicrosoftTeamsAppInstallState.Unknown,
1496
+ microsoftError: "BotNotInConversationRoster",
1497
+ });
1498
+
1499
+ expect(message).toContain("BotNotInConversationRoster");
1500
+ });
1501
+
1502
+ test("no trailing quote section when there is no Microsoft error to quote", () => {
1503
+ const message: string = MicrosoftTeamsUtil.getRosterRejectionMessage({
1504
+ channelName: "General",
1505
+ installState: MicrosoftTeamsAppInstallState.Unknown,
1506
+ });
1507
+
1508
+ expect(message).not.toContain("Microsoft's response was");
1509
+ expect(message.endsWith(".")).toBe(true);
1510
+ });
1511
+
1512
+ test("the Azure Bot Teams channel is always listed as a cause", () => {
1513
+ for (const installState of [
1514
+ MicrosoftTeamsAppInstallState.Unknown,
1515
+ MicrosoftTeamsAppInstallState.Installed,
1516
+ MicrosoftTeamsAppInstallState.NotInstalled,
1517
+ ]) {
1518
+ const message: string = MicrosoftTeamsUtil.getRosterRejectionMessage({
1519
+ channelName: "General",
1520
+ installState: installState,
1521
+ });
1522
+
1523
+ expect(message).toContain(
1524
+ "does not have the Microsoft Teams channel enabled",
1525
+ );
1526
+ }
1527
+ });
1528
+ });
1529
+
1530
+ /*
1531
+ * The Graph installed-apps check.
1532
+ *
1533
+ * Its contract is asymmetric on purpose: it may only answer NotInstalled when
1534
+ * Graph actually enumerated the team's apps and this deployment's app was not
1535
+ * among them. Any other outcome — a permission the tenant never granted, a
1536
+ * transport failure, an unconfigured client id — is Unknown, because reporting
1537
+ * those as NotInstalled is what sent the reporter of this bug in circles.
1538
+ */
1539
+ describe("MicrosoftTeamsUtil.isAppInstalledInTeam", () => {
1540
+ function mockGraph(pages: Array<JSONObject | HTTPErrorResponse>): jest.Mock {
1541
+ const get: jest.Mock = jest.fn();
1542
+
1543
+ for (const page of pages) {
1544
+ if (page instanceof HTTPErrorResponse) {
1545
+ get.mockResolvedValueOnce(page);
1546
+ continue;
1547
+ }
1548
+ get.mockResolvedValueOnce({ data: page } as HTTPResponse<JSONObject>);
1549
+ }
1550
+
1551
+ jest.spyOn(API, "get").mockImplementation(get as any);
1552
+ return get;
1553
+ }
1554
+
1555
+ function callIsAppInstalled(
1556
+ teamId: string = TEAM_ID,
1557
+ ): Promise<MicrosoftTeamsAppInstallState> {
1558
+ return MicrosoftTeamsUtil.isAppInstalledInTeam({
1559
+ authToken: "auth-token",
1560
+ projectId: ObjectID.generate(),
1561
+ teamId: teamId,
1562
+ });
1563
+ }
1564
+
1565
+ beforeEach(() => {
1566
+ useRealInstallStateCheck();
1567
+ jest
1568
+ .spyOn(MicrosoftTeamsUtil, "getValidAccessToken")
1569
+ .mockResolvedValue("app-access-token");
1570
+ });
1571
+
1572
+ test("an installed app matching this deployment's client id is Installed", async () => {
1573
+ mockGraph([
1574
+ {
1575
+ value: [
1576
+ { id: "install-1", teamsApp: { externalId: MOCK_APP_CLIENT_ID } },
1577
+ ],
1578
+ },
1579
+ ]);
1580
+
1581
+ await expect(callIsAppInstalled()).resolves.toBe(
1582
+ MicrosoftTeamsAppInstallState.Installed,
1583
+ );
1584
+ });
1585
+
1586
+ test("matching is on externalId, so another vendor's OneUptime app is not ours", async () => {
1587
+ /*
1588
+ * This is the case the reporter's screenshots could not rule out: a tile
1589
+ * called "OneUptime · HackerBay Inc" in the team's app list which is the
1590
+ * public store package, whose bot is not this deployment's bot.
1591
+ */
1592
+ mockGraph([
1593
+ {
1594
+ value: [
1595
+ {
1596
+ id: "install-1",
1597
+ teamsApp: {
1598
+ externalId: "99999999-9999-9999-9999-999999999999",
1599
+ displayName: "OneUptime",
1600
+ },
1601
+ },
1602
+ ],
1603
+ },
1604
+ ]);
1605
+
1606
+ await expect(callIsAppInstalled()).resolves.toBe(
1607
+ MicrosoftTeamsAppInstallState.NotInstalled,
1608
+ );
1609
+ });
1610
+
1611
+ test("an empty app list is NotInstalled", async () => {
1612
+ mockGraph([{ value: [] }]);
1613
+
1614
+ await expect(callIsAppInstalled()).resolves.toBe(
1615
+ MicrosoftTeamsAppInstallState.NotInstalled,
1616
+ );
1617
+ });
1618
+
1619
+ test("a response with no value array at all is NotInstalled", async () => {
1620
+ mockGraph([{}]);
1621
+
1622
+ await expect(callIsAppInstalled()).resolves.toBe(
1623
+ MicrosoftTeamsAppInstallState.NotInstalled,
1624
+ );
1625
+ });
1626
+
1627
+ test("the app is found on a later page", async () => {
1628
+ const get: jest.Mock = mockGraph([
1629
+ {
1630
+ value: [{ id: "install-1", teamsApp: { externalId: "other-app" } }],
1631
+ "@odata.nextLink": "https://graph.microsoft.com/v1.0/page-2",
1632
+ },
1633
+ {
1634
+ value: [
1635
+ { id: "install-2", teamsApp: { externalId: MOCK_APP_CLIENT_ID } },
1636
+ ],
1637
+ },
1638
+ ]);
1639
+
1640
+ await expect(callIsAppInstalled()).resolves.toBe(
1641
+ MicrosoftTeamsAppInstallState.Installed,
1642
+ );
1643
+ expect(get).toHaveBeenCalledTimes(2);
1644
+ });
1645
+
1646
+ test("NotInstalled is only returned after every page was read", async () => {
1647
+ const get: jest.Mock = mockGraph([
1648
+ {
1649
+ value: [{ id: "install-1", teamsApp: { externalId: "other-app" } }],
1650
+ "@odata.nextLink": "https://graph.microsoft.com/v1.0/page-2",
1651
+ },
1652
+ { value: [{ id: "install-2", teamsApp: { externalId: "another-app" } }] },
1653
+ ]);
1654
+
1655
+ await expect(callIsAppInstalled()).resolves.toBe(
1656
+ MicrosoftTeamsAppInstallState.NotInstalled,
1657
+ );
1658
+ expect(get).toHaveBeenCalledTimes(2);
1659
+ });
1660
+
1661
+ test("a Graph error is Unknown, never NotInstalled", async () => {
1662
+ /*
1663
+ * The realistic case: a workspace connected before
1664
+ * TeamsAppInstallation.ReadForTeam.All was documented gets a 403 here. It
1665
+ * must not be told its app is missing.
1666
+ */
1667
+ mockGraph([new HTTPErrorResponse(403, { error: "Forbidden" }, {})]);
1668
+
1669
+ await expect(callIsAppInstalled()).resolves.toBe(
1670
+ MicrosoftTeamsAppInstallState.Unknown,
1671
+ );
1672
+ });
1673
+
1674
+ test("a thrown transport error is Unknown", async () => {
1675
+ jest.spyOn(API, "get").mockRejectedValue(new Error("socket hang up"));
1676
+
1677
+ await expect(callIsAppInstalled()).resolves.toBe(
1678
+ MicrosoftTeamsAppInstallState.Unknown,
1679
+ );
1680
+ });
1681
+
1682
+ test("a token failure is Unknown", async () => {
1683
+ jest
1684
+ .spyOn(MicrosoftTeamsUtil, "getValidAccessToken")
1685
+ .mockRejectedValue(new Error("no token"));
1686
+ const get: jest.Mock = mockGraph([{ value: [] }]);
1687
+
1688
+ await expect(callIsAppInstalled()).resolves.toBe(
1689
+ MicrosoftTeamsAppInstallState.Unknown,
1690
+ );
1691
+ expect(get).not.toHaveBeenCalled();
1692
+ });
1693
+
1694
+ test("the team id is scoped into the Graph URL and the app list is expanded", async () => {
1695
+ const get: jest.Mock = mockGraph([{ value: [] }]);
1696
+
1697
+ await callIsAppInstalled("team-abc");
1698
+
1699
+ const requestedUrl: string = get.mock.calls[0]![0].url.toString();
1700
+ expect(requestedUrl).toContain("/teams/team-abc/installedApps");
1701
+ // Without $expand there is no teamsApp to match externalId against.
1702
+ expect(requestedUrl).toContain("$expand=teamsApp");
1703
+ });
1704
+
1705
+ test("the access token is sent as a bearer token", async () => {
1706
+ const get: jest.Mock = mockGraph([{ value: [] }]);
1707
+
1708
+ await callIsAppInstalled();
1709
+
1710
+ expect(get.mock.calls[0]![0].headers.Authorization).toBe(
1711
+ "Bearer app-access-token",
1712
+ );
1713
+ });
1714
+ });