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:
Pablo F.G
2026-08-18 17:46:57 +02:00
parent 0c296b3227
commit e78c128de4
4 changed files with 72 additions and 3 deletions
+28
View File
@@ -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,
});
});
+8 -1
View File
@@ -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(() => {