Files
prowler/ui/app/(prowler)/integrations/slack/callback/route.test.ts

331 lines
11 KiB
TypeScript

/**
* Unit tests for the Slack OAuth callback Route Handler. MSW (node) serves the
* same contract double the browser tests use, so the real
* `exchangeSlackOAuthCode` runs underneath — only Next's request-scoped pieces
* (session, cache) are stubbed.
*/
import { setupServer } from "msw/node";
import { revalidatePath } from "next/cache";
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import { handlersForSlack } from "@/__tests__/msw/handlers/slack";
import {
SLACK_EXCHANGE_OUTCOME,
SLACK_OAUTH_CODE,
SLACK_OAUTH_STATE,
SLACK_RETRY_AFTER_SECONDS,
SLACK_WORKSPACE_CONFLICT_CODE,
slackFixture,
} from "@/__tests__/msw/handlers/slack.fixtures";
import type { SlackFixture } from "@/__tests__/msw/handlers/slack.fixtures";
import { GET, HEAD } from "./route";
// The MSW handlers read the literal `process.env.UI_API_BASE_URL`, which
// Vite's `define` inlines at build time; the action resolves the same var
// through `readEnv`'s computed access at module import, which `define` cannot
// reach. This hoisted block runs before either import so both agree.
vi.hoisted(() => {
process.env.UI_API_BASE_URL ??= "http://localhost/api/v1";
});
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
revalidateTag: vi.fn(),
unstable_cache: <T>(fn: T) => fn,
}));
// `auth()` reaches for a request scope the test has none of; the exchange only
// needs a bearer the double never checks.
vi.mock("@/auth.config", () => ({
auth: vi.fn(() => Promise.resolve({ accessToken: "test-access-token" })),
}));
const server = setupServer();
const exchangeCalls: string[] = [];
server.events.on("request:start", ({ request }) => {
if (
request.method === "POST" &&
request.url.includes("/slack/oauth/exchange")
) {
exchangeCalls.push(request.url);
}
});
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
const CALLBACK_URL = "https://cloud.prowler.com/integrations/slack/callback";
const wire = (fixture: SlackFixture) =>
server.use(...handlersForSlack(fixture));
const get = (query: string, headers?: HeadersInit) =>
GET(new Request(`${CALLBACK_URL}?${query}`, { headers }));
const HAPPY_QUERY = `code=${SLACK_OAUTH_CODE}&state=${SLACK_OAUTH_STATE}`;
/**
* The redirect's target, with the `303` and the header's relativity asserted
* on the way: behind the reverse proxy the handler is reached on the
* instance's internal hostname, so an absolute `Location` echoing the request
* would send the browser somewhere no DNS resolves.
*/
const locationOf = (response: Response): URL => {
expect(response.status).toBe(303);
const location = response.headers.get("location");
expect(location).not.toBeNull();
expect(location).toMatch(/^\//);
return new URL(location as string, CALLBACK_URL);
};
const revalidatedPaths = () =>
vi.mocked(revalidatePath).mock.calls.map(([path]) => path);
beforeEach(() => {
vi.stubEnv("UI_CLOUD_ENABLED", "true");
vi.mocked(revalidatePath).mockClear();
exchangeCalls.length = 0;
});
describe("the Slack OAuth callback route", () => {
it("exchanges the code and redirects to the integration page, leaking neither code nor state", async () => {
wire(slackFixture());
const location = locationOf(await get(HAPPY_QUERY));
expect(location.pathname).toBe("/integrations/slack");
expect(Array.from(location.searchParams.entries())).toEqual([
["slack", "connected"],
]);
// The code is single-use: one exchange, no retry.
expect(exchangeCalls).toHaveLength(1);
// A completed install invalidates the cached "none connected".
expect(revalidatedPaths()).toEqual(
expect.arrayContaining(["/integrations", "/integrations/slack"]),
);
});
it.each([
SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_CONTENT,
SLACK_EXCHANGE_OUTCOME.UNREADABLE_HTML,
SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_DATA,
])(
"reports an unreadable 2xx (%s) as unconfirmed, not as failed",
async (exchangeOutcome) => {
wire(slackFixture({ exchangeOutcome }));
const location = locationOf(await get(HAPPY_QUERY));
expect(Array.from(location.searchParams.entries())).toEqual([
["slack", "unconfirmed"],
]);
// Nothing of the unreadable body rides the redirect.
expect(location.href).not.toMatch(/DOCTYPE|html/i);
// The 2xx says the install happened, so the cache goes with it.
expect(revalidatedPaths()).toEqual(
expect.arrayContaining(["/integrations", "/integrations/slack"]),
);
},
);
it("passes Slack's own refusal through as a token, exchanging nothing", async () => {
const location = locationOf(await get("error=access_denied"));
expect(Array.from(location.searchParams.entries())).toEqual([
["slack", "slack_error"],
["slack_reason", "access_denied"],
]);
expect(exchangeCalls).toHaveLength(0);
});
it("lets the error win when Slack sends it alongside a code, so the code is not spent", async () => {
const location = locationOf(
await get(`error=access_denied&${HAPPY_QUERY}`),
);
expect(location.searchParams.get("slack")).toBe("slack_error");
expect(exchangeCalls).toHaveLength(0);
});
it("drops a refusal reason that is not shaped like a token", async () => {
for (const error of [
"<script>alert(1)</script>",
"Some long sentence, not a reason code.",
]) {
const location = locationOf(
await get(`error=${encodeURIComponent(error)}`),
);
expect(Array.from(location.searchParams.entries())).toEqual([
["slack", "slack_error"],
]);
expect(location.href).not.toMatch(/script|alert|sentence/i);
}
});
// The back button's path: replaying the callback re-sends a state/code the
// API already consumed, which it refuses with a code-less 400 — or, in the
// deployed API's spelling, a 400 naming `invalid_oauth_state`.
it.each([
SLACK_EXCHANGE_OUTCOME.REFUSED_STATE,
SLACK_EXCHANGE_OUTCOME.REFUSED_STATE_CODED,
SLACK_EXCHANGE_OUTCOME.SLACK_REFUSED,
])(
"reports a consumed or timed-out completion (%s) as expired, not as retryable",
async (exchangeOutcome) => {
wire(slackFixture({ exchangeOutcome }));
const location = locationOf(await get(HAPPY_QUERY));
expect(Array.from(location.searchParams.entries())).toEqual([
["slack", "expired"],
]);
},
);
it("names the workspace conflict so the page can keep Prowler's wording for it", async () => {
wire(
slackFixture({
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.DIFFERENT_WORKSPACE,
}),
);
const location = locationOf(await get(HAPPY_QUERY));
expect(Array.from(location.searchParams.entries())).toEqual([
["slack", "error"],
["slack_code", SLACK_WORKSPACE_CONFLICT_CODE],
]);
});
it("carries the rate limit's wait so the page can say when to come back", async () => {
wire(slackFixture({ rateLimited: true }));
const location = locationOf(await get(HAPPY_QUERY));
expect(Array.from(location.searchParams.entries())).toEqual([
["slack", "rate_limited"],
["slack_retry", String(SLACK_RETRY_AFTER_SECONDS)],
]);
});
it("reports Slack being broken upstream as an error", async () => {
wire(slackFixture({ oauthUpstreamError: true }));
const location = locationOf(await get(HAPPY_QUERY));
expect(location.searchParams.get("slack")).toBe("error");
});
it.each([
["state", `code=${SLACK_OAUTH_CODE}`],
["code", `state=${SLACK_OAUTH_STATE}`],
])(
"asks for nothing when the completion carries no %s",
async (_missing, query) => {
const location = locationOf(await get(query));
expect(Array.from(location.searchParams.entries())).toEqual([
["slack", "incomplete"],
]);
expect(exchangeCalls).toHaveLength(0);
},
);
it("answers HEAD itself, since Next would otherwise run the GET for it", () => {
const response = HEAD();
expect(response.status).toBe(204);
expect(response.headers.get("Cache-Control")).toBe("no-store");
expect(exchangeCalls).toHaveLength(0);
});
it("answers a prefetch nothing, so no intermediary burns the code", async () => {
const prefetchHeaders: HeadersInit[] = [
{ "sec-purpose": "prefetch" },
{ purpose: "prefetch" },
{ "x-moz": "prefetch" },
];
for (const headers of prefetchHeaders) {
const response = await get(HAPPY_QUERY, headers);
expect(response.status).toBe(204);
expect(response.headers.get("Cache-Control")).toBe("no-store");
}
expect(exchangeCalls).toHaveLength(0);
});
it("detects a prefetch when an earlier purpose header describes navigation", async () => {
// Given - different clients supplied purpose headers with mixed values.
const headers = {
"sec-purpose": "navigate",
purpose: "prefetch",
"x-moz": "navigate",
};
// When
const response = await get(HAPPY_QUERY, headers);
// Then - any prefetch marker prevents the single-use code exchange.
expect(response.status).toBe(204);
expect(exchangeCalls).toHaveLength(0);
});
it("exchanges the code when ordinary navigation follows a prefetch", async () => {
// Given - a speculative request reached the callback before navigation.
wire(slackFixture());
const prefetchResponse = await get(HAPPY_QUERY, {
"sec-purpose": "prefetch",
});
// When - the browser performs the ordinary callback navigation.
const location = locationOf(await get(HAPPY_QUERY));
// Then - the guard cannot be cached and the real exchange still runs once.
expect(prefetchResponse.headers.get("Cache-Control")).toBe("no-store");
expect(location.searchParams.get("slack")).toBe("connected");
expect(exchangeCalls).toHaveLength(1);
});
// The prod topology: the reverse proxy reaches Next on the instance's
// internal hostname, which no browser can resolve — the redirect must not
// inherit it.
it("keeps the redirect relative when reached on an internal hostname", async () => {
wire(slackFixture());
const internalCallback = `http://ip-10-0-0-1.eu-west-1.compute.internal:3000/integrations/slack/callback?${HAPPY_QUERY}`;
const response = await GET(new Request(internalCallback));
expect(response.status).toBe(303);
expect(response.headers.get("location")).toBe(
"/integrations/slack?slack=connected",
);
// The non-cloud "go home" redirect must not echo the internal host either.
vi.stubEnv("UI_CLOUD_ENABLED", "false");
const home = await GET(new Request(internalCallback));
expect(home.headers.get("location")).toBe("/");
});
it("sends non-cloud deployments home without exchanging anything", async () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
const location = locationOf(await get(HAPPY_QUERY));
expect(location.pathname).toBe("/");
expect(exchangeCalls).toHaveLength(0);
});
});