Quarantined high-risk skills (browser session capture, WebSocket interception) for Slack and Microsoft Teams. Install only after reviewing the security implications.
76
96%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Low
Low-risk findings worth noting
// slack-ext-grid.test.js — tst suite for skills/slack/scripts/slack-ext-grid.js
//
// Run from skills/slack/:
// tst tests/slack-ext-grid.test.js
//
// This file imports only `tst` and the relative module — no builtin test runner,
// no fs, no path, no sliccy:* — so it runs in the SLICC test realm as-is, and no
// integration detector can class it as a skip. Do not name that runner even in a
// comment here: a substring-based detector would skip this file for mentioning it.
//
// Covers: parameter builders, ID classifiers, channel helpers, approval
// helpers, user classification, and the pagination utility.
import test, { is, ok, throws } from 'tst';
import * as gridMod from '../scripts/slack-ext-grid.js';
const {
buildEgSetRestrictedParams,
buildEgSetRegularParams,
buildEgSetStatusParams,
buildDeidentifyParams,
buildEgSetUltraRestrictedParams,
buildConvertChannelParams,
buildChannelSearchParams,
buildApprovalsListParams,
buildAppApproveRestrictParams,
buildAppClearResolutionParams,
buildAppPermissionsParams,
buildAppListParams,
resolveAppOrRequestId,
isValidPermissionType,
VALID_PERMISSION_TYPES,
VALID_SEARCH_CHANNEL_TYPES,
VALID_CHANNEL_SORT_FIELDS,
filterChannels,
summarizeChannel,
summarizeApproval,
classifyUser,
collectPages,
clampChannelSearchLimit,
CHANNEL_SEARCH_PAGE_MIN,
CHANNEL_SEARCH_PAGE_MAX,
slackErrorDetail,
channelSearchApiQuery,
searchChannels,
} = gridMod.default || gridMod;
// ── buildEgSetRestrictedParams ────────────────────────────────────────────────
test('buildEgSetRestrictedParams returns {user}', () => {
const p = buildEgSetRestrictedParams('U12345');
is(p.user, 'U12345');
});
test('buildEgSetRestrictedParams has no team_id field', () => {
const p = buildEgSetRestrictedParams('U12345');
is(p.team_id, undefined);
});
test('buildEgSetRestrictedParams only has user key', () => {
const p = buildEgSetRestrictedParams('U12345');
is(Object.keys(p).length, 1);
});
// ── buildEgSetRegularParams ───────────────────────────────────────────────────
test('buildEgSetRegularParams returns {user}', () => {
const p = buildEgSetRegularParams('U67890');
is(p.user, 'U67890');
is(Object.keys(p).length, 1);
});
// ── buildEgSetStatusParams ────────────────────────────────────────────────────
test('buildEgSetStatusParams with status=delete returns correct shape', () => {
const p = buildEgSetStatusParams('U12345', 'delete');
is(p.user, 'U12345');
is(p.status, 'delete');
});
test('buildEgSetStatusParams includes both user and status keys', () => {
const p = buildEgSetStatusParams('U12345', 'delete');
ok(Object.keys(p).includes('user'));
ok(Object.keys(p).includes('status'));
is(Object.keys(p).length, 2);
});
test('buildEgSetStatusParams status=delete is NOT permanent delete (wire trap)', () => {
// status=delete means DEACTIVATE (reversible). The value 'delete' is the wire
// value observed from the UI; it does not delete the account permanently.
const p = buildEgSetStatusParams('U12345', 'delete');
is(p.status, 'delete');
// The trap: if someone changes this to 'inactive' or 'disabled' they would be
// calling a DIFFERENT operation. The wire value must stay 'delete'.
ok(p.status !== 'inactive');
ok(p.status !== 'disabled');
});
// ── buildDeidentifyParams ─────────────────────────────────────────────────────
test('buildDeidentifyParams returns {user}', () => {
const p = buildDeidentifyParams('U12345');
is(p.user, 'U12345');
is(Object.keys(p).length, 1);
});
// MUTATION TARGET: changing `user` to `user_id` would break this test
test('buildDeidentifyParams uses the key "user" not "user_id"', () => {
const p = buildDeidentifyParams('U12345');
ok('user' in p);
ok(!('user_id' in p));
});
// ── buildEgSetUltraRestrictedParams (UNVERIFIED) ──────────────────────────────
test('buildEgSetUltraRestrictedParams returns {user} — UNVERIFIED endpoint', () => {
// NOTE: enterprise.users.admin.setUltraRestricted is real (probe confirms
// user_not_found, not unknown_method) but ok:true was never observed from UI.
// This test only checks the parameter shape, not the method's behavior.
const p = buildEgSetUltraRestrictedParams('U12345');
is(p.user, 'U12345');
is(Object.keys(p).length, 1);
});
// ── buildConvertChannelParams ─────────────────────────────────────────────────
test('buildConvertChannelParams uses channel_id (not channel)', () => {
const p = buildConvertChannelParams('C12345');
is(p.channel_id, 'C12345');
ok(!('channel' in p));
});
test('buildConvertChannelParams has exactly one key', () => {
is(Object.keys(buildConvertChannelParams('C12345')).length, 1);
});
// ── buildChannelSearchParams ──────────────────────────────────────────────────
test('buildChannelSearchParams defaults: limit=20, search_channel_types=exclude_archived, sort=name, sort_dir=asc', () => {
const p = buildChannelSearchParams('', 0, '', '', '', '');
is(p.limit, '20');
is(p.search_channel_types, 'exclude_archived');
is(p.sort, 'name');
is(p.sort_dir, 'asc');
});
test('buildChannelSearchParams: query always present (empty string)', () => {
const p = buildChannelSearchParams('', 10, '', '', '', '');
ok('query' in p);
is(p.query, '');
});
test('buildChannelSearchParams: cursor always present (empty string)', () => {
const p = buildChannelSearchParams('', 10, '', '', '', '');
ok('cursor' in p);
is(p.cursor, '');
});
test('buildChannelSearchParams: explicit search_channel_types respected', () => {
is(buildChannelSearchParams('', 10, '', 'all', '', '').search_channel_types, 'all');
is(buildChannelSearchParams('', 10, '', 'private', '', '').search_channel_types, 'private');
is(buildChannelSearchParams('', 10, '', 'archived', '', '').search_channel_types, 'archived');
});
test('buildChannelSearchParams: explicit sort respected', () => {
is(buildChannelSearchParams('', 10, '', '', 'member_count', '').sort, 'member_count');
is(buildChannelSearchParams('', 10, '', '', 'created', '').sort, 'created');
});
test('buildChannelSearchParams: explicit sort_dir=desc respected', () => {
is(buildChannelSearchParams('', 10, '', '', '', 'desc').sort_dir, 'desc');
});
test('buildChannelSearchParams includes query when provided', () => {
const p = buildChannelSearchParams('support', 10, '', '', '', '');
is(p.query, 'support');
is(p.limit, '10');
});
test('buildChannelSearchParams never includes channel_ids (measured defect guard)', () => {
// channel_ids is silently ignored by Slack — passing it returns the unfiltered
// list confidently. This test verifies we never emit it.
const p = buildChannelSearchParams('anything', 50, '', 'all', 'name', 'asc');
ok(!('channel_ids' in p));
});
test('buildChannelSearchParams includes cursor when provided', () => {
const p = buildChannelSearchParams('', 10, 'dXNlcjpV', '', '', '');
is(p.cursor, 'dXNlcjpV');
});
// MUTATION TARGET: defaulting to 'all' instead of 'exclude_archived' silently
// changes result counts (2072 all vs 1515 exclude_archived in this org).
test('buildChannelSearchParams default is exclude_archived not all', () => {
const p = buildChannelSearchParams('', 10, '', '', '', '');
ok(p.search_channel_types !== 'all');
is(p.search_channel_types, 'exclude_archived');
});
// MUTATION TARGET: the 2026-10-02 bug. Slack rejects limit > 20 with
// invalid_arguments ("[ERROR] must be less than 20 [json-pointer:/limit]"), so
// the old default of 50, and any --limit above 20, made every channel-search
// fail. The builder must never emit a limit Slack rejects.
test('buildChannelSearchParams never emits a limit above 20 (Slack: invalid_arguments)', () => {
for (const lim of [21, 50, 100, 1000, '50', '21']) {
const n = Number(buildChannelSearchParams('santander', lim, '', 'all', 'name', 'asc').limit);
ok(n <= 20, 'limit ' + lim + ' emitted ' + n);
is(n, 20);
}
});
test('buildChannelSearchParams never emits a limit below 2 (0 rejected, 1 answers zero rows)', () => {
// limit=0 -> "must be greater than 1"; limit=1 -> ok:true, 0 rows, no cursor.
is(buildChannelSearchParams('', 1, '', '', '', '').limit, '2');
is(buildChannelSearchParams('', -5, '', '', '', '').limit, '20');
is(buildChannelSearchParams('', 'abc', '', '', '', '').limit, '20');
is(buildChannelSearchParams('', undefined, '', '', '', '').limit, '20');
});
// MUTATION TARGET: Slack's message says "must be less than 20", but 20 was
// measured to work (20 rows + next_cursor). A clamp tightened to 19 to match
// the wording costs a page per 380 channels and fails this test.
test('buildChannelSearchParams keeps 19 and 20 unchanged (20 is the measured max)', () => {
is(buildChannelSearchParams('', 19, '', '', '', '').limit, '19');
is(buildChannelSearchParams('', 20, '', '', '', '').limit, '20');
is(buildChannelSearchParams('', 100, '', '', '', '').limit, '20');
});
test('buildChannelSearchParams keeps an in-range limit as a string', () => {
is(buildChannelSearchParams('', 20, '', '', '', '').limit, '20');
is(buildChannelSearchParams('', 2, '', '', '', '').limit, '2');
is(buildChannelSearchParams('', 7, '', '', '', '').limit, '7');
is(typeof buildChannelSearchParams('', 7, '', '', '', '').limit, 'string');
});
test('clampChannelSearchLimit bounds match the measured 2..20 range', () => {
is(CHANNEL_SEARCH_PAGE_MIN, 2);
is(CHANNEL_SEARCH_PAGE_MAX, 20);
is(clampChannelSearchLimit(20), 20);
is(clampChannelSearchLimit(21), 20);
is(clampChannelSearchLimit(1), 2);
is(clampChannelSearchLimit(0), 20);
is(clampChannelSearchLimit(null), 20);
is(clampChannelSearchLimit('12'), 12);
is(clampChannelSearchLimit(12.9), 12);
});
// ── slackErrorDetail ──────────────────────────────────────────────────────────
// MUTATION TARGET: dropping response_metadata.messages is what made the
// 2026-10-02 invalid_arguments unexplained.
test('slackErrorDetail appends response_metadata.messages', () => {
is(
slackErrorDetail({
ok: false,
error: 'invalid_arguments',
response_metadata: { messages: ['[ERROR] must be less than 20 [json-pointer:/limit]'] },
}),
'invalid_arguments ([ERROR] must be less than 20 [json-pointer:/limit])'
);
});
test('slackErrorDetail joins several messages and warnings', () => {
is(
slackErrorDetail({ ok: false, error: 'invalid_arguments', response_metadata: { messages: ['a', 'b'], warnings: ['w'] } }),
'invalid_arguments (a; b; w)'
);
});
test('slackErrorDetail: plain error, missing body, non-string messages', () => {
is(slackErrorDetail({ ok: false, error: 'not_allowed' }), 'not_allowed');
is(slackErrorDetail(null), 'no_response');
is(slackErrorDetail(undefined), 'no_response');
is(slackErrorDetail({ ok: false }), 'unknown_error');
is(slackErrorDetail({ ok: false, error: 'x', response_metadata: { messages: 'not-an-array' } }), 'x');
is(slackErrorDetail({ ok: false, error: 'x', response_metadata: { messages: [null, 3, ''] } }), 'x');
});
// ── channelSearchApiQuery ─────────────────────────────────────────────────────
// MUTATION TARGET: Slack's query is a word-PREFIX match (2026-10-02:
// query=santander missed #aem-gruposantander). Sending a name to the server
// by default would silently drop infix matches.
test('channelSearchApiQuery: name text enumerates (empty query) by default', () => {
is(channelSearchApiQuery('santander', false), '');
is(channelSearchApiQuery('', false), '');
is(channelSearchApiQuery('', true), '');
});
test('channelSearchApiQuery: channel id goes to the server; serverQuery sends names', () => {
is(channelSearchApiQuery('C09U7MX5R7X', false), 'C09U7MX5R7X');
is(channelSearchApiQuery('santander', true), 'santander');
});
// ── searchChannels ────────────────────────────────────────────────────────────
function searchChan(id, name) {
return { id: id, name: name, member_count: 3, is_private: false, is_archived: false };
}
// Fake admin.conversations.search with Slack's measured contract: limit must
// be 2..20, otherwise invalid_arguments with the explaining message.
function fakeSearch(pages) {
const seen = [];
const call = async (method, params) => {
seen.push({ method, params: Object.assign({}, params) });
const lim = Number(params.limit);
if (!(lim >= 2 && lim <= 20)) {
return {
ok: false,
error: 'invalid_arguments',
response_metadata: { messages: ['[ERROR] must be less than 20 [json-pointer:/limit]'] },
};
}
const idx = params.cursor ? Number(params.cursor.replace('cur', '')) : 0;
const page = pages[idx];
return {
ok: true,
conversations: page,
total_count: pages.reduce((n, p) => n + p.length, 0),
next_cursor: idx + 1 < pages.length ? 'cur' + (idx + 1) : '',
};
};
return { call, seen };
}
const SANTANDER_PAGES = [
[searchChan('C06MDH3G9J5', 'aem-santanderemea'), searchChan('C09U7MX5R7X', 'aem-gruposantander'), searchChan('C1', 'general')],
[searchChan('C0C5YLFVBDY', 'agents-santander'), searchChan('C2', 'random')],
[searchChan('C3', 'zzz'), searchChan('C095J7Z00P5', 'universaleditor-santander')],
];
// MUTATION TARGET: the end-to-end form of the bug — with the old limit=50
// default the very first page answers invalid_arguments.
test('searchChannels: default page size is accepted by the measured contract', async () => {
const f = fakeSearch(SANTANDER_PAGES);
const r = await searchChannels(f.call, { query: 'santander' });
is(r.error, null);
is(r.complete, true);
for (const c of f.seen) is(c.params.limit, '20');
});
test('searchChannels: an oversized --limit is clamped, not sent', async () => {
const f = fakeSearch(SANTANDER_PAGES);
const r = await searchChannels(f.call, { query: 'santander', limit: 50 });
is(r.error, null);
is(f.seen[0].params.limit, '20');
});
// MUTATION TARGET: pagination. Every page must be read, following next_cursor.
test('searchChannels: follows next_cursor to the end and matches across pages', async () => {
const f = fakeSearch(SANTANDER_PAGES);
const r = await searchChannels(f.call, { query: 'santander' });
is(r.pages, 3);
is(r.fetched, 7);
is(r.totalCount, 7);
is(r.complete, true);
is(
r.matches.map((c) => c.id).join(','),
'C06MDH3G9J5,C09U7MX5R7X,C0C5YLFVBDY,C095J7Z00P5'
);
is(f.seen.map((c) => c.params.cursor).join('|'), '|cur1|cur2');
});
test('searchChannels: a name query enumerates (query="") and matches infix names locally', async () => {
const f = fakeSearch(SANTANDER_PAGES);
const r = await searchChannels(f.call, { query: 'santander' });
for (const c of f.seen) is(c.params.query, '');
ok(r.matches.some((c) => c.name === 'aem-gruposantander'));
});
test('searchChannels: serverQuery sends the query; an id is always sent', async () => {
const a = fakeSearch(SANTANDER_PAGES);
await searchChannels(a.call, { query: 'santander', serverQuery: true });
is(a.seen[0].params.query, 'santander');
const b = fakeSearch(SANTANDER_PAGES);
const r = await searchChannels(b.call, { query: 'C09U7MX5R7X' });
is(b.seen[0].params.query, 'C09U7MX5R7X');
is(r.matches.map((c) => c.id).join(','), 'C09U7MX5R7X');
});
test('searchChannels: passes types/sort/sort_dir through and never sends channel_ids', async () => {
const f = fakeSearch(SANTANDER_PAGES);
await searchChannels(f.call, { query: 'x', types: 'all', sort: 'created', sortDir: 'desc' });
for (const c of f.seen) {
is(c.method, 'admin.conversations.search');
is(c.params.search_channel_types, 'all');
is(c.params.sort, 'created');
is(c.params.sort_dir, 'desc');
ok(!('channel_ids' in c.params));
}
});
// MUTATION TARGET: --max used to cap ROWS FETCHED before the local match,
// which silently dropped matches. It must count matches.
test('searchChannels: max counts matches, not rows fetched', async () => {
const f = fakeSearch(SANTANDER_PAGES);
const r = await searchChannels(f.call, { query: 'santander', max: 3 });
is(r.matches.length, 3);
is(r.matches.map((c) => c.id).join(','), 'C06MDH3G9J5,C09U7MX5R7X,C0C5YLFVBDY');
is(r.complete, false);
is(r.pages, 2);
});
test('searchChannels: max larger than the matches still reads every page', async () => {
const f = fakeSearch(SANTANDER_PAGES);
const r = await searchChannels(f.call, { query: 'santander', max: 50 });
is(r.matches.length, 4);
is(r.complete, true);
});
test('searchChannels: a Slack error carries response_metadata.messages', async () => {
const call = async () => ({
ok: false,
error: 'invalid_arguments',
response_metadata: { messages: ['[ERROR] must be less than 20 [json-pointer:/limit]'] },
});
const r = await searchChannels(call, { query: 'santander' });
is(r.error, 'invalid_arguments ([ERROR] must be less than 20 [json-pointer:/limit])');
is(r.complete, false);
is(r.pages, 0);
});
test('searchChannels: an error mid-pagination is an error, never a short result', async () => {
let n = 0;
const call = async () => {
n += 1;
if (n === 1) return { ok: true, conversations: [searchChan('C1', 'santander-a')], next_cursor: 'cur1' };
return { ok: false, error: 'xhr_error' };
};
const r = await searchChannels(call, { query: 'santander' });
is(r.error, 'xhr_error');
is(r.complete, false);
is(r.pages, 1);
});
test('searchChannels: malformed bodies are errors', async () => {
is((await searchChannels(async () => null, {})).error, 'no_response');
is((await searchChannels(async () => ({ ok: true }), {})).error, 'malformed_response');
is((await searchChannels(async () => ({ ok: 'false', conversations: [] }), {})).error, 'unknown_error');
});
test('searchChannels: next_cursor in response_metadata is followed too', async () => {
let n = 0;
const call = async (_m, p) => {
n += 1;
if (!p.cursor) return { ok: true, conversations: [searchChan('C1', 'a')], response_metadata: { next_cursor: 'x' } };
return { ok: true, conversations: [searchChan('C2', 'b')], next_cursor: '' };
};
const r = await searchChannels(call, {});
is(n, 2);
is(r.fetched, 2);
is(r.complete, true);
});
test('searchChannels: maxPages stops a cursor that never ends, reported incomplete', async () => {
const call = async () => ({ ok: true, conversations: [searchChan('C1', 'a')], next_cursor: 'again' });
const r = await searchChannels(call, { maxPages: 4 });
is(r.pages, 4);
is(r.complete, false);
is(r.error, null);
});
// ── VALID_SEARCH_CHANNEL_TYPES and VALID_CHANNEL_SORT_FIELDS ──────────────────
test('VALID_SEARCH_CHANNEL_TYPES has all 5 observed values', () => {
ok(VALID_SEARCH_CHANNEL_TYPES.has('all'));
ok(VALID_SEARCH_CHANNEL_TYPES.has('exclude_archived'));
ok(VALID_SEARCH_CHANNEL_TYPES.has('private'));
ok(VALID_SEARCH_CHANNEL_TYPES.has('private_exclude'));
ok(VALID_SEARCH_CHANNEL_TYPES.has('archived'));
is(VALID_SEARCH_CHANNEL_TYPES.size, 5);
});
test('VALID_CHANNEL_SORT_FIELDS: name, member_count, created are valid', () => {
ok(VALID_CHANNEL_SORT_FIELDS.has('name'));
ok(VALID_CHANNEL_SORT_FIELDS.has('member_count'));
ok(VALID_CHANNEL_SORT_FIELDS.has('created'));
});
test('VALID_CHANNEL_SORT_FIELDS: last_activity_ts not valid (returns invalid_sort live)', () => {
// Probed live 2026-09-22: last_activity_ts and num_members both return invalid_sort.
ok(!VALID_CHANNEL_SORT_FIELDS.has('last_activity_ts'));
ok(!VALID_CHANNEL_SORT_FIELDS.has('num_members'));
});
// ── buildApprovalsListParams ──────────────────────────────────────────────────
test('buildApprovalsListParams includes sort and sort_dir', () => {
const p = buildApprovalsListParams(10, '', '');
is(p.sort, 'date_expire');
is(p.sort_dir, 'desc');
});
test('buildApprovalsListParams default limit is 25', () => {
const p = buildApprovalsListParams(0, '', '');
is(p.limit, '25');
});
test('buildApprovalsListParams omits query when empty', () => {
const p = buildApprovalsListParams(10, '', '');
ok(!('query' in p));
});
// ── buildAppApproveRestrictParams ─────────────────────────────────────────────
test('buildAppApproveRestrictParams with requestId uses request_id', () => {
const p = buildAppApproveRestrictParams({ requestId: 'I0C3EKRE3S5' });
is(p.request_id, 'I0C3EKRE3S5');
ok(!('app_id' in p));
ok(!('enterprise_id' in p));
});
test('buildAppApproveRestrictParams with appId uses app_id+enterprise_id', () => {
const p = buildAppApproveRestrictParams({ appId: 'A0123456789', enterpriseId: 'E06V3987PMY' });
is(p.app_id, 'A0123456789');
is(p.enterprise_id, 'E06V3987PMY');
ok(!('request_id' in p));
});
// MUTATION TARGET: swapping request_id key name or dropping it would fail this
test('buildAppApproveRestrictParams request_id key is exactly "request_id"', () => {
const p = buildAppApproveRestrictParams({ requestId: 'I999' });
ok('request_id' in p);
ok(!('requestId' in p));
});
// ── buildAppClearResolutionParams ─────────────────────────────────────────────
test('buildAppClearResolutionParams returns app_id and enterprise_id', () => {
const p = buildAppClearResolutionParams('A0123456789', 'E06V3987PMY');
is(p.app_id, 'A0123456789');
is(p.enterprise_id, 'E06V3987PMY');
is(Object.keys(p).length, 2);
});
// ── buildAppPermissionsParams ─────────────────────────────────────────────────
test('buildAppPermissionsParams returns app_id and permission_type', () => {
const p = buildAppPermissionsParams('A0123456789', 'no_one');
is(p.app_id, 'A0123456789');
is(p.permission_type, 'no_one');
});
test('buildAppPermissionsParams with everyone', () => {
is(buildAppPermissionsParams('A1', 'everyone').permission_type, 'everyone');
});
test('buildAppPermissionsParams with named_entities', () => {
is(buildAppPermissionsParams('A1', 'named_entities').permission_type, 'named_entities');
});
// ── buildAppListParams ────────────────────────────────────────────────────────
test('buildAppListParams returns enterprise_id and limit', () => {
const p = buildAppListParams('E06V3987PMY', 10, '');
is(p.enterprise_id, 'E06V3987PMY');
is(p.limit, '10');
ok(!('cursor' in p));
});
test('buildAppListParams includes cursor when provided', () => {
const p = buildAppListParams('E06V3987PMY', 10, 'abc');
is(p.cursor, 'abc');
});
// ── resolveAppOrRequestId ─────────────────────────────────────────────────────
test('resolveAppOrRequestId: I-prefix is request ID', () => {
const r = resolveAppOrRequestId('I0C3EKRE3S5');
is(r.requestId, 'I0C3EKRE3S5');
ok(!('appId' in r));
});
test('resolveAppOrRequestId: A-prefix is app ID', () => {
const r = resolveAppOrRequestId('A0123456789');
is(r.appId, 'A0123456789');
ok(!('requestId' in r));
});
test('resolveAppOrRequestId: invalid input returns null', () => {
is(resolveAppOrRequestId('XBOGUS'), null);
is(resolveAppOrRequestId(''), null);
is(resolveAppOrRequestId(null), null);
is(resolveAppOrRequestId(undefined), null);
});
test('resolveAppOrRequestId: short ID is rejected', () => {
is(resolveAppOrRequestId('A123'), null);
is(resolveAppOrRequestId('I123'), null);
});
// MUTATION TARGET: swapping the I/A check would cause wrong routing
test('resolveAppOrRequestId: I and A are not interchangeable', () => {
const r1 = resolveAppOrRequestId('I0C3EKRE3S5');
const r2 = resolveAppOrRequestId('A0C3EKRE3S5');
ok('requestId' in r1);
ok('appId' in r2);
});
// ── isValidPermissionType ─────────────────────────────────────────────────────
test('isValidPermissionType: known values pass', () => {
ok(isValidPermissionType('no_one'));
ok(isValidPermissionType('everyone'));
ok(isValidPermissionType('named_entities'));
});
test('isValidPermissionType: unknown value fails', () => {
ok(!isValidPermissionType('admins_only'));
ok(!isValidPermissionType(''));
ok(!isValidPermissionType('NO_ONE'));
});
test('VALID_PERMISSION_TYPES has exactly 3 entries', () => {
is(VALID_PERMISSION_TYPES.size, 3);
});
// ── filterChannels ────────────────────────────────────────────────────────────
const SAMPLE_CHANNELS = [
{ id: 'C001', name: 'general' },
{ id: 'C002', name: 'support-tickets' },
{ id: 'C003', name: 'eng-support' },
{ id: 'C004', name: 'random' },
];
test('filterChannels: no query returns all channels', () => {
is(filterChannels(SAMPLE_CHANNELS, '').length, 4);
});
test('filterChannels: name substring match is case-insensitive', () => {
const r = filterChannels(SAMPLE_CHANNELS, 'SUPPORT');
is(r.length, 2);
ok(r.some(c => c.id === 'C002'));
ok(r.some(c => c.id === 'C003'));
});
test('filterChannels: exact ID match works', () => {
const r = filterChannels(SAMPLE_CHANNELS, 'C001');
is(r.length, 1);
is(r[0].name, 'general');
});
test('filterChannels: no match returns empty array', () => {
is(filterChannels(SAMPLE_CHANNELS, 'zzz-no-such-channel').length, 0);
});
test('filterChannels: does not mutate input array', () => {
const original = SAMPLE_CHANNELS.slice();
filterChannels(SAMPLE_CHANNELS, 'support');
is(SAMPLE_CHANNELS.length, original.length);
});
// MUTATION TARGET: removing case normalization would break this
test('filterChannels: query "General" matches "general" (case insensitive)', () => {
is(filterChannels(SAMPLE_CHANNELS, 'General').length, 1);
});
// ── summarizeChannel ──────────────────────────────────────────────────────────
test('summarizeChannel picks expected keys', () => {
const full = {
id: 'C001',
name: 'general',
is_private: false,
is_archived: false,
member_count: 150,
external_user_count: 0,
purpose: 'General',
created: 1600000000,
creator_id: 'U999',
last_activity_ts: 1700000000,
// extra fields that should be dropped:
canvas: {},
lists: [],
connected_team_ids: [],
};
const s = summarizeChannel(full);
is(s.id, 'C001');
is(s.name, 'general');
is(s.member_count, 150);
ok(!('canvas' in s));
ok(!('lists' in s));
});
// ── summarizeApproval ─────────────────────────────────────────────────────────
test('summarizeApproval extracts id, partner_org, channel, status, expires', () => {
const a = {
id: 'I0C3EKRE3S5',
connecting_team: { name: 'Slalom Consulting' },
channel: { name: 'rtcdp-alphasense' },
status: 'fulfilled',
date_expire: 1791213421,
};
const s = summarizeApproval(a);
is(s.id, 'I0C3EKRE3S5');
is(s.partner_org, 'Slalom Consulting');
is(s.channel, 'rtcdp-alphasense');
is(s.status, 'fulfilled');
is(s.expires, 1791213421);
});
test('summarizeApproval handles missing connecting_team gracefully', () => {
const s = summarizeApproval({ id: 'I001', status: 'pending', date_expire: 0 });
is(s.partner_org, '');
is(s.channel, '');
});
// ── classifyUser ──────────────────────────────────────────────────────────────
test('classifyUser: regular member', () => {
is(classifyUser({ is_restricted: false, is_ultra_restricted: false, deleted: false, is_bot: false }), 'regular');
});
test('classifyUser: multi-channel guest (is_restricted=true)', () => {
is(classifyUser({ is_restricted: true, is_ultra_restricted: false, deleted: false, is_bot: false }), 'multi-channel guest');
});
test('classifyUser: single-channel guest (is_ultra_restricted=true)', () => {
is(classifyUser({ is_restricted: true, is_ultra_restricted: true, deleted: false, is_bot: false }), 'single-channel guest');
});
test('classifyUser: deactivated (deleted=true) overrides guest flags', () => {
is(classifyUser({ is_restricted: true, is_ultra_restricted: true, deleted: true, is_bot: false }), 'deactivated');
});
test('classifyUser: bot', () => {
is(classifyUser({ is_restricted: false, is_ultra_restricted: false, deleted: false, is_bot: true }), 'bot');
});
test('classifyUser: deleted takes priority over bot', () => {
is(classifyUser({ deleted: true, is_bot: true }), 'deactivated');
});
test('classifyUser: null input returns unknown', () => {
is(classifyUser(null), 'unknown');
is(classifyUser(undefined), 'unknown');
});
// MUTATION TARGET: swapping deleted/bot priority would break this
test('classifyUser: priority order is deactivated > bot > single-guest > multi-guest > regular', () => {
is(classifyUser({ deleted: true, is_bot: true, is_ultra_restricted: true }), 'deactivated');
is(classifyUser({ deleted: false, is_bot: true, is_ultra_restricted: true }), 'bot');
is(classifyUser({ deleted: false, is_bot: false, is_ultra_restricted: true }), 'single-channel guest');
is(classifyUser({ deleted: false, is_bot: false, is_restricted: true }), 'multi-channel guest');
is(classifyUser({ deleted: false, is_bot: false }), 'regular');
});
// ── collectPages ──────────────────────────────────────────────────────────────
test('collectPages: single page with no next_cursor', async () => {
const pages = [{ ok: true, items: [1, 2, 3], next_cursor: '' }];
let call = 0;
const fetch = async () => pages[call++];
const r = await collectPages(fetch, 'items', 0);
is(r.items.length, 3);
is(r.pages, 1);
is(r.error, undefined);
});
test('collectPages: two pages with cursor handoff', async () => {
const responses = [
{ ok: true, items: ['a', 'b'], next_cursor: 'cursor1' },
{ ok: true, items: ['c', 'd'], next_cursor: '' },
];
let call = 0;
const fetch = async (cursor) => {
// First call cursor should be '', second 'cursor1'
if (call === 0) is(cursor, '');
if (call === 1) is(cursor, 'cursor1');
return responses[call++];
};
const r = await collectPages(fetch, 'items', 0);
is(r.items.length, 4);
is(r.pages, 2);
});
test('collectPages: response_metadata.next_cursor is respected', async () => {
const responses = [
{ ok: true, items: ['a'], response_metadata: { next_cursor: 'meta-cursor' } },
{ ok: true, items: ['b'], response_metadata: { next_cursor: '' } },
];
let call = 0;
const fetch = async () => responses[call++];
const r = await collectPages(fetch, 'items', 0);
is(r.items.length, 2);
is(r.pages, 2);
});
test('collectPages: error on first page returns error, no items', async () => {
const fetch = async () => ({ ok: false, error: 'not_authed' });
const r = await collectPages(fetch, 'items', 0);
is(r.error, 'not_authed');
is(r.items.length, 0);
});
test('collectPages: maxItems cap stops early', async () => {
const responses = [
{ ok: true, items: ['a', 'b', 'c'], next_cursor: 'next' },
{ ok: true, items: ['d', 'e'], next_cursor: '' },
];
let call = 0;
const fetch = async () => responses[call++];
const r = await collectPages(fetch, 'items', 2);
is(r.items.length, 2);
// Should NOT have fetched the second page because cap was hit on first
is(r.pages, 1);
});
// MUTATION TARGET: removing the maxItems check would break this
test('collectPages: maxItems=1 fetches only one item and stops', async () => {
const fetch = async () => ({ ok: true, items: ['x', 'y'], next_cursor: 'more' });
const r = await collectPages(fetch, 'items', 1);
is(r.items.length, 1);
is(r.pages, 1);
});
test('collectPages: missing items key returns empty chunk without error', async () => {
const fetch = async () => ({ ok: true, next_cursor: '' });
const r = await collectPages(fetch, 'conversations', 0);
is(r.items.length, 0);
is(r.error, undefined);
});
// ── Mutation verification matrix ──────────────────────────────────────────────
//
// Each mutation and the test(s) that catch it:
//
// MUTATION G1: In buildEgSetStatusParams, change 'delete' to 'inactive'
// Caught by: "buildEgSetStatusParams status=delete is NOT permanent delete"
// "buildEgSetStatusParams includes both user and status keys"
//
// MUTATION G2: In buildDeidentifyParams, change key name to 'user_id'
// Caught by: "buildDeidentifyParams uses the key 'user' not 'user_id'"
//
// MUTATION G3: In buildConvertChannelParams, change key to 'channel' (not channel_id)
// Caught by: "buildConvertChannelParams uses channel_id (not channel)"
//
// MUTATION G4: In buildChannelSearchParams, add a channel_ids field
// Caught by: "buildChannelSearchParams never includes channel_ids"
//
// MUTATION G5: In buildAppApproveRestrictParams, swap requestId branch
// (use app_id+enterprise_id for request ID input)
// Caught by: "buildAppApproveRestrictParams with requestId uses request_id"
// "buildAppApproveRestrictParams request_id key is exactly 'request_id'"
//
// MUTATION G6: In resolveAppOrRequestId, swap I→appId, A→requestId
// Caught by: "resolveAppOrRequestId: I and A are not interchangeable"
// "resolveAppOrRequestId: I-prefix is request ID"
// "resolveAppOrRequestId: A-prefix is app ID"
//
// MUTATION G7: In filterChannels, remove toLowerCase() case normalization
// Caught by: "filterChannels: name substring match is case-insensitive"
// "filterChannels: query 'General' matches 'general'"
//
// MUTATION G8: In classifyUser, swap deleted and bot priority
// Caught by: "classifyUser: deleted takes priority over bot"
// "classifyUser: priority order is deactivated > bot > ..."
//
// MUTATION G9: In collectPages, remove the maxItems cap check
// Caught by: "collectPages: maxItems cap stops early"
// "collectPages: maxItems=1 fetches only one item and stops"
//
// MUTATION G10: In collectPages, ignore response_metadata.next_cursor
// Caught by: "collectPages: response_metadata.next_cursor is respected"
//
// VERIFICATION RECORD:
// Each mutation G1-G10 was applied, the named test confirmed to fail,
// and then reverted. See PR description for details.
// MUTATION G11: In buildChannelSearchParams, change default to 'all' (not 'exclude_archived')
// Caught by: tests 13, 22
// (Matters: 'all' returns 2072 channels, 'exclude_archived' returns 1515 in this org)
//
// MUTATION G12: In buildChannelSearchParams, omit query/cursor when empty (undefined)
// Caught by: tests 14, 15
// (query and cursor must be present even as empty strings, per observed API behaviour)
//
// VERIFICATION: G1-G12 each applied, named test confirmed to fail, mutation reverted.
// ── channel-archive / channel-unarchive ─────────────────────────────────────────
//
// Every flow test drives runChannelArchiveFlow with a stubbed call() that
// records the exact call sequence, a stubbed sleep() that records each delay,
// and a fixed clock. The write methods must appear only on the paths that are
// allowed to write.
const {
CHANNEL_SEARCH_MAX_LIMIT,
READBACK_ATTEMPTS,
READBACK_DELAY_MS,
buildArchiveChannelParams,
buildChannelLookupParams,
lastActivityToMs,
idleDaysSince,
knownCount,
classifyChannelHost,
normalizeChannelState,
evaluateChannelGuards,
lookupChannel,
runChannelArchiveFlow,
} = gridMod.default || gridMod;
const ORG = 'E06V3987PMY';
// 2026-09-25T13:03:06Z, the day these wire facts were measured.
const NOW_MS = 1790341386000;
const DAY = 86400000;
// last_activity_ts is MICROSECONDS on the wire.
const usAgo = (days) => (NOW_MS - days * DAY) * 1000;
function chan(over) {
return Object.assign(
{
id: 'C04633RSEDU',
name: 'assets-adidas',
is_private: true,
is_archived: false,
member_count: 6,
external_user_count: 0,
is_ext_shared: false,
is_pending_ext_shared: false,
is_org_shared: false,
last_activity_ts: 1679564401348099,
},
over || {}
);
}
// states: channel object per search call, in order (the last one repeats).
// null means "the search does not return it". Each search answers the channel
// plus a decoy with a different id, so a match that is not by id is caught.
function stub(spec) {
const s = spec || {};
const calls = [];
const sleeps = [];
let searches = 0;
const call = async (method, params) => {
calls.push({ method, params });
if (method === 'admin.conversations.search') {
if (s.searchError) return { ok: false, error: s.searchError };
const list = s.states || [chan()];
const st = list[Math.min(searches, list.length - 1)];
searches += 1;
const decoy = chan({ id: 'C0DECOY0001', name: 'decoy', is_archived: !(st && st.is_archived) });
return { ok: true, conversations: st ? [decoy, st] : [decoy], next_cursor: '' };
}
if (method === 'conversations.info') {
return s.info || { ok: false, error: 'channel_not_found' };
}
if (method === 'admin.conversations.archive' || method === 'admin.conversations.unarchive') {
return s.writeResult || { ok: true };
}
return { ok: false, error: 'not_mocked' };
};
return {
call,
calls,
sleeps,
seq: () => calls.map((c) => c.method).join(','),
run: (over) =>
runChannelArchiveFlow(
Object.assign(
{
action: 'archive',
channelId: 'C04633RSEDU',
orgId: ORG,
confirm: false,
maxMembers: null,
minIdleDays: null,
allowShared: false,
call,
sleep: async (ms) => {
sleeps.push(ms);
},
now: () => NOW_MS,
},
over || {}
)
),
};
}
const S = 'admin.conversations.search';
const A = 'admin.conversations.archive';
const U = 'admin.conversations.unarchive';
const writes = (h) => h.calls.filter((c) => c.method === A || c.method === U).length;
// ── pure helpers ──
test('buildArchiveChannelParams sends channel_id only', () => {
const p = buildArchiveChannelParams('C0634KMGW2G');
is(JSON.stringify(p), '{"channel_id":"C0634KMGW2G"}');
});
test('buildChannelLookupParams: all six required params, limit 20, types all, no channel_ids', () => {
const p = buildChannelLookupParams('C0634KMGW2G', '');
is(
JSON.stringify(Object.keys(p).sort()),
JSON.stringify(['cursor', 'limit', 'query', 'search_channel_types', 'sort', 'sort_dir'])
);
is(p.limit, '20');
is(p.search_channel_types, 'all');
is(p.query, 'C0634KMGW2G');
is(p.cursor, '');
is(p.channel_ids, undefined);
});
test('CHANNEL_SEARCH_MAX_LIMIT never exceeds 20 (21 answers invalid_arguments)', () => {
ok(CHANNEL_SEARCH_MAX_LIMIT <= 20);
is(Number(buildChannelLookupParams('x').limit) <= 20, true);
});
test('lastActivityToMs: 16-digit microseconds convert to milliseconds', () => {
is(lastActivityToMs(1686690712432979), 1686690712432);
is(new Date(lastActivityToMs(1686690712432979)).toISOString(), '2023-06-13T21:11:52.432Z');
is(lastActivityToMs('1686690712432979'), 1686690712432);
});
test('lastActivityToMs: microseconds are NOT read as milliseconds or seconds', () => {
// Reading the measured value as ms would land in year ~55,000; as seconds, further.
const ms = lastActivityToMs(1686690712432979);
is(new Date(ms).getUTCFullYear(), 2023);
});
test('lastActivityToMs: seconds and milliseconds recognised by magnitude', () => {
is(lastActivityToMs(1686690712), 1686690712000);
is(lastActivityToMs('1686690712.432979'), 1686690712432);
is(lastActivityToMs(1686690712432), 1686690712432);
});
test('lastActivityToMs: missing, zero, negative and garbage are null (unknown)', () => {
is(lastActivityToMs(undefined), null);
is(lastActivityToMs(null), null);
is(lastActivityToMs(0), null);
is(lastActivityToMs(-5), null);
is(lastActivityToMs('soon'), null);
is(lastActivityToMs(12345), null);
});
test('idleDaysSince: assets-adidas measured value is 1282 days idle', () => {
is(idleDaysSince(lastActivityToMs(1679564401348099), NOW_MS), 1282);
});
test('idleDaysSince: unknown stays null, future clamps to 0', () => {
is(idleDaysSince(null, NOW_MS), null);
is(idleDaysSince(NOW_MS + DAY, NOW_MS), 0);
});
test('knownCount: -1, null and undefined are unknown, 0 is a count', () => {
is(knownCount(-1), null);
is(knownCount(null), null);
is(knownCount(undefined), null);
is(knownCount('6'), null);
is(knownCount(0), 0);
is(knownCount(6), 6);
});
test('classifyChannelHost: not-shared, us, other, unknown', () => {
is(classifyChannelHost(chan(), ORG), 'not-shared');
is(classifyChannelHost(chan({ is_ext_shared: true, conversation_host_id: ORG }), ORG), 'us');
is(classifyChannelHost(chan({ is_ext_shared: true, conversation_host_id: 'ELWSLBREU' }), ORG), 'other');
is(classifyChannelHost(chan({ is_ext_shared: true }), ORG), 'unknown');
is(classifyChannelHost(chan({ is_pending_ext_shared: true, conversation_host_id: ORG }), ORG), 'us');
});
test('normalizeChannelState: fields, microsecond date, idle days, unknown members', () => {
const st = normalizeChannelState(chan({ member_count: -1 }), ORG, NOW_MS);
is(st.name, 'assets-adidas');
is(st.is_private, true);
is(st.is_archived, false);
is(st.member_count, null);
is(st.member_count_raw, -1);
is(st.last_activity_date, '2023-03-23');
is(st.idle_days, 1282);
is(st.host, 'not-shared');
});
test('evaluateChannelGuards: null state is not-found', () => {
is(evaluateChannelGuards('archive', null, { orgId: ORG }).reason, 'not-found');
});
test('evaluateChannelGuards: members-unknown for null AND -1 when --max-members is set', () => {
for (const mc of [null, undefined, -1]) {
const st = normalizeChannelState(chan({ member_count: mc }), ORG, NOW_MS);
const d = evaluateChannelGuards('archive', st, { orgId: ORG, maxMembers: 2 });
is(d.outcome, 'refuse', 'member_count=' + mc);
is(d.reason, 'members-unknown', 'member_count=' + mc);
}
});
test('evaluateChannelGuards: unknown members are irrelevant without --max-members', () => {
const st = normalizeChannelState(chan({ member_count: null }), ORG, NOW_MS);
is(evaluateChannelGuards('archive', st, { orgId: ORG }).outcome, 'proceed');
});
test('evaluateChannelGuards: members-over-limit is strictly greater than', () => {
const st = normalizeChannelState(chan({ member_count: 2 }), ORG, NOW_MS);
is(evaluateChannelGuards('archive', st, { orgId: ORG, maxMembers: 2 }).outcome, 'proceed');
is(evaluateChannelGuards('archive', st, { orgId: ORG, maxMembers: 1 }).reason, 'members-over-limit');
const zero = normalizeChannelState(chan({ member_count: 0 }), ORG, NOW_MS);
is(evaluateChannelGuards('archive', zero, { orgId: ORG, maxMembers: 0 }).outcome, 'proceed');
});
test('evaluateChannelGuards: active-recently and activity-unknown', () => {
const recent = normalizeChannelState(chan({ last_activity_ts: usAgo(10) }), ORG, NOW_MS);
is(evaluateChannelGuards('archive', recent, { orgId: ORG, minIdleDays: 30 }).reason, 'active-recently');
is(evaluateChannelGuards('archive', recent, { orgId: ORG, minIdleDays: 10 }).outcome, 'proceed');
const unknown = normalizeChannelState(chan({ last_activity_ts: 0 }), ORG, NOW_MS);
is(evaluateChannelGuards('archive', unknown, { orgId: ORG, minIdleDays: 30 }).reason, 'activity-unknown');
});
test('evaluateChannelGuards: ext-shared hosted elsewhere is refused for both actions', () => {
const st = normalizeChannelState(chan({ is_ext_shared: true, conversation_host_id: 'ELWSLBREU' }), ORG, NOW_MS);
is(evaluateChannelGuards('archive', st, { orgId: ORG, allowShared: true }).reason, 'ext-shared-hosted-elsewhere');
const arch = normalizeChannelState(
chan({ is_archived: true, is_ext_shared: true, conversation_host_id: 'ELWSLBREU' }),
ORG,
NOW_MS
);
is(evaluateChannelGuards('unarchive', arch, { orgId: ORG }).reason, 'ext-shared-hosted-elsewhere');
});
test('evaluateChannelGuards: ext-shared hosted by us needs --allow-shared to archive', () => {
const st = normalizeChannelState(chan({ is_ext_shared: true, conversation_host_id: ORG }), ORG, NOW_MS);
is(evaluateChannelGuards('archive', st, { orgId: ORG }).reason, 'ext-shared-requires-allow-shared');
is(evaluateChannelGuards('archive', st, { orgId: ORG, allowShared: true }).outcome, 'proceed');
});
test('evaluateChannelGuards: ext-shared with no host id is refused as ext-shared-host-unknown', () => {
const st = normalizeChannelState(chan({ is_ext_shared: true }), ORG, NOW_MS);
is(evaluateChannelGuards('archive', st, { orgId: ORG, allowShared: true }).reason, 'ext-shared-host-unknown');
});
test('evaluateChannelGuards: already-archived / not-archived are noop, missing is_archived refuses', () => {
const arch = normalizeChannelState(chan({ is_archived: true, member_count: -1 }), ORG, NOW_MS);
const d = evaluateChannelGuards('archive', arch, { orgId: ORG, maxMembers: 1 });
is(d.outcome, 'noop');
is(d.reason, 'already-archived');
is(evaluateChannelGuards('unarchive', normalizeChannelState(chan(), ORG, NOW_MS), { orgId: ORG }).reason, 'not-archived');
const noFlag = normalizeChannelState(chan({ is_archived: undefined }), ORG, NOW_MS);
is(evaluateChannelGuards('archive', noFlag, { orgId: ORG }).reason, 'archived-unknown');
});
// ── lookup ──
test('lookupChannel: finds by query=<id> and matches on id, not on position', async () => {
const h = stub();
const r = await lookupChannel(h.call, 'C04633RSEDU');
is(r.found, true);
is(r.channel.id, 'C04633RSEDU');
is(r.via, 'id');
is(h.seq(), S);
is(h.calls[0].params.query, 'C04633RSEDU');
is(h.calls[0].params.limit, '20');
});
test('lookupChannel: id miss falls back to conversations.info name, then query=<name>', async () => {
const states = [null, chan()];
const h = stub({ states, info: { ok: true, channel: { id: 'C04633RSEDU', name: 'assets-adidas' } } });
const r = await lookupChannel(h.call, 'C04633RSEDU');
is(r.found, true);
is(r.via, 'name');
is(h.seq(), [S, 'conversations.info', S].join(','));
is(h.calls[2].params.query, 'assets-adidas');
});
test('lookupChannel: miss everywhere is found:false (caller refuses not-found)', async () => {
const h = stub({ states: [null] });
const r = await lookupChannel(h.call, 'C04633RSEDU');
is(r.found, false);
is(h.seq(), [S, 'conversations.info'].join(','));
});
test('lookupChannel: pages with the cursor, capped, matching id locally', async () => {
const calls = [];
const call = async (method, params) => {
calls.push(params.cursor);
if (params.cursor === '') return { ok: true, conversations: [chan({ id: 'C0OTHER0001' })], next_cursor: 'p2' };
return { ok: true, conversations: [chan()], next_cursor: '' };
};
const r = await lookupChannel(call, 'C04633RSEDU');
is(r.found, true);
is(calls.join('|'), '|p2');
});
// ── flow: dry run ──
test('flow dry run: reads only, makes no archive call', async () => {
const h = stub();
const r = await h.run();
is(h.seq(), S);
is(writes(h), 0);
is(r.mode, 'dry-run');
is(r.status, 'dry-run');
is(r.decision.outcome, 'proceed');
is(r.state.member_count, 6);
is(r.state.idle_days, 1282);
is(r.exitCode, 0);
});
test('flow dry run: a would-be refusal is reported, still no write', async () => {
const h = stub();
const r = await h.run({ maxMembers: 2 });
is(h.seq(), S);
is(r.decision.reason, 'members-over-limit');
is(r.exitCode, 0);
});
test('flow dry run: already archived channel reports already-archived', async () => {
const h = stub({ states: [chan({ id: 'C0634KMGW2G', name: 'aem-axeno1', is_private: false, is_archived: true, member_count: -1 })] });
const r = await h.run({ channelId: 'C0634KMGW2G' });
is(r.decision.reason, 'already-archived');
is(writes(h), 0);
});
// ── flow: named refusals under --confirm (each: no write, exit 1) ──
const REFUSALS = [
['not-found', { states: [null] }, {}],
['members-over-limit', {}, { maxMembers: 2 }],
['members-unknown', { states: [chan({ member_count: null })] }, { maxMembers: 10 }],
['active-recently', { states: [chan({ last_activity_ts: usAgo(3) })] }, { minIdleDays: 30 }],
['activity-unknown', { states: [chan({ last_activity_ts: undefined })] }, { minIdleDays: 30 }],
['ext-shared-hosted-elsewhere', { states: [chan({ is_ext_shared: true, conversation_host_id: 'E01UA4N2G78' })] }, { allowShared: true }],
['ext-shared-host-unknown', { states: [chan({ is_ext_shared: true })] }, {}],
['ext-shared-requires-allow-shared', { states: [chan({ is_ext_shared: true, conversation_host_id: ORG })] }, {}],
];
for (const [reason, stubSpec, runOver] of REFUSALS) {
test('flow --confirm refusal ' + reason + ': named, exit 1, no archive call', async () => {
const h = stub(stubSpec);
const r = await h.run(Object.assign({ confirm: true }, runOver));
is(r.status, 'refused');
is(r.decision.reason, reason);
is(r.exitCode, 1);
is(writes(h), 0);
});
}
test('flow --confirm members-unknown: member_count null is refused, never read as 0', async () => {
const h = stub({ states: [chan({ member_count: null })] });
const r = await h.run({ confirm: true, maxMembers: 5 });
is(r.decision.reason, 'members-unknown');
is(r.state.member_count, null);
is(h.seq(), S);
});
test('flow --confirm re-check: dry run saw 1 member, 4 by confirm time -> refused', async () => {
// The measured incident: checked at 1 member, 4 members 45 s later.
const h = stub({ states: [chan({ member_count: 1 }), chan({ member_count: 4 })] });
const dry = await h.run({ maxMembers: 1 });
is(dry.decision.outcome, 'proceed');
const r = await h.run({ confirm: true, maxMembers: 1 });
is(r.decision.reason, 'members-over-limit');
is(r.state.member_count, 4);
is(h.seq(), [S, S].join(','));
is(writes(h), 0);
});
test('flow --confirm with a failing pre-write read: exit 1, no write', async () => {
const h = stub({ searchError: 'ratelimited' });
const r = await h.run({ confirm: true });
is(r.status, 'read-error');
is(r.exitCode, 1);
is(writes(h), 0);
});
// ── flow: already-archived / not-archived ──
test('flow --confirm already-archived: exit 0, nothing to do, no write', async () => {
const h = stub({ states: [chan({ is_archived: true, member_count: -1 })] });
const r = await h.run({ confirm: true, maxMembers: 1 });
is(r.status, 'already-archived');
is(r.exitCode, 0);
is(h.seq(), S);
});
test('flow --confirm unarchive of an unarchived channel: not-archived, exit 0, no write', async () => {
const h = stub();
const r = await h.run({ action: 'unarchive', confirm: true });
is(r.status, 'not-archived');
is(r.exitCode, 0);
is(writes(h), 0);
});
// ── flow: success, lag, exhausted ──
test('flow --confirm success: re-check, archive, read-back (exact sequence)', async () => {
const h = stub({ states: [chan(), chan({ is_archived: true, member_count: -1 })] });
const r = await h.run({ confirm: true, maxMembers: 10, minIdleDays: 365 });
is(h.seq(), [S, A, S].join(','));
is(JSON.stringify(h.calls[1].params), '{"channel_id":"C04633RSEDU"}');
is(r.status, 'archived (confirmed)');
is(r.readback.confirmed, true);
is(r.readback.attempts, 1);
is(r.exitCode, 0);
is(h.sleeps.length, 0);
});
test('flow --confirm read-back lag: first two reads stale, third confirms', async () => {
const h = stub({ states: [chan(), chan(), chan(), chan({ is_archived: true })] });
const r = await h.run({ confirm: true });
is(h.seq(), [S, A, S, S, S].join(','));
is(r.status, 'archived (confirmed)');
is(r.readback.attempts, 3);
is(h.sleeps.join(','), [READBACK_DELAY_MS, READBACK_DELAY_MS].join(','));
is(r.exitCode, 0);
});
test('flow --confirm read-back exhausted: unconfirmed (not failed), exit 3', async () => {
const h = stub({ states: [chan()] });
const r = await h.run({ confirm: true });
is(h.calls.filter((c) => c.method === S).length, 1 + READBACK_ATTEMPTS);
is(writes(h), 1);
is(r.readback.confirmed, false);
is(r.readback.attempts, READBACK_ATTEMPTS);
is(r.status, 'archived (unconfirmed: search index did not reflect it after ' + READBACK_ATTEMPTS + ' attempts)');
is(r.exitCode, 3);
is(h.sleeps.length, READBACK_ATTEMPTS - 1);
});
test('flow read-back window covers the measured 38-51 s archive index lag', () => {
// Live round trip 2026-09-25: the archive showed in the index 38-51 s after
// the write. A 30 s window (7 x 5 s) ran out and reported unconfirmed.
ok((READBACK_ATTEMPTS - 1) * READBACK_DELAY_MS >= 60000);
});
test('flow --confirm write error: reported, exit 1, no read-back', async () => {
const h = stub({ writeResult: { ok: false, error: 'restricted_action' } });
const r = await h.run({ confirm: true });
is(h.seq(), [S, A].join(','));
is(r.status, 'error');
is(r.write.error, 'restricted_action');
is(r.exitCode, 1);
});
test('flow --confirm unarchive success: re-check, unarchive, read-back until not archived', async () => {
const h = stub({ states: [chan({ is_archived: true }), chan({ is_archived: true }), chan({ is_archived: false })] });
const r = await h.run({ action: 'unarchive', confirm: true });
is(h.seq(), [S, U, S, S].join(','));
is(r.status, 'unarchived (confirmed)');
is(r.exitCode, 0);
});
test('flow: every search call in every path uses limit 20', async () => {
const h = stub({ states: [chan()] });
await h.run({ confirm: true });
ok(h.calls.length > 2);
for (const c of h.calls.filter((x) => x.method === S)) is(c.params.limit, '20');
});
// MUTATION M1 (pre-write re-check always passes): in runChannelArchiveFlow,
// skip the `decision.outcome === 'refuse'` return under --confirm.
// Caught by: every "flow --confirm refusal <reason>" test, "flow --confirm
// members-unknown ...", "flow --confirm re-check: dry run saw 1 member ...".
// MUTATION M2 (null member count read as 0): in evaluateChannelGuards, drop
// the members-unknown branch and compare (member_count || 0).
// Caught by: "evaluateChannelGuards: members-unknown for null AND -1 ...",
// "flow --confirm refusal members-unknown ...", "flow --confirm
// members-unknown: member_count null is refused, never read as 0".
// ── member_count: -1 (measured on archived channels) and Slack Connect impact ──
const { externalTeamIds, sharedArchiveImpact, summarizeChannel: _summarize } = gridMod.default || gridMod;
test('knownCount: any negative or non-finite count is unknown', () => {
for (const n of [-1, -5, -0.5, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) {
is(knownCount(n), null, 'knownCount(' + n + ')');
}
});
test('evaluateChannelGuards: member_count -1 on an ACTIVE channel is members-unknown, not a pass', () => {
// A plain `members <= max` check would pass -1 for any max.
const st = normalizeChannelState(chan({ is_archived: false, member_count: -1 }), ORG, NOW_MS);
is(st.member_count, null);
const d = evaluateChannelGuards('archive', st, { orgId: ORG, maxMembers: 0 });
is(d.outcome, 'refuse');
is(d.reason, 'members-unknown');
});
test('evaluateChannelGuards: NaN / Infinity / -5 member counts are members-unknown', () => {
for (const mc of [Number.NaN, Number.POSITIVE_INFINITY, -5]) {
const st = normalizeChannelState(chan({ member_count: mc }), ORG, NOW_MS);
is(evaluateChannelGuards('archive', st, { orgId: ORG, maxMembers: 100 }).reason, 'members-unknown', String(mc));
}
});
test('evaluateChannelGuards: ARCHIVED channel with member_count -1 reports already-archived, not members-unknown', () => {
const st = normalizeChannelState(chan({ is_archived: true, member_count: -1 }), ORG, NOW_MS);
const d = evaluateChannelGuards('archive', st, { orgId: ORG, maxMembers: 2, minIdleDays: 30 });
is(d.outcome, 'noop');
is(d.reason, 'already-archived');
});
test('flow --confirm: active channel with member_count -1 is refused members-unknown, no archive call', async () => {
const h = stub({ states: [chan({ is_archived: false, member_count: -1 })] });
const r = await h.run({ confirm: true, maxMembers: 50 });
is(r.status, 'refused');
is(r.decision.reason, 'members-unknown');
is(r.exitCode, 1);
is(h.seq(), S);
is(writes(h), 0);
});
test('flow --confirm: archived channel with member_count -1 reports already-archived, exit 0, no write', async () => {
const h = stub({ states: [chan({ is_archived: true, member_count: -1 })] });
const r = await h.run({ confirm: true, maxMembers: 2 });
is(r.status, 'already-archived');
is(r.decision.reason, 'already-archived');
is(r.exitCode, 0);
is(writes(h), 0);
});
// Measured shape of a Slack Connect channel we host (aem-pga-tour, read-only).
const connectChan = (over) =>
chan(
Object.assign(
{
id: 'C03GXBSC72T',
name: 'aem-pga-tour',
is_private: false,
member_count: 116,
external_user_count: 41,
is_ext_shared: true,
conversation_host_id: ORG,
context_team_id: 'T0385CHDU9E',
connected_team_ids: ['T0BQQL6FJ', ORG, 'E08CP5WPXGT'],
pending_connected_team_ids: [],
internal_team_ids: ['T0385CHDU9E'],
},
over || {}
)
);
test('externalTeamIds: drops this org, its internal workspaces and context team', () => {
const c = connectChan({ connected_team_ids: ['T0BQQL6FJ', ORG, 'T0385CHDU9E', 'E08CP5WPXGT', 'T0BQQL6FJ'] });
is(externalTeamIds(c.connected_team_ids, c, ORG).join(','), 'T0BQQL6FJ,E08CP5WPXGT');
// A missing list is UNKNOWN (null), never an empty list (Codex round 5).
is(externalTeamIds(undefined, c, ORG), null);
is(externalTeamIds(null, c, ORG), null);
is(externalTeamIds('T0BQQL6FJ', c, ORG), null);
is(externalTeamIds([], c, ORG).length, 0, 'an actual empty list stays empty');
});
test('sharedArchiveImpact: says N external users, M organisations, and NOT reversible', () => {
const imp = sharedArchiveImpact(normalizeChannelState(connectChan(), ORG, NOW_MS));
is(imp.external_users, 41);
is(imp.external_team_ids.join(','), 'T0BQQL6FJ,E08CP5WPXGT');
is(imp.reversible, false);
ok(imp.text.includes('Archiving will disconnect 41 external users from 2 external organisations'), imp.text);
ok(imp.text.includes('Unarchiving will NOT reconnect them'), imp.text);
});
test('sharedArchiveImpact: unknown external count is said as unknown, never 0', () => {
const imp = sharedArchiveImpact(normalizeChannelState(connectChan({ external_user_count: -1 }), ORG, NOW_MS));
is(imp.external_users, null);
ok(imp.text.includes('an unknown number of external users'), imp.text);
});
test('sharedArchiveImpact: pending invitations are named; not-shared channels have no impact', () => {
const imp = sharedArchiveImpact(
normalizeChannelState(connectChan({ pending_connected_team_ids: ['T0PENDING1'] }), ORG, NOW_MS)
);
ok(imp.text.includes('1 pending Slack Connect invitation (T0PENDING1)'), imp.text);
is(sharedArchiveImpact(normalizeChannelState(chan(), ORG, NOW_MS)), null);
});
test('ext-shared-requires-allow-shared refusal names the disconnect in plain words', () => {
const d = evaluateChannelGuards('archive', normalizeChannelState(connectChan(), ORG, NOW_MS), { orgId: ORG });
is(d.reason, 'ext-shared-requires-allow-shared');
ok(d.detail.includes('disconnect 41 external users from 2 external organisations'), d.detail);
ok(d.detail.includes('--allow-shared'), d.detail);
});
test('already-archived is checked before the Slack Connect guard', () => {
const st = normalizeChannelState(connectChan({ is_archived: true, member_count: -1 }), ORG, NOW_MS);
is(evaluateChannelGuards('archive', st, { orgId: ORG }).reason, 'already-archived');
});
test('flow dry run on a Slack Connect channel without --allow-shared: refusal + impact, no write', async () => {
const h = stub({ states: [connectChan()] });
const r = await h.run({ channelId: 'C03GXBSC72T' });
is(h.seq(), S);
is(r.decision.reason, 'ext-shared-requires-allow-shared');
is(r.impact.external_users, 41);
is(r.impact.reversible, false);
});
test('flow --confirm --allow-shared on a Slack Connect channel: re-check, archive, read-back; impact reported', async () => {
const after = connectChan({
is_archived: true,
member_count: -1,
is_ext_shared: false,
external_user_count: 0,
connected_team_ids: [],
});
const h = stub({ states: [connectChan(), after] });
const r = await h.run({ channelId: 'C03GXBSC72T', confirm: true, allowShared: true });
is(h.seq(), [S, A, S].join(','));
is(r.status, 'archived (confirmed)');
is(r.impact.external_team_ids.length, 2);
is(r.readback.state.is_ext_shared, false);
is(r.readback.state.external_team_ids.length, 0);
});
test('state read uses the RAW search entry: fields channel-search --json drops still drive the guards', () => {
const raw = connectChan();
const summary = _summarize(raw);
is(summary.is_ext_shared, undefined, 'summarizeChannel drops is_ext_shared (known follow-up)');
is(summary.conversation_host_id, undefined);
const st = normalizeChannelState(raw, ORG, NOW_MS);
is(st.is_ext_shared, true);
is(st.conversation_host_id, ORG);
is(st.host, 'us');
});
// MUTATION M3 (-1 accepted as a count): in knownCount, drop `&& n >= 0`.
// Caught by: "knownCount: any negative or non-finite count is unknown",
// "evaluateChannelGuards: member_count -1 on an ACTIVE channel ...",
// "flow --confirm: active channel with member_count -1 is refused ...".
// MUTATION M4 (member guard before already-archived): move the
// already-archived check below the member-count guard.
// Caught by: "... ARCHIVED channel with member_count -1 reports already-archived ...",
// "flow --confirm: archived channel with member_count -1 reports already-archived ...".
// ── A lookup that did not complete is a FAILURE, never "not found" ────────────
//
// channel-search has shipped two false "no channels matched" answers: --max
// dropping matches, and --json printing no JSON on zero results. A false
// not-found here would say a channel does not exist when it does. So every
// incomplete read must surface as an error.
function rawCall(fn) {
const calls = [];
const call = async (method, params) => {
calls.push({ method, params });
return fn(method, params, calls.length);
};
return { call, calls };
}
test('lookupChannel: no body at all is an error, not not-found', async () => {
const h = rawCall(() => undefined);
const r = await lookupChannel(h.call, 'C04633RSEDU');
is(r.found, false);
is(r.error, 'no_response');
});
test('lookupChannel: null / non-object body is an error, not not-found', async () => {
for (const body of [null, 'oops', 42]) {
const r = await lookupChannel(rawCall(() => body).call, 'C04633RSEDU');
is(r.error, 'no_response', 'body=' + JSON.stringify(body));
}
});
test('lookupChannel: ok:true with no conversations array is malformed_response, not not-found', async () => {
for (const body of [{ ok: true }, { ok: true, conversations: null }, { ok: true, conversations: {} }]) {
const h = rawCall((m) => (m === 'admin.conversations.search' ? body : { ok: false, error: 'channel_not_found' }));
const r = await lookupChannel(h.call, 'C04633RSEDU');
is(r.found, false);
is(r.error, 'malformed_response', JSON.stringify(body));
is(h.calls.length, 1, 'must stop at the malformed page, not fall back and call it a miss');
}
});
test('lookupChannel: page cap reached with a cursor pending is lookup_truncated, not not-found', async () => {
const h = rawCall(() => ({ ok: true, conversations: [chan({ id: 'C0OTHER0001' })], next_cursor: 'more' }));
const r = await lookupChannel(h.call, 'C04633RSEDU');
is(r.found, false);
is(r.error, 'lookup_truncated');
is(h.calls.filter((c) => c.method === S).length, 5);
});
test('lookupChannel: a complete search with no hit (cursor exhausted) IS not-found', async () => {
// Control for the three tests above: a genuine, complete miss stays a miss.
const h = rawCall((m) =>
m === 'admin.conversations.search'
? { ok: true, conversations: [chan({ id: 'C0OTHER0001' })], next_cursor: '' }
: { ok: false, error: 'channel_not_found' }
);
const r = await lookupChannel(h.call, 'C04633RSEDU');
is(r.found, false);
is(r.error, undefined);
});
test('lookupChannel: conversations.info failing with anything but channel_not_found is an error', async () => {
const miss = { ok: true, conversations: [], next_cursor: '' };
const r1 = await lookupChannel(
rawCall((m) => (m === S ? miss : { ok: false, error: 'ratelimited' })).call,
'C04633RSEDU'
);
is(r1.error, 'conversations.info: ratelimited');
const r2 = await lookupChannel(rawCall((m) => (m === S ? miss : undefined)).call, 'C04633RSEDU');
is(r2.error, 'no_response');
});
test('lookupChannel: never calls channel-search-style params (no max, no channel_ids)', async () => {
const h = rawCall((m) => (m === S ? { ok: true, conversations: [], next_cursor: '' } : { ok: false, error: 'channel_not_found' }));
await lookupChannel(h.call, 'C04633RSEDU');
for (const c of h.calls.filter((x) => x.method === S)) {
is(c.params.max, undefined);
is(c.params.channel_ids, undefined);
is(c.params.limit, '20');
}
});
test('flow --confirm with a missing body: read-error, exit 1, no decision, no write', async () => {
const h = rawCall(() => undefined);
const r = await runChannelArchiveFlow({
action: 'archive', channelId: 'C04633RSEDU', orgId: ORG, confirm: true,
maxMembers: null, minIdleDays: null, call: h.call, sleep: async () => {}, now: () => NOW_MS,
});
is(r.status, 'read-error');
is(r.exitCode, 1);
is(r.decision, null, 'must not be evaluated as not-found');
is(h.calls.filter((c) => c.method === A).length, 0);
});
test('flow dry run with a malformed body: read-error (exit 1), not a not-found report', async () => {
const h = rawCall(() => ({ ok: true }));
const r = await runChannelArchiveFlow({
action: 'archive', channelId: 'C04633RSEDU', orgId: ORG, confirm: false,
maxMembers: null, minIdleDays: null, call: h.call, sleep: async () => {}, now: () => NOW_MS,
});
is(r.status, 'read-error');
is(r.exitCode, 1);
ok(/malformed_response/.test(r.error), r.error);
});
// MUTATION M5 (malformed body read as empty): in lookupChannel, replace the
// Array.isArray(r.conversations) check with `(r.conversations || [])`.
// Caught by: "lookupChannel: ok:true with no conversations array is
// malformed_response ...", "flow dry run with a malformed body ...".
// ── argument validation (channel-archive / channel-unarchive) ─────────────────
const { suggestFlag, parseGuardCount, checkChannelArchiveArgs, editDistance, CHANNEL_GLOBAL_FLAGS } =
gridMod.default || gridMod;
const P = ['channel-archive', 'C04633RSEDU'];
test('suggestFlag: singular, missing hyphen and typo map to the real flag', () => {
const allowed = CHANNEL_GLOBAL_FLAGS.concat(['max-members', 'min-idle-days', 'allow-shared']);
is(suggestFlag('max-member', allowed), 'max-members');
is(suggestFlag('min-idle-day', allowed), 'min-idle-days');
is(suggestFlag('allowshared', allowed), 'allow-shared');
is(suggestFlag('confrm', allowed), 'confirm');
is(suggestFlag('completely-unrelated-thing', allowed), null);
is(editDistance('kitten', 'sitting'), 3);
});
test('parseGuardCount: only plain non-negative integers', () => {
is(JSON.stringify(parseGuardCount(undefined)), '{"ok":true,"value":null}');
is(parseGuardCount('0').value, 0);
is(parseGuardCount('180').value, 180);
for (const bad of [true, '', 'abc', '-3', '2.5', '1e3', ' 2', '+2', '0x10', '99999999999999999999']) {
is(parseGuardCount(bad).ok, false, 'must reject ' + JSON.stringify(bad));
}
});
test('checkChannelArchiveArgs: every global and command flag is accepted', () => {
const r = checkChannelArchiveArgs(
'archive',
{ ws: 'T0385CHDU9E', workspace: 'T1', org: 'E06V3987PMY', json: true, confirm: true, 'max-members': '2', 'min-idle-days': '180', 'allow-shared': true },
P
);
is(r.ok, true, JSON.stringify(r.errors));
is(r.maxMembers, 2);
is(r.minIdleDays, 180);
});
test('checkChannelArchiveArgs: unknown flags fail closed with the named code and a suggestion', () => {
const r = checkChannelArchiveArgs('archive', { 'max-member': '2', confirm: true }, P);
is(r.ok, false);
is(r.errors[0].code, 'unknown-flag');
is(r.errors[0].message, 'unknown-flag: --max-member (did you mean --max-members?)');
is(r.maxMembers, null, 'the misspelled guard must never populate the real one');
});
test('checkChannelArchiveArgs: an unknown flag with no near match still fails', () => {
const r = checkChannelArchiveArgs('archive', { 'totally-new': '1' }, P);
is(r.ok, false);
is(r.errors[0].message, 'unknown-flag: --totally-new');
});
test('checkChannelArchiveArgs: bad guard values are invalid-value, never null', () => {
for (const [name, raw] of [['max-members', 'abc'], ['max-members', ''], ['min-idle-days', '-3'], ['max-members', true]]) {
const r = checkChannelArchiveArgs('archive', { [name]: raw }, P);
is(r.ok, false, name + '=' + JSON.stringify(raw));
is(r.errors[0].code, 'invalid-value');
}
});
test('checkChannelArchiveArgs: a stray positional word is refused', () => {
const r = checkChannelArchiveArgs('archive', {}, ['channel-archive', 'C04633RSEDU', 'max-members=2']);
is(r.ok, false);
is(r.errors[0].code, 'unexpected-argument');
});
test('checkChannelArchiveArgs: unarchive refuses archive-only flags and typos', () => {
is(checkChannelArchiveArgs('unarchive', { 'max-members': '2' }, P).errors[0].code, 'archive-only-flag');
is(checkChannelArchiveArgs('unarchive', { 'allow-shared': true }, P).errors[0].code, 'archive-only-flag');
is(checkChannelArchiveArgs('unarchive', { confrm: true }, P).errors[0].message, 'unknown-flag: --confrm (did you mean --confirm?)');
is(checkChannelArchiveArgs('unarchive', { confirm: true, json: true }, P).ok, true);
});
// ── Codex round 3 (P1): missing sharing flags are UNKNOWN, never "not shared" ──
test('classifyChannelHost: missing or non-boolean is_ext_shared / is_pending_ext_shared is sharing-unknown', () => {
for (const over of [
{ is_ext_shared: undefined },
{ is_pending_ext_shared: undefined },
{ is_ext_shared: undefined, is_pending_ext_shared: undefined },
{ is_ext_shared: 'true' },
{ is_ext_shared: 1 },
{ is_pending_ext_shared: null },
]) {
is(classifyChannelHost(chan(over), ORG), 'sharing-unknown', JSON.stringify(over));
}
is(classifyChannelHost(chan(), ORG), 'not-shared', 'control: both real booleans, false');
});
test('evaluateChannelGuards: sharing-unknown refuses archive even with --allow-shared', () => {
const st = normalizeChannelState(chan({ is_ext_shared: undefined }), ORG, NOW_MS);
const d = evaluateChannelGuards('archive', st, { orgId: ORG, allowShared: true });
is(d.outcome, 'refuse');
is(d.reason, 'sharing-unknown');
is(sharedArchiveImpact(st), null, 'no made-up disconnect count for an unknown sharing state');
});
test('evaluateChannelGuards: sharing-unknown refuses unarchive too', () => {
const st = normalizeChannelState(chan({ is_archived: true, is_pending_ext_shared: undefined }), ORG, NOW_MS);
is(evaluateChannelGuards('unarchive', st, { orgId: ORG }).reason, 'sharing-unknown');
});
test('evaluateChannelGuards: an archived channel with missing sharing flags is still already-archived (no write)', () => {
const st = normalizeChannelState(chan({ is_archived: true, is_ext_shared: undefined }), ORG, NOW_MS);
is(evaluateChannelGuards('archive', st, { orgId: ORG }).reason, 'already-archived');
});
test('flow --confirm --allow-shared with missing sharing flags: refused sharing-unknown, no archive call', async () => {
const h = stub({ states: [chan({ is_ext_shared: undefined, is_pending_ext_shared: undefined })] });
const r = await h.run({ confirm: true, allowShared: true });
is(r.status, 'refused');
is(r.decision.reason, 'sharing-unknown');
is(r.exitCode, 1);
is(h.seq(), S);
is(writes(h), 0);
});
// MUTATION M9 (absent sharing flag read as false): in classifyChannelHost,
// drop the typeof boolean check. Caught by: "classifyChannelHost: missing or
// non-boolean ...", "evaluateChannelGuards: sharing-unknown refuses ...",
// "flow --confirm --allow-shared with missing sharing flags ...".
// ── Codex round 4 (P2): conversations.info ok:true without a usable channel ────
const EMPTY_SEARCH = { ok: true, conversations: [], next_cursor: '' };
test('lookupChannel: conversations.info ok:true without a usable channel is malformed_response, not not-found', async () => {
for (const info of [
{ ok: true },
{ ok: true, channel: null },
{ ok: true, channel: { id: 'C04633RSEDU' } },
{ ok: true, channel: { id: 'C04633RSEDU', name: '' } },
{ ok: true, channel: { id: 'C04633RSEDU', name: 42 } },
{ ok: true, channel: { id: 'C0SOMEOTHER', name: 'other-channel' } },
]) {
const h = rawCall((m) => (m === S ? EMPTY_SEARCH : info));
const r = await lookupChannel(h.call, 'C04633RSEDU');
is(r.found, false);
is(r.error, 'conversations.info: malformed_response', JSON.stringify(info));
is(h.calls.filter((c) => c.method === S).length, 1, 'no name search on a malformed info answer');
}
});
test('lookupChannel: conversations.info ok:true with this channel and a name still drives the name search (control)', async () => {
let n = 0;
const h = rawCall((m) => {
if (m === S) return (n += 1) === 1 ? EMPTY_SEARCH : { ok: true, conversations: [chan()], next_cursor: '' };
return { ok: true, channel: { id: 'C04633RSEDU', name: 'assets-adidas' } };
});
const r = await lookupChannel(h.call, 'C04633RSEDU');
is(r.found, true);
is(r.via, 'name');
});
test('flow --confirm: malformed conversations.info fallback is read-error (exit 1), no decision, no write', async () => {
const h = rawCall((m) => (m === S ? EMPTY_SEARCH : m === 'conversations.info' ? { ok: true } : { ok: true }));
const r = await runChannelArchiveFlow({
action: 'archive', channelId: 'C04633RSEDU', orgId: ORG, confirm: true,
maxMembers: null, minIdleDays: null, call: h.call, sleep: async () => {}, now: () => NOW_MS,
});
is(r.status, 'read-error');
is(r.decision, null);
is(h.calls.filter((c) => c.method === A).length, 0);
});
test('flow --confirm: a write answering a non-boolean ok is an error, not a success', async () => {
for (const [wr, err] of [[{ ok: 'false' }, 'malformed_response'], [{ ok: 1 }, 'malformed_response'], [null, 'no_response'], [{ ok: false }, 'no_response']]) {
const h = stub({ writeResult: wr === null ? undefined : wr });
if (wr === null) {
// stub() falls back to {ok:true} for undefined; use a raw call for a missing body.
const raw = rawCall((m) => (m === S ? { ok: true, conversations: [chan()], next_cursor: '' } : m === A ? null : { ok: false, error: 'channel_not_found' }));
const r = await runChannelArchiveFlow({ action: 'archive', channelId: 'C04633RSEDU', orgId: ORG, confirm: true, maxMembers: null, minIdleDays: null, call: raw.call, sleep: async () => {}, now: () => NOW_MS });
is(r.status, 'error');
is(r.write.error, err);
continue;
}
const r = await h.run({ confirm: true });
is(r.status, 'error', JSON.stringify(wr));
is(r.write.ok, false);
is(r.write.error, err, JSON.stringify(wr));
is(r.readback, null, 'no read-back after a failed write');
}
});
// MUTATION M10 (malformed info read as a miss): in lookupChannel, drop the
// ok:true channel check (accept any ok:true, name = ch && ch.name).
// Caught by: "lookupChannel: conversations.info ok:true without a usable
// channel ...", "flow --confirm: malformed conversations.info fallback ...".
// ── Codex round 5 (P2): missing connected-team lists are unknown, never 0 ──────
test('sharedArchiveImpact: missing connected_team_ids is "could not be determined", never 0 organisations', () => {
for (const ids of [undefined, null, 'T0BQQL6FJ', { a: 1 }]) {
const st = normalizeChannelState(connectChan({ connected_team_ids: ids }), ORG, NOW_MS);
const imp = sharedArchiveImpact(st);
is(imp.external_team_ids, null, JSON.stringify(ids));
ok(imp.text.includes('an unknown number of external organisations'), imp.text);
ok(imp.text.includes('could not be determined'), imp.text);
ok(!/\b0 external organisations\b/.test(imp.text), 'must not claim 0: ' + imp.text);
ok(imp.text.includes('Unarchiving will NOT reconnect them'), imp.text);
}
});
test('sharedArchiveImpact: missing pending_connected_team_ids is unknown, never "no pending invitations"', () => {
const st = normalizeChannelState(connectChan({ pending_connected_team_ids: undefined }), ORG, NOW_MS);
const imp = sharedArchiveImpact(st);
is(imp.pending_external_team_ids, null);
ok(imp.text.includes('pending_connected_team_ids, so their number could not be determined'), imp.text);
is(imp.external_team_ids.join(','), 'T0BQQL6FJ,E08CP5WPXGT', 'a present connected list is still counted');
});
test('ext-shared-requires-allow-shared refusal with a missing connected list says unknown, not 0', () => {
const st = normalizeChannelState(connectChan({ connected_team_ids: undefined }), ORG, NOW_MS);
const d = evaluateChannelGuards('archive', st, { orgId: ORG });
is(d.reason, 'ext-shared-requires-allow-shared');
ok(d.detail.includes('an unknown number of external organisations'), d.detail);
});
test('sharedArchiveImpact: real empty lists still read as 0 (control)', () => {
const st = normalizeChannelState(connectChan({ connected_team_ids: [ORG], pending_connected_team_ids: [] }), ORG, NOW_MS);
const imp = sharedArchiveImpact(st);
is(imp.external_team_ids.length, 0);
ok(imp.text.includes('from 0 external organisations.'), imp.text);
});
// MUTATION M11 (unknown list read as empty): in externalTeamIds, return []
// instead of null for a non-array. Caught by: "externalTeamIds: drops this
// org ...", "sharedArchiveImpact: missing connected_team_ids ...",
// "... missing pending_connected_team_ids ...", "ext-shared-requires-allow-shared
// refusal with a missing connected list ...".
// ── Codex round 6 (P2): only ok === true is a successful read ──────────────────
//
// A truthy non-boolean ok (e.g. "false") must never let a matching row through:
// that row could authorize a confirmed archive. Writes are already strict.
test('lookupChannel: search ok that is not the boolean true is malformed_response, even with a matching row', async () => {
for (const ok of ['false', 'true', 1, undefined, null]) {
const h = rawCall((m) => (m === S ? { ok, conversations: [chan()], next_cursor: '' } : { ok: false, error: 'channel_not_found' }));
const r = await lookupChannel(h.call, 'C04633RSEDU');
is(r.found, false, 'ok=' + JSON.stringify(ok));
is(r.channel, null);
is(r.error, 'malformed_response', 'ok=' + JSON.stringify(ok));
}
});
test('lookupChannel: search ok:false still reports the Slack error (control)', async () => {
const r = await lookupChannel(rawCall(() => ({ ok: false, error: 'ratelimited' })).call, 'C04633RSEDU');
is(r.error, 'ratelimited');
});
test('lookupChannel: conversations.info with a non-boolean ok is malformed, never the expected miss', async () => {
const miss = { ok: true, conversations: [], next_cursor: '' };
for (const ok of ['false', 0, undefined]) {
const r = await lookupChannel(rawCall((m) => (m === S ? miss : { ok, error: 'channel_not_found' })).call, 'C04633RSEDU');
is(r.error, 'conversations.info: malformed_response', 'ok=' + JSON.stringify(ok));
}
// control: the real boolean false + channel_not_found is the expected private-channel miss
const r = await lookupChannel(rawCall((m) => (m === S ? miss : { ok: false, error: 'channel_not_found' })).call, 'C04633RSEDU');
is(r.found, false);
is(r.error, undefined);
});
test('flow --confirm: search {ok:"false"} with a matching row is read-error, no decision, no archive call', async () => {
const h = rawCall((m) => (m === S ? { ok: 'false', conversations: [chan()], next_cursor: '' } : { ok: true }));
const r = await runChannelArchiveFlow({
action: 'archive', channelId: 'C04633RSEDU', orgId: ORG, confirm: true,
maxMembers: null, minIdleDays: null, call: h.call, sleep: async () => {}, now: () => NOW_MS,
});
is(r.status, 'read-error');
is(r.decision, null);
is(r.exitCode, 1);
is(h.calls.filter((c) => c.method === A).length, 0);
});
// MUTATION M12 (truthy ok trusted): in lookupChannel, drop the
// `if (r.ok !== true) return { error: 'malformed_response' };` line.
// Caught by: "lookupChannel: search ok that is not the boolean true ...",
// "flow --confirm: search {ok:\"false\"} with a matching row ...", and the
// entry-point test "channel-archive --confirm (entry point): search {ok:"false"} ...".
// ── Codex round 7 (P1): contradictory Slack Connect metadata is sharing-unknown ─
test('classifyChannelHost: both flags false but the row shows sharing evidence is sharing-unknown', () => {
const cases = [
[{ external_user_count: 3 }, 'external_user_count 3'],
[{ connected_team_ids: ['T0BQQL6FJ', ORG] }, 'external connected_team_ids T0BQQL6FJ'],
[{ pending_connected_team_ids: ['T0PENDING1'] }, 'external pending_connected_team_ids T0PENDING1'],
[{ conversation_host_id: 'E01UA4N2G78' }, 'conversation_host_id E01UA4N2G78'],
[{ conversation_host_id: ORG }, 'conversation_host_id ' + ORG],
];
for (const [over, reason] of cases) {
const c = chan(Object.assign({ is_ext_shared: false, is_pending_ext_shared: false }, over));
is(classifyChannelHost(c, ORG), 'sharing-unknown', JSON.stringify(over));
const st = normalizeChannelState(c, ORG, NOW_MS);
ok(st.sharing_conflicts.includes(reason), JSON.stringify(st.sharing_conflicts));
}
});
test('classifyChannelHost: consistent not-shared rows stay not-shared (controls)', () => {
for (const over of [
{},
{ connected_team_ids: null, external_user_count: 0 },
{ connected_team_ids: [ORG, 'T0385CHDU9E'], internal_team_ids: ['T0385CHDU9E'] },
{ external_user_count: -1 },
{ pending_connected_team_ids: [] },
]) {
is(classifyChannelHost(chan(over), ORG), 'not-shared', JSON.stringify(over));
}
});
test('evaluateChannelGuards: contradictory metadata refuses archive even with --allow-shared, naming the evidence', () => {
const st = normalizeChannelState(chan({ external_user_count: 4, conversation_host_id: ORG }), ORG, NOW_MS);
const d = evaluateChannelGuards('archive', st, { orgId: ORG, allowShared: true });
is(d.outcome, 'refuse');
is(d.reason, 'sharing-unknown');
ok(d.detail.includes('is_ext_shared and is_pending_ext_shared are false, but the same row reports external_user_count 4'), d.detail);
ok(d.detail.includes('conversation_host_id ' + ORG), d.detail);
});
test('flow --confirm --allow-shared with contradictory metadata: refused sharing-unknown, no archive call', async () => {
const h = stub({ states: [chan({ connected_team_ids: ['T04650MFY', ORG], external_user_count: 2 })] });
const r = await h.run({ confirm: true, allowShared: true });
is(r.status, 'refused');
is(r.decision.reason, 'sharing-unknown');
is(writes(h), 0);
});
test('an archived channel with contradictory metadata is still already-archived (no write)', () => {
const st = normalizeChannelState(chan({ is_archived: true, member_count: -1, conversation_host_id: ORG }), ORG, NOW_MS);
is(evaluateChannelGuards('archive', st, { orgId: ORG }).reason, 'already-archived');
});
// MUTATION M13 (contradictions ignored): in classifyChannelHost, return
// 'not-shared' whenever both flags are false. Caught by: "classifyChannelHost:
// both flags false but the row shows sharing evidence ...", "evaluateChannelGuards:
// contradictory metadata refuses ...", "flow --confirm --allow-shared with
// contradictory metadata ...", and the entry-point contradiction test.