Repository navigation
fix(cli): refuse event triggers whose integration the owner has not connected #9206
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| import { describe, expect, it } from "bun:test"; | ||
| import type { ApiClient } from "../../lib/api-client"; | ||
| import { | ||
| assertTriggersConnected, | ||
| missingConnectionError, | ||
| } from "./missingConnectionError"; | ||
|
|
||
| const linear = { config: { kind: "linear" } }; | ||
| const slack = { config: { kind: "slack" } }; | ||
| const live = { connected: true, needsReauth: false }; | ||
|
|
||
| describe("missingConnectionError", () => { | ||
| it("refuses a trigger whose integration the caller has not connected", () => { | ||
| const error = missingConnectionError([linear], {}); | ||
| expect(error?.message).toContain("you have not connected Linear"); | ||
| expect(error?.suggestion).toContain("Connect Linear in Settings"); | ||
| }); | ||
|
|
||
| it("asks for a reconnect when the connection expired", () => { | ||
| const error = missingConnectionError([linear], { | ||
| linear: { connected: false, needsReauth: true }, | ||
| }); | ||
| expect(error?.message).toContain("your Linear connection expired"); | ||
| expect(error?.suggestion).toContain("Reconnect Linear"); | ||
| }); | ||
|
|
||
| it("refuses a pin to an expired account while another account is live", () => { | ||
| const error = missingConnectionError([{ ...linear, connectionId: "old" }], { | ||
| linear: { | ||
| ...live, | ||
| accounts: [ | ||
| { id: "old", needsReauth: true }, | ||
| { id: "new", needsReauth: false }, | ||
| ], | ||
| }, | ||
| }); | ||
| expect(error?.message).toContain("your Linear connection expired"); | ||
| }); | ||
|
|
||
| it("refuses a pin to an account the caller does not own", () => { | ||
| const error = missingConnectionError( | ||
| [{ ...linear, connectionId: "theirs" }], | ||
| { linear: { ...live, accounts: [{ id: "mine", needsReauth: false }] } }, | ||
| ); | ||
| expect(error?.message).toContain("pinned to an account"); | ||
| }); | ||
|
|
||
| it("allows a pin to a live account", () => { | ||
| expect( | ||
| missingConnectionError([{ ...linear, connectionId: "mine" }], { | ||
| linear: { ...live, accounts: [{ id: "mine", needsReauth: false }] }, | ||
| }), | ||
| ).toBeNull(); | ||
| }); | ||
|
|
||
| it("allows a trigger whose integration is connected", () => { | ||
| expect(missingConnectionError([linear], { linear: live })).toBeNull(); | ||
| }); | ||
|
|
||
| it("names every missing integration once", () => { | ||
| const error = missingConnectionError([linear, linear, slack], {}); | ||
| expect(error?.message).toBe( | ||
| "These triggers would never fire: you have not connected Linear; you have not connected Slack", | ||
| ); | ||
| }); | ||
|
|
||
| it("ignores triggers that need no connection", () => { | ||
| expect( | ||
| missingConnectionError( | ||
| [{ config: { kind: "schedule" } }, { config: { kind: "github" } }], | ||
| {}, | ||
| ), | ||
| ).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| describe("assertTriggersConnected", () => { | ||
| const api = (query: () => Promise<unknown>) => | ||
| ({ integration: { connectionStatus: { query } } }) as unknown as ApiClient; | ||
|
|
||
| it("throws when the status names a missing connection", async () => { | ||
| await expect( | ||
| assertTriggersConnected( | ||
| api(async () => ({})), | ||
| "org", | ||
| [linear], | ||
| ), | ||
| ).rejects.toThrow("you have not connected Linear"); | ||
| }); | ||
|
|
||
| it("lets the save go ahead when the status query fails", async () => { | ||
| await expect( | ||
| assertTriggersConnected( | ||
| api(async () => { | ||
| throw new Error("offline"); | ||
| }), | ||
| "org", | ||
| [linear], | ||
| ), | ||
| ).resolves.toBeUndefined(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| import { CLIError } from "@superset/cli-framework"; | ||
| import { TRIGGER_KIND_CONNECTOR } from "@superset/shared/automation-triggers"; | ||
| import { getConnector } from "@superset/shared/connectors"; | ||
| import type { ApiClient } from "../../lib/api-client"; | ||
|
|
||
| export interface ConnectionState { | ||
| connected: boolean; | ||
| needsReauth: boolean; | ||
| accounts?: { id: string; needsReauth: boolean }[]; | ||
| } | ||
|
|
||
| type Problem = "missing" | "expired" | "foreignPin"; | ||
|
|
||
| interface TriggerInput { | ||
| config: { kind: string }; | ||
| connectionId?: string | null; | ||
| } | ||
|
|
||
| function problemFor( | ||
| trigger: TriggerInput, | ||
| state: ConnectionState | undefined, | ||
| ): Problem | null { | ||
| if (trigger.connectionId) { | ||
| const account = state?.accounts?.find( | ||
| (candidate) => candidate.id === trigger.connectionId, | ||
| ); | ||
| if (!account) return "foreignPin"; | ||
| return account.needsReauth ? "expired" : null; | ||
| } | ||
| if (state?.connected) return null; | ||
| return state?.needsReauth ? "expired" : "missing"; | ||
| } | ||
|
|
||
| export function missingConnectionError( | ||
| triggers: TriggerInput[], | ||
| status: Record<string, ConnectionState>, | ||
| ): CLIError | null { | ||
| const problems = new Map<string, Problem>(); | ||
| for (const trigger of triggers) { | ||
| const connector = TRIGGER_KIND_CONNECTOR[trigger.config.kind]; | ||
| if (!connector || problems.has(connector)) continue; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Report distinct problems on triggers for the same connector. If two Linear triggers have different invalid pins,
|
||
| const problem = problemFor(trigger, status[connector]); | ||
| if (problem) problems.set(connector, problem); | ||
| } | ||
| if (problems.size === 0) return null; | ||
|
|
||
| const name = (connector: string) => | ||
| getConnector(connector)?.displayName ?? connector; | ||
| const reasons: string[] = []; | ||
| const steps: string[] = []; | ||
| for (const [connector, problem] of problems) { | ||
| const n = name(connector); | ||
| if (problem === "expired") { | ||
| reasons.push(`your ${n} connection expired`); | ||
| steps.push(`Reconnect ${n} in Settings → Integrations.`); | ||
| } else if (problem === "foreignPin") { | ||
| reasons.push( | ||
| `a ${n} trigger is pinned to an account you have not connected`, | ||
| ); | ||
| steps.push( | ||
| `Drop its connectionId, or set it to one of your ${n} accounts.`, | ||
| ); | ||
| } else { | ||
| reasons.push(`you have not connected ${n}`); | ||
| steps.push(`Connect ${n} in Settings → Integrations.`); | ||
| } | ||
| } | ||
| return new CLIError( | ||
| `These triggers would never fire: ${reasons.join("; ")}`, | ||
| [...steps, "Then run this command again."].join(" "), | ||
| ); | ||
| } | ||
|
|
||
| export async function assertTriggersConnected( | ||
| api: ApiClient, | ||
| organizationId: string | undefined, | ||
| triggers: TriggerInput[] | undefined, | ||
| ): Promise<void> { | ||
| if (!organizationId || !triggers?.length) return; | ||
| const status = await api.integration.connectionStatus | ||
| .query({ organizationId }) | ||
| .catch(() => null); | ||
| if (!status) return; | ||
| const error = missingConnectionError(triggers, status); | ||
| if (error) throw error; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Report a missing integration before a foreign pin.
If a pinned trigger has no entry in
statusfor its connector,problemForreturns"foreignPin". The error tells the user to drop the pin or choose an existing account. Neither action connects the missing integration. If the user drops the pin, the next attempt instead reports"missing". Return"missing"when the connector has no status entry; reserve"foreignPin"for a pin absent from an existing connector’s accounts. The CLI accepts optional pins, and the status query can omit connectors with no visible accounts. (raw.githubusercontent.com)