mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
fix(ui): keep the refusal code when only part of the channel list loads
- Carry the code of the refusal that truncated the read, so a grant refused on a later page still offers the reconnect path
This commit is contained in:
@@ -352,6 +352,12 @@ const channelOptions = (count: number) =>
|
||||
const RATE_LIMITED_MESSAGE =
|
||||
"Slack is rate limiting Prowler right now. Try again in about 30 seconds.";
|
||||
|
||||
/** A dead grant, as the API reports one: the reason in `code`, prose in `detail`. */
|
||||
const TOKEN_EXPIRED_CODE = "token_expired";
|
||||
const TOKEN_EXPIRED_DETAIL = "Slack refused the request: token_expired.";
|
||||
const TOKEN_EXPIRED_MESSAGE =
|
||||
"Prowler's Slack credential has expired. Connect the workspace again to restore access.";
|
||||
|
||||
describe("getSlackChannels", () => {
|
||||
it("follows a cursor-only `next` on the listing's own URL, not on the API root", async () => {
|
||||
// The link is opaque (design D6), so the API may answer with the cursor
|
||||
@@ -446,9 +452,31 @@ describe("getSlackChannels", () => {
|
||||
|
||||
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
|
||||
|
||||
// Slack named no reason for the wait, so the truncation carries none: a
|
||||
// rate limit says nothing about the grant itself.
|
||||
expect(result).toEqual({
|
||||
channels: [channelOption(FIRST_CHANNEL)],
|
||||
incomplete: RATE_LIMITED_MESSAGE,
|
||||
code: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("names the reason a later page was refused, not only the wording", async () => {
|
||||
// A grant that has stopped working refuses the second cursor page exactly
|
||||
// as it refuses the first (contract, Cross-cutting) — and the read is a
|
||||
// success either way, so `code` is the only way the caller can hear it.
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(channelPage(FIRST_CHANNEL, "?page[cursor]=2"))
|
||||
.mockResolvedValueOnce(
|
||||
errorResponse(400, TOKEN_EXPIRED_DETAIL, TOKEN_EXPIRED_CODE),
|
||||
);
|
||||
|
||||
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(result).toEqual({
|
||||
channels: [channelOption(FIRST_CHANNEL)],
|
||||
incomplete: TOKEN_EXPIRED_MESSAGE,
|
||||
code: TOKEN_EXPIRED_CODE,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -314,6 +314,13 @@ interface SlackChannelsSuccess {
|
||||
* the picker *and* the reason.
|
||||
*/
|
||||
incomplete?: string;
|
||||
/**
|
||||
* The `code` of the refusal that cut the read short, when it named one. A
|
||||
* grant that has stopped working refuses the second cursor page exactly as it
|
||||
* refuses the first, and a caller reading only the failure path would never
|
||||
* hear about it.
|
||||
*/
|
||||
code?: string | null;
|
||||
}
|
||||
|
||||
export type SlackChannelsResult = SlackChannelsSuccess | SlackActionError;
|
||||
@@ -363,7 +370,7 @@ export const getSlackChannels = async (
|
||||
);
|
||||
|
||||
return channels.length > 0
|
||||
? { channels, incomplete: refusal.error }
|
||||
? { channels, incomplete: refusal.error, code: refusal.code }
|
||||
: refusal;
|
||||
}
|
||||
|
||||
|
||||
@@ -769,6 +769,36 @@ describe("a credential Slack no longer accepts", () => {
|
||||
expect(message).not.toMatch(new RegExp(SLACK_TOKEN_EXPIRED_CODE));
|
||||
}, 30000);
|
||||
|
||||
it("offers it too when only a later cursor page is what Slack refuses", async () => {
|
||||
// Given — a two-page workspace whose first page reads fine and whose second
|
||||
// is refused by a credential Slack no longer accepts. The read stopped
|
||||
// short of the workspace rather than failing, and a grant that has stopped
|
||||
// working refuses page two exactly as it refuses page one.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
partiallyReadSlackFixture({
|
||||
channelsRefusal: SLACK_TOKEN_EXPIRED_REFUSAL,
|
||||
}),
|
||||
);
|
||||
|
||||
// When — nothing but opening the page.
|
||||
await harness.mount();
|
||||
|
||||
// Then — what was read stays on offer, as it does for any short list.
|
||||
expect(await harness.channelOptions()).toEqual([
|
||||
SLACK_PUBLIC_CHANNEL.name,
|
||||
SLACK_SECOND_PUBLIC_CHANNEL.name,
|
||||
]);
|
||||
|
||||
// And — the dead credential is reported all the same: a picker that still
|
||||
// works is no reason to leave the user without the one fix there is.
|
||||
const notice = await harness.revokedCredentialNotice();
|
||||
expect(notice).toMatch(/Prowler's Slack credential has expired/);
|
||||
expect(harness.offersReconnect()).toBe(true);
|
||||
// The row still reads connected until something says otherwise, and this
|
||||
// is that something.
|
||||
expect(await harness.connectionBadge()).toBe("Disconnected");
|
||||
}, 60000);
|
||||
|
||||
it("keeps saying so when a later check fails without Slack naming a reason", async () => {
|
||||
// Given — the listing found the credential dead on arrival, and a
|
||||
// connection check that settles as failed naming nothing: the generic check
|
||||
|
||||
@@ -276,8 +276,12 @@ export const SlackIntegrationManager = ({
|
||||
},
|
||||
);
|
||||
// The listing is the call a dead credential shows up on first: it
|
||||
// runs on arrival, before the user has touched anything.
|
||||
if ("error" in result) recordRefusal(result.code);
|
||||
// runs on arrival, before the user has touched anything — and a read
|
||||
// cut short names its refusal's code too, so a grant that died on a
|
||||
// later cursor page is heard the same as one that refused the first.
|
||||
// Channels that did arrive are Slack answering: a truncation naming no
|
||||
// code (it was busy) leaves the credential proven alive.
|
||||
if ("error" in result || result.code) recordRefusal(result.code);
|
||||
else provedCredentialAlive();
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
Reference in New Issue
Block a user