CtrlK
BlogDocsLog inGet started
Tessl Logo
PluginPublic

ai-ecoverse/advanced-skills

Quarantined high-risk skills (browser session capture, WebSocket interception) for Slack and Microsoft Teams. Install only after reviewing the security implications.

Quality

94%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide
SecuritybySnyk

Low

Low-risk findings worth noting

Overview
Quality
Evals
Security
Files

slack-ext-connect.test.jsskills/slack/tests/

// Tests for the Slack Connect commands in skills/slack/scripts/slack-ext.jsh:
//   guest-invite, connect-revoke, approvals --detail / approvals show,
//   connect-status, connect-invite, connect-permissions.
//
// Run with:
//   tst <path-to-this-file>
//
// Same strategy as slack-ext.test.js: compile the REAL .jsh unmodified (so
// main() dispatches exactly as a user's command line would), inject mock
// sliccy:* modules and a stub browser whose fetch plays the Slack API. Every id,
// name and address here is a fake.

import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import test, { is, ok } from 'tst';
import * as _argvMod from '../scripts/argv.js';
import * as _manifestDiffMod from '../scripts/manifest-diff.js';
import * as _gridMod from '../scripts/slack-ext-grid.js';

const SCRIPT = fileURLToPath(new URL('../scripts/slack-ext.jsh', import.meta.url));
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
const grid = _gridMod.default || _gridMod;

const ORG = 'E0EXAMPLE01';
const WS = 'T0EXAMPLE01';
const CHAN = 'C0EXAMPLE01';
const INVITE = 'I0EXAMPLE01';
const EMAIL = 'guest@example.com';

class NodeExitError extends Error {
  constructor(message, code) {
    super(message);
    this.name = 'NodeExitError';
    this.exitCode = code !== undefined ? code : 1;
  }
}

// A sharedApprovals.list row as measured, with fakes, INCLUDING icon/avatar
// fields that the detail view must strip.
function approvalRow(over) {
  return Object.assign(
    {
      id: INVITE,
      status: 'pending',
      connection_status: 'pending',
      channel: { id: CHAN, name: 'example-shared' },
      home_user: {
        id: 'U0EXAMPLE01',
        real_name: 'Example Inviter',
        profile: { image_48: 'https://example.invalid/avatar-home.png' },
      },
      away_user: {
        id: 'U0EXAMPLE02',
        real_name: 'Example Guest',
        team_id: 'T0EXAMPLE02',
        profile: { email: EMAIL, image_72: 'https://example.invalid/avatar-away.png' },
      },
      connecting_team: {
        id: 'T0EXAMPLE02',
        name: 'Example Partner Org',
        domain: 'example-partner',
        requires_sponsorship: true,
        icon: { image_68: 'https://example.invalid/icon-team.png' },
      },
      home_date_approve: 1790000000,
      away_date_approve: 0,
      approving_user_id: 'U0EXAMPLE01',
      invite_date_created: 1789900000,
      date_expire: 1791000000,
    },
    over || {}
  );
}

/**
 * @param {object} opts
 * @param {string[]} opts.argv
 * @param {function} [opts.api] (method, params, state) => body | undefined
 * @param {object[]} [opts.rows] initial sharedApprovals rows
 * @param {object} [opts.channels] conversations.info channel objects by id
 * @param {object} [opts.teams] team.info team objects by id
 * @param {string} [opts.tabUrl] override the Slack tab URL
 *
 * setTimeout inside the script is always fake: it fires at once and records
 * the delay in h.sleeps, so retry and read-back waits cost nothing.
 */
async function load(opts) {
  const calls = [];
  const sleeps = [];
  const stdout = [];
  const stderr = [];
  const state = {
    rows: (opts.rows || [approvalRow()]).map((r) => JSON.parse(JSON.stringify(r))),
    channels: JSON.parse(JSON.stringify(opts.channels || {})),
    teams: JSON.parse(JSON.stringify(opts.teams || {})),
  };
  const fakeTab = { id: 'tab1', url: opts.tabUrl || 'https://app.slack.com/client/' + WS + '/' + CHAN };

  const browserStub = {
    async findTab() {
      return fakeTab;
    },
    async localStorage(_tab, key) {
      if (key !== 'localConfig_v2') return null;
      return JSON.stringify({ teams: { [WS]: { token: 'xoxc-test-ws' }, [ORG]: { token: 'xoxc-test-org' } } });
    },
    async fetch(_tab, url, fetchOpts) {
      const method = url.replace('/api/', '');
      const params = {};
      let token = null;
      if (fetchOpts && typeof fetchOpts.body === 'string') {
        for (const [k, v] of new URLSearchParams(fetchOpts.body)) {
          if (k === 'token') token = v;
          else params[k] = v;
        }
      }
      calls.push({ method, params, token });
      if (opts.api) {
        const custom = opts.api(method, params, state);
        if (custom !== undefined) return { body: custom };
      }
      if (method === 'conversations.sharedApprovals.list') {
        return { body: { ok: true, approvals: JSON.parse(JSON.stringify(state.rows)), response_metadata: { next_cursor: '' } } };
      }
      if (method === 'conversations.revokeSharedInvite') {
        const row = state.rows.find((r) => r.id === params.invite_id);
        if (row) {
          row.status = 'expired';
          row.date_expire = 1790500000;
        }
        return { body: { ok: true } };
      }
      if (method === 'conversations.info') {
        const c = state.channels[params.channel];
        return {
          body: c
            ? { ok: true, channel: JSON.parse(JSON.stringify(c)) }
            : { ok: false, error: 'channel_not_found' },
        };
      }
      if (method === 'team.info') {
        const t = state.teams[params.team];
        return { body: t ? { ok: true, team: t } : { ok: false, error: 'team_not_found' } };
      }
      if (method === 'conversations.inviteShared') return { body: { ok: true } };
      if (method === 'conversations.externalInvitePermissions.set') {
        const c = state.channels[params.channel];
        const lim = c.connected_limited_team_ids.filter((t) => t !== params.target_team);
        if (params.action === 'downgrade') lim.push(params.target_team);
        c.connected_limited_team_ids = lim;
        return { body: { ok: true } };
      }
      if (method === 'users.admin.inviteBulk') {
        const invites = JSON.parse(params.invites);
        return {
          body: {
            ok: true,
            invites: invites.map((i) => ({ email: i.email, ok: true, invite_id: 'I0EXAMPLE02', expiration_ts: 1791000000 })),
          },
        };
      }
      return { body: { ok: false, error: 'not_mocked' } };
    },
  };

  const cliStub = {
    die(message, options) {
      const code = options && options.exitCode !== undefined ? options.exitCode : 1;
      stderr.push(String(message));
      throw new NodeExitError(String(message), code);
    },
    help(message) {
      stdout.push(String(message));
      throw new NodeExitError('help', 0);
    },
    out(value) {
      stdout.push(typeof value === 'string' ? value : JSON.stringify(value, null, 2));
    },
    warn(message) {
      stderr.push(String(message));
    },
  };

  const mocks = {
    'sliccy:browser': browserStub,
    'sliccy:cli': cliStub,
    'sliccy:color': new Proxy({}, { get: () => (s) => String(s) }),
    'sliccy:exec': { exec: async () => ({ exitCode: 0, stdout: '', stderr: '' }) },
    'sliccy:http': {
      client: () => ({
        post: async () => {
          throw new Error('these commands must not use the App Manifest HTTP client');
        },
      }),
    },
    'sliccy:skill': { config: async () => null },
    fs: {
      readFile: async () => {
        throw new Error('ENOENT');
      },
      writeFile: async () => {
        throw new Error('EACCES');
      },
    },
  };
  const relativeModules = {
    './argv.js': () => _argvMod.default || _argvMod,
    './manifest-diff.js': () => _manifestDiffMod.default || _manifestDiffMod,
    './slack-ext-grid.js': () => grid,
  };
  const mockRequire = (id) => {
    if (Object.hasOwn(mocks, id)) return mocks[id];
    const key = id.replace(/^\.\.\/(scripts\/)?/, './');
    if (Object.hasOwn(relativeModules, key)) return relativeModules[key]();
    throw new Error('unexpected require(' + id + ')');
  };
  const mockProcess = {
    argv: ['node', SCRIPT, ...opts.argv],
    env: {},
    exit: (code) => {
      throw new NodeExitError('exit', code);
    },
  };
  const mockConsole = {
    log: (m) => stdout.push(String(m === undefined ? '' : m)),
    error: (m) => stderr.push(String(m === undefined ? '' : m)),
    warn: (m) => stderr.push(String(m === undefined ? '' : m)),
  };

  const source = readFileSync(SCRIPT, 'utf8');
  const factory = new AsyncFunction('require', 'process', 'console', 'setTimeout', source);
  const fakeSetTimeout = (fn, ms) => {
    sleeps.push(ms);
    Promise.resolve().then(fn);
    return 0;
  };
  let runError = null;
  try {
    await factory(mockRequire, mockProcess, mockConsole, fakeSetTimeout);
  } catch (e) {
    runError = e;
  }
  return {
    runError,
    calls,
    sleeps,
    state,
    text: () => stdout.join('\n'),
    errText: () => stderr.join('\n'),
    exit: () => (runError ? (runError.name === 'NodeExitError' ? runError.exitCode : 'THROW:' + runError.message) : 0),
    methods: () => calls.map((c) => c.method).join(','),
    count: (m) => calls.filter((c) => c.method === m).length,
  };
}

const MUTATING = [
  'users.admin.inviteBulk',
  'conversations.revokeSharedInvite',
  'conversations.inviteShared',
  'conversations.externalInvitePermissions.set',
];
const mutations = (h) => h.calls.filter((c) => MUTATING.includes(c.method)).length;

// ── guest-invite ──────────────────────────────────────────────────────────────

test('guest-invite dry run: makes no Slack call at all, shows the payload target', async () => {
  const h = await load({ argv: ['--ws=' + WS, '--org=' + ORG, 'guest-invite', EMAIL, '--channel=' + CHAN] });
  is(h.exit(), 0);
  is(h.calls.length, 0);
  is(mutations(h), 0);
  const t = h.text();
  ok(/Dry run — would invite a single-channel guest/.test(t), t);
  ok(t.includes(EMAIL) && t.includes(WS) && t.includes(CHAN), t);
  ok(/No --confirm; nothing changed/.test(t), t);
});

test('guest-invite dry run --json: one JSON document with the exact params, no call', async () => {
  const h = await load({ argv: ['--ws=' + WS, '--org=' + ORG, 'guest-invite', EMAIL, '--channel=' + CHAN, '--json'] });
  is(h.exit(), 0);
  is(h.calls.length, 0);
  const doc = JSON.parse(h.text());
  is(doc.dry_run, true);
  is(doc.params.team_id, WS);
});

test('guest-invite --confirm: exactly one inviteBulk with exactly the measured fields, ORG token', async () => {
  const h = await load({ argv: ['--ws=' + WS, '--org=' + ORG, 'guest-invite', EMAIL, '--channel=' + CHAN, '--confirm'] });
  is(h.exit(), 0, h.errText());
  is(h.methods(), 'users.admin.inviteBulk');
  const c = h.calls[0];
  is(c.params, {
    team_id: WS,
    invites: JSON.stringify([{ email: EMAIL, type: 'ultra_restricted', mode: 'manual' }]),
    channels: CHAN,
    ultra_restricted: 'true',
    source: 'invite_modal',
    mode: 'manual',
  });
  is(c.token, 'xoxc-test-org');
  const t = h.text();
  ok(/Result:\s+invited/.test(t), t);
  ok(t.includes('I0EXAMPLE02'), t);
  ok(/Expires:\s+\d{4}-\d\d-\d\d \d\d:\d\dZ/.test(t), t);
});

test('guest-invite --confirm: top-level ok:true with a failed per-invite result exits non-zero', async () => {
  const h = await load({
    argv: ['--ws=' + WS, '--org=' + ORG, 'guest-invite', EMAIL, '--channel=' + CHAN, '--confirm'],
    api: (m) => (m === 'users.admin.inviteBulk' ? { ok: true, invites: [{ email: EMAIL, ok: false, error: 'already_invited' }] } : undefined),
  });
  is(h.exit(), 1);
  ok(/FAILED: already_invited/.test(h.text()), h.text());
  ok(/1 of 1 invite\(s\) failed/.test(h.errText()), h.errText());
});

test('guest-invite --confirm: ok:true with no per-invite entry is a failure, not success', async () => {
  const h = await load({
    argv: ['--ws=' + WS, '--org=' + ORG, 'guest-invite', EMAIL, '--channel=' + CHAN, '--confirm'],
    api: (m) => (m === 'users.admin.inviteBulk' ? { ok: true, invites: [] } : undefined),
  });
  is(h.exit(), 1);
  ok(/missing_from_response/.test(h.text()), h.text());
});

test('guest-invite --confirm --json: failure still exits non-zero', async () => {
  const h = await load({
    argv: ['--ws=' + WS, '--org=' + ORG, 'guest-invite', EMAIL, '--channel=' + CHAN, '--confirm', '--json'],
    api: (m) => (m === 'users.admin.inviteBulk' ? { ok: true, invites: [{ email: EMAIL, ok: false, error: 'invalid_email' }] } : undefined),
  });
  is(h.exit(), 1);
  const doc = JSON.parse(h.text());
  is(doc.summary.failed, 1);
});

test('guest-invite without --ws is refused before any call', async () => {
  const h = await load({ argv: ['--org=' + ORG, 'guest-invite', EMAIL, '--channel=' + CHAN, '--confirm'] });
  is(h.exit(), 1);
  is(h.calls.length, 0);
  ok(/--ws=<TEAM_ID> is required/.test(h.errText()), h.errText());
});

test('guest-invite with the org id as --ws is refused (team_id must be the workspace)', async () => {
  const h = await load({ argv: ['--ws=' + ORG, '--org=' + ORG, 'guest-invite', EMAIL, '--channel=' + CHAN, '--confirm'] });
  is(h.exit(), 1);
  is(h.calls.length, 0);
  ok(/WORKSPACE ID/.test(h.errText()), h.errText());
});

test('guest-invite refuses a channel list and a bad email', async () => {
  const a = await load({ argv: ['--ws=' + WS, '--org=' + ORG, 'guest-invite', EMAIL, '--channel=C0EXAMPLE01,C0EXAMPLE02', '--confirm'] });
  is(a.exit(), 1);
  is(a.calls.length, 0);
  const b = await load({ argv: ['--ws=' + WS, '--org=' + ORG, 'guest-invite', 'not-an-email', '--channel=' + CHAN, '--confirm'] });
  is(b.exit(), 1);
  is(b.calls.length, 0);
});

// ── connect-revoke ────────────────────────────────────────────────────────────

test('connect-revoke dry run: reads sharedApprovals.list only and shows the invite', async () => {
  const h = await load({ argv: ['--org=' + ORG, 'connect-revoke', INVITE, '--channel=' + CHAN] });
  is(h.exit(), 0, h.errText());
  is(h.methods(), 'conversations.sharedApprovals.list');
  is(mutations(h), 0);
  const t = h.text();
  ok(/Dry run — would revoke Slack Connect invite/.test(t), t);
  ok(t.includes('Example Partner Org') && t.includes('Example Guest'), t);
  ok(/No --confirm; nothing changed/.test(t), t);
});

test('connect-revoke --confirm: revokes, RE-READS the row and reports the new status', async () => {
  const h = await load({ argv: ['--org=' + ORG, 'connect-revoke', INVITE, '--channel=' + CHAN, '--confirm'] });
  is(h.exit(), 0, h.errText());
  is(h.methods(), 'conversations.sharedApprovals.list,conversations.revokeSharedInvite,conversations.sharedApprovals.list');
  is(h.calls[1].params, { invite_id: INVITE, channel: CHAN });
  is(h.calls[1].token, 'xoxc-test-org');
  const t = h.text();
  ok(/Status before:\s+pending/.test(t), t);
  ok(/Status now:\s+expired/.test(t), t);
  ok(/Match:\s+yes/.test(t), t);
});

test('connect-revoke --confirm: ok:true but the re-read still shows pending -> exit 3', async () => {
  const h = await load({
    argv: ['--org=' + ORG, 'connect-revoke', INVITE, '--channel=' + CHAN, '--confirm'],
    api: (m) => (m === 'conversations.revokeSharedInvite' ? { ok: true } : undefined),
  });
  is(h.exit(), 3);
  ok(/Revoke sent, NOT confirmed/.test(h.text()), h.text());
  ok(/re-read shows status pending/.test(h.errText()), h.errText());
});

test('connect-revoke --confirm: team_is_restricted -> clear error and org-token hint, no re-read', async () => {
  const h = await load({
    argv: ['--org=' + ORG, 'connect-revoke', INVITE, '--channel=' + CHAN, '--confirm'],
    api: (m) => (m === 'conversations.revokeSharedInvite' ? { ok: false, error: 'team_is_restricted' } : undefined),
  });
  is(h.exit(), 1);
  const e = h.errText();
  ok(/team_is_restricted\. Nothing was revoked/.test(e), e);
  ok(/ORG-level token/.test(e) && /--org=<E id>/.test(e), e);
  is(h.methods(), 'conversations.sharedApprovals.list,conversations.revokeSharedInvite');
});

test('connect-revoke: unknown invite is refused without a revoke call', async () => {
  const h = await load({ argv: ['--org=' + ORG, 'connect-revoke', 'I0EXAMPLE09', '--channel=' + CHAN, '--confirm'] });
  is(h.exit(), 1);
  is(mutations(h), 0);
  ok(/not found in conversations\.sharedApprovals\.list/.test(h.errText()), h.errText());
});

test('connect-revoke: --channel that does not match the row is refused', async () => {
  const h = await load({ argv: ['--org=' + ORG, 'connect-revoke', INVITE, '--channel=C0EXAMPLE02', '--confirm'] });
  is(h.exit(), 1);
  is(mutations(h), 0);
  ok(/is for channel C0EXAMPLE01, not C0EXAMPLE02/.test(h.errText()), h.errText());
});

test('connect-revoke: already expired is nothing to do, no revoke call', async () => {
  const h = await load({ rows: [approvalRow({ status: 'expired' })], argv: ['--org=' + ORG, 'connect-revoke', INVITE, '--channel=' + CHAN, '--confirm'] });
  is(h.exit(), 0);
  is(mutations(h), 0);
  ok(/already expired; nothing to revoke/.test(h.text()), h.text());
});

test('connect-revoke: finds the row on a later page', async () => {
  let page = 0;
  const h = await load({
    argv: ['--org=' + ORG, 'connect-revoke', INVITE, '--channel=' + CHAN],
    api: (m) => {
      if (m !== 'conversations.sharedApprovals.list') return undefined;
      page += 1;
      if (page === 1) return { ok: true, approvals: [approvalRow({ id: 'I0EXAMPLE05' })], response_metadata: { next_cursor: 'c2' } };
      return { ok: true, approvals: [approvalRow()], response_metadata: { next_cursor: '' } };
    },
  });
  is(h.exit(), 0, h.errText());
  is(h.count('conversations.sharedApprovals.list'), 2);
  is(h.calls[1].params.cursor, 'c2');
});

// ── approvals detail ──────────────────────────────────────────────────────────

test('approvals show: both approval sides, inviter, invitee + org; icons stripped', async () => {
  const h = await load({ argv: ['--org=' + ORG, 'approvals', 'show', INVITE] });
  is(h.exit(), 0, h.errText());
  is(mutations(h), 0);
  const t = h.text();
  ok(/Inviter:\s+Example Inviter \(U0EXAMPLE01\)/.test(t), t);
  ok(/Invitee:\s+Example Guest \(U0EXAMPLE02\) <guest@example\.com>/.test(t), t);
  ok(/Invitee org:\s+Example Partner Org \(T0EXAMPLE02\)/.test(t), t);
  ok(/Our side:\s+approved \d{4}-/.test(t), t);
  ok(/Other org:\s+NOT approved/.test(t), t);
  ok(/Waiting on:\s+other org/.test(t), t);
  ok(/Sponsorship:\s+required/.test(t), t);
  ok(!/example\.invalid|image_|icon/.test(t), t);
});

test('approvals --detail --json: stdout is one JSON document, both sides present, no icon fields', async () => {
  const h = await load({ argv: ['--org=' + ORG, 'approvals', '--detail', '--json'] });
  is(h.exit(), 0, h.errText());
  const raw = h.text();
  const doc = JSON.parse(raw);
  is(doc.total, 1);
  const d = doc.approvals[0];
  is(d.home_approved, 1790000000);
  is(d.away_approved, 0);
  is(d.waiting_on, 'other org');
  is(d.invitee, { id: 'U0EXAMPLE02', real_name: 'Example Guest', email: EMAIL, team_id: 'T0EXAMPLE02' });
  is(d.connecting_team, { id: 'T0EXAMPLE02', name: 'Example Partner Org', domain: 'example-partner', requires_sponsorship: true });
  ok(!/example\.invalid|image_|"icon"/.test(raw), raw);
});

test('approvals --detail (human): prints the detail block per row', async () => {
  const h = await load({ argv: ['--org=' + ORG, 'approvals', '--detail'] });
  is(h.exit(), 0, h.errText());
  ok(/Other org:\s+NOT approved/.test(h.text()), h.text());
});

test('approvals with an unknown subcommand is refused', async () => {
  const h = await load({ argv: ['--org=' + ORG, 'approvals', 'bogus'] });
  is(h.exit(), 1);
  is(h.calls.length, 0);
});

// ── pure helpers ──────────────────────────────────────────────────────────────

test('summarizeApprovalDetail: waiting_on covers all four states', () => {
  const w = (h, a) => grid.summarizeApprovalDetail(approvalRow({ home_date_approve: h, away_date_approve: a })).waiting_on;
  is(w(1, 0), 'other org');
  is(w(0, 1), 'our side');
  is(w(0, 0), 'both sides');
  is(w(1, 1), 'nobody');
});

test('summarizeInviteResults: case-insensitive email match, per-invite ok only', () => {
  const s = grid.summarizeInviteResults({ ok: true, invites: [{ email: 'Guest@Example.com', ok: true, invite_id: 'I0EXAMPLE03' }] }, [EMAIL]);
  is(s.ok, true);
  is(s.failed, 0);
  const f = grid.summarizeInviteResults({ ok: true, invites: [{ email: EMAIL, ok: 'true' }] }, [EMAIL]);
  is(f.ok, false);
});

// ── connect-status / connect-invite / connect-permissions ─────────────────────

const PARTNER = 'E0EXAMPLE02'; // external org (grid)
const PARTNER_WS = 'T0EXAMPLE09'; // one of PARTNER's workspaces
const OTHER = 'E0EXAMPLE03'; // a second external org

// A conversations.info channel, shaped as measured 2026-10-15 (fakes): hosted
// by us, PARTNER post-only, OTHER Post and Invite.
function sharedChan(over) {
  return Object.assign(
    {
      id: CHAN,
      name: 'example-shared',
      is_archived: false,
      is_private: false,
      is_shared: true,
      is_ext_shared: true,
      is_org_shared: false,
      is_pending_ext_shared: false,
      conversation_host_id: ORG,
      context_team_id: WS,
      internal_team_ids: [WS],
      connected_team_ids: [PARTNER, OTHER, ORG],
      connected_limited_team_ids: [PARTNER],
      pending_connected_team_ids: [],
    },
    over || {}
  );
}

// A never-shared channel, as measured: no connected_* lists at all.
function plainChan(over) {
  return Object.assign(
    {
      id: CHAN,
      name: 'example-internal',
      is_archived: false,
      is_private: false,
      is_shared: false,
      is_ext_shared: false,
      is_org_shared: false,
      is_pending_ext_shared: false,
      context_team_id: WS,
      internal_team_ids: [WS],
      pending_connected_team_ids: [],
    },
    over || {}
  );
}

const TEAMS = {
  [PARTNER]: { id: PARTNER, name: 'Example Partner', domain: 'example-partner' },
  [PARTNER_WS]: { id: PARTNER, name: 'Example Partner', domain: 'example-partner' },
  [OTHER]: { id: OTHER, name: 'Other Partner', domain: 'other-partner' },
  [ORG]: { id: ORG, name: 'Example Home Org', domain: 'example-home' },
};

const INV = ['--ws=' + WS, '--org=' + ORG, 'connect-invite', CHAN];
const PERM = ['--ws=' + WS, '--org=' + ORG, 'connect-permissions', CHAN];

// ── connect-invite ──

test('connect-invite dry run: ONE conversations.info read, zero writes, shows emails + state + permission', async () => {
  const h = await load({
    argv: INV.concat(['--emails=B@Example.org,a@example.com,b@example.org']),
    channels: { [CHAN]: plainChan() },
  });
  is(h.exit(), 0, h.errText());
  is(h.methods(), 'conversations.info');
  is(h.calls[0].params, { channel: CHAN });
  is(h.calls[0].token, 'xoxc-test-ws');
  is(mutations(h), 0);
  const t = h.text();
  ok(/Dry run: connect-invite/.test(t), t);
  ok(/Emails:\s+b@example\.org, a@example\.com\n/.test(t), t);
  ok(/Permission:\s+Post and Invite \(default\)/.test(t), t);
  ok(/external_limited=false/.test(t), t);
  ok(/ext_shared:\s+no/.test(t) && /pending_ext:\s+no/.test(t), t);
  ok(/No --confirm; nothing changed/.test(t), t);
});

test('connect-invite --confirm: ONE call per address, each with external_limited:"false" EXPLICITLY, then re-reads', async () => {
  const h = await load({
    argv: INV.concat(['--emails=a@example.com,b@example.org', '--confirm']),
    channels: { [CHAN]: plainChan() },
  });
  is(h.exit(), 0, h.errText());
  is(h.methods(), 'conversations.info,conversations.inviteShared,conversations.inviteShared,conversations.info');
  is(h.calls[1].params, { channel: CHAN, emails: 'a@example.com', external_limited: 'false' });
  is(h.calls[2].params, { channel: CHAN, emails: 'b@example.org', external_limited: 'false' });
  is(h.calls[1].token, 'xoxc-test-ws');
  is(mutations(h), 2);
  ok(/a@example\.com:\s+invited/.test(h.text()), h.text());
  const t = h.text();
  ok(/Invited/.test(t), t);
  ok(/An unchanged channel is EXPECTED/.test(t), t);
  ok(/slack-ext approvals --query=example-internal --detail/.test(t), t);
});

test('connect-invite --post-only --confirm: external_limited:"true"', async () => {
  const h = await load({
    argv: INV.concat(['--emails=a@example.com', '--post-only', '--confirm']),
    channels: { [CHAN]: plainChan() },
  });
  is(h.exit(), 0, h.errText());
  is(h.calls[1].method, 'conversations.inviteShared');
  is(h.calls[1].params, { channel: CHAN, emails: 'a@example.com', external_limited: 'true' });
  ok(/Permission:\s+post-only \(--post-only\)/.test(h.text()), h.text());
});

test('connect-invite --json dry run: one JSON document, params carry external_limited "false"', async () => {
  const h = await load({
    argv: INV.concat(['--emails=a@example.com', '--json']),
    channels: { [CHAN]: sharedChan() },
  });
  is(h.exit(), 0, h.errText());
  const doc = JSON.parse(h.text());
  is(doc.status, 'dry-run');
  is(doc.params, [{ channel: CHAN, emails: 'a@example.com', external_limited: 'false' }]);
  is(doc.external_limited, 'false');
  is(doc.permission, 'Post and Invite');
  is(doc.state.post_only_team_ids, [PARTNER]);
  is(doc.state.post_and_invite_team_ids, [OTHER]);
});

test('connect-invite refuses channel not found (named), even with --confirm, no write', async () => {
  const h = await load({ argv: INV.concat(['--emails=a@example.com', '--confirm']) });
  is(h.exit(), 1);
  is(mutations(h), 0);
  ok(/refused: not-found/.test(h.errText()), h.errText());
});

test('connect-invite refuses an archived channel (named), no write', async () => {
  const h = await load({
    argv: INV.concat(['--emails=a@example.com', '--confirm']),
    channels: { [CHAN]: plainChan({ is_archived: true }) },
  });
  is(h.exit(), 1);
  is(mutations(h), 0);
  ok(/refused: archived/.test(h.errText()), h.errText());
});

test('connect-invite refuses a channel hosted by another org (named), no write', async () => {
  const h = await load({
    argv: INV.concat(['--emails=a@example.com', '--confirm']),
    channels: { [CHAN]: sharedChan({ conversation_host_id: OTHER }) },
  });
  is(h.exit(), 1);
  is(mutations(h), 0);
  ok(/refused: hosted-elsewhere/.test(h.errText()) && h.errText().includes(OTHER), h.errText());
});

test('connect-invite refuses missing sharing flags (sharing-unknown), never read as not shared', async () => {
  const c = plainChan();
  delete c.is_pending_ext_shared;
  const h = await load({ argv: INV.concat(['--emails=a@example.com', '--confirm']), channels: { [CHAN]: c } });
  is(h.exit(), 1);
  is(mutations(h), 0);
  ok(/refused: sharing-unknown/.test(h.errText()), h.errText());
});

test('connect-invite: conversations.info read error is not "not found": exit 1, no write', async () => {
  const h = await load({
    argv: INV.concat(['--emails=a@example.com', '--confirm']),
    api: (m) => (m === 'conversations.info' ? { ok: false, error: 'internal_error' } : undefined),
  });
  is(h.exit(), 1);
  is(mutations(h), 0);
  ok(/UNKNOWN/.test(h.errText()) && /internal_error/.test(h.errText()), h.errText());
});

test('connect-invite email validation: bad address, missing --emails, empty list -> no Slack call', async () => {
  for (const extra of [['--emails=a@example.com,not-an-email'], [], ['--emails=,,'], ['--emails'], ['--emails=a@b']]) {
    const h = await load({ argv: INV.concat(extra, ['--confirm']), channels: { [CHAN]: plainChan() } });
    is(h.exit(), 1, extra.join(' '));
    is(h.calls.length, 0, extra.join(' '));
  }
  const bad = await load({ argv: INV.concat(['--emails=a@example.com,not-an-email']) });
  ok(/invalid email address\(es\): not-an-email/.test(bad.errText()), bad.errText());
});

test('connect-invite: a misspelt --postonly is refused (would otherwise widen to Post and Invite)', async () => {
  const h = await load({
    argv: INV.concat(['--emails=a@example.com', '--postonly', '--confirm']),
    channels: { [CHAN]: plainChan() },
  });
  is(h.exit(), 1);
  is(h.calls.length, 0);
  ok(/unknown-flag: --postonly \(did you mean --post-only\?\)/.test(h.errText()), h.errText());
});

test('connect-invite requires --ws=<T…>: missing or an org id is refused before any call', async () => {
  const a = await load({ argv: ['connect-invite', CHAN, '--emails=a@example.com', '--confirm'] });
  is(a.exit(), 1);
  is(a.calls.length, 0);
  ok(/--ws=<T…> is required/.test(a.errText()), a.errText());
  const b = await load({ argv: ['--ws=' + ORG, 'connect-invite', CHAN, '--emails=a@example.com'] });
  is(b.exit(), 1);
  is(b.calls.length, 0);
});

test('connect-invite --confirm: a truthy non-boolean ok ("true") is a FAILURE, exit 1', async () => {
  const h = await load({
    argv: INV.concat(['--emails=a@example.com', '--confirm']),
    channels: { [CHAN]: plainChan() },
    api: (m) => (m === 'conversations.inviteShared' ? { ok: 'true' } : undefined),
  });
  is(h.exit(), 1);
  ok(/malformed_response/.test(h.errText()), h.errText());
  is(h.count('conversations.info'), 1);
});

test('connect-invite --confirm: ok:false is reported and nothing is claimed', async () => {
  const h = await load({
    argv: INV.concat(['--emails=a@example.com', '--confirm', '--json']),
    channels: { [CHAN]: plainChan() },
    api: (m) => (m === 'conversations.inviteShared' ? { ok: false, error: 'invalid_email' } : undefined),
  });
  is(h.exit(), 1);
  const doc = JSON.parse(h.text());
  is(doc.status, 'error');
  ok(/invalid_email/.test(doc.error), doc.error);
});

test('connect-invite --confirm: ratelimited is retried with a backoff, then succeeds', async () => {
  let n = 0;
  const h = await load({
    argv: INV.concat(['--emails=a@example.com', '--confirm']),
    channels: { [CHAN]: plainChan() },
    api: (m) => {
      if (m !== 'conversations.inviteShared') return undefined;
      n += 1;
      return n === 1 ? { ok: false, error: 'ratelimited' } : { ok: true };
    },
  });
  is(h.exit(), 0, h.errText());
  is(h.count('conversations.inviteShared'), 2);
  is(h.sleeps.join(','), String(grid.CONNECT_WRITE_RETRY_MS));
});

// ── connect-permissions ──

test('connect-permissions dry run --upgrade: reads only, zero writes, names the exact call', async () => {
  const h = await load({ argv: PERM.concat(['--team=' + PARTNER, '--upgrade']), channels: { [CHAN]: sharedChan() } });
  is(h.exit(), 0, h.errText());
  is(h.methods(), 'conversations.info');
  is(mutations(h), 0);
  const t = h.text();
  ok(/Now:\s+post-only/.test(t), t);
  ok(/Target:\s+Post and Invite \(--upgrade\)/.test(t), t);
  ok(t.includes('target_team=' + PARTNER + ', action=upgrade'), t);
  ok(/No --confirm; nothing changed/.test(t), t);
});

test('connect-permissions --upgrade --confirm: exact params on the WORKSPACE token, read-back confirms', async () => {
  const h = await load({
    argv: PERM.concat(['--team=' + PARTNER, '--upgrade', '--confirm']),
    channels: { [CHAN]: sharedChan() },
  });
  is(h.exit(), 0, h.errText());
  is(h.methods(), 'conversations.info,conversations.externalInvitePermissions.set,conversations.info');
  is(h.calls[1].params, { channel: CHAN, target_team: PARTNER, action: 'upgrade' });
  is(h.calls[1].token, 'xoxc-test-ws');
  ok(/upgraded \(confirmed\)/.test(h.text()), h.text());
  ok(/Read-back:\s+confirmed after 1 attempt/.test(h.text()), h.text());
});

test('connect-permissions --downgrade --confirm: Post and Invite -> post-only', async () => {
  const h = await load({
    argv: PERM.concat(['--team=' + OTHER, '--downgrade', '--confirm']),
    channels: { [CHAN]: sharedChan() },
  });
  is(h.exit(), 0, h.errText());
  is(h.calls[1].params, { channel: CHAN, target_team: OTHER, action: 'downgrade' });
  ok(h.state.channels[CHAN].connected_limited_team_ids.includes(OTHER));
});

test('connect-permissions: --upgrade on a team already Post and Invite is nothing to do (exit 0, no write)', async () => {
  const h = await load({
    argv: PERM.concat(['--team=' + OTHER, '--upgrade', '--confirm']),
    channels: { [CHAN]: sharedChan() },
  });
  is(h.exit(), 0, h.errText());
  is(mutations(h), 0);
  ok(/already-post-and-invite: nothing to do/.test(h.text()), h.text());
});

test('connect-permissions: --downgrade on a team already post-only is nothing to do (exit 0, no write)', async () => {
  const h = await load({
    argv: PERM.concat(['--team=' + PARTNER, '--downgrade', '--confirm', '--json']),
    channels: { [CHAN]: sharedChan() },
  });
  is(h.exit(), 0, h.errText());
  is(mutations(h), 0);
  is(JSON.parse(h.text()).status, 'nothing-to-do');
});

test('connect-permissions: a team that is not connected is refused (team-not-connected), no write', async () => {
  const h = await load({
    argv: PERM.concat(['--team=E0EXAMPLE07', '--upgrade', '--confirm']),
    channels: { [CHAN]: sharedChan() },
  });
  is(h.exit(), 1);
  is(mutations(h), 0);
  ok(/refused: team-not-connected/.test(h.errText()) && /team_not_found/.test(h.errText()), h.errText());
});

test('connect-permissions: a T id of a connected grid is resolved to its E id via team.info', async () => {
  const h = await load({
    argv: PERM.concat(['--team=' + PARTNER_WS, '--upgrade', '--confirm']),
    channels: { [CHAN]: sharedChan() },
    teams: TEAMS,
  });
  is(h.exit(), 0, h.errText());
  is(h.calls[1].method, 'team.info');
  is(h.calls[2].params, { channel: CHAN, target_team: PARTNER, action: 'upgrade' });
});

test('connect-permissions refuses: own org as --team, not-shared, archived, hosted elsewhere', async () => {
  const cases = [
    [['--team=' + ORG], sharedChan(), /refused: own-team/],
    [['--team=' + PARTNER], plainChan(), /refused: not-shared/],
    [['--team=' + PARTNER], sharedChan({ is_archived: true }), /refused: archived/],
    [['--team=' + PARTNER], sharedChan({ conversation_host_id: OTHER }), /refused: hosted-elsewhere/],
    [['--team=' + PARTNER], sharedChan({ connected_limited_team_ids: undefined }), /refused: connections-unknown/],
  ];
  for (const [extra, chan, re] of cases) {
    const h = await load({ argv: PERM.concat(extra, ['--upgrade', '--confirm']), channels: { [CHAN]: chan } });
    is(h.exit(), 1, String(re));
    is(mutations(h), 0, String(re));
    ok(re.test(h.errText()), h.errText());
  }
});

test('connect-permissions: --upgrade with --downgrade, or neither, or no --team: refused, no call', async () => {
  for (const extra of [['--team=' + PARTNER, '--upgrade', '--downgrade'], ['--team=' + PARTNER], ['--upgrade'], ['--team=bogus', '--upgrade']]) {
    const h = await load({ argv: PERM.concat(extra), channels: { [CHAN]: sharedChan() } });
    is(h.exit(), 1, extra.join(' '));
    is(h.calls.length, 0, extra.join(' '));
  }
});

test('connect-permissions: ratelimited is retried (bounded, injectable backoff), then confirmed', async () => {
  let n = 0;
  const h = await load({
    argv: PERM.concat(['--team=' + PARTNER, '--upgrade', '--confirm']),
    channels: { [CHAN]: sharedChan() },
    api: (m, _params, state) => {
      if (m !== 'conversations.externalInvitePermissions.set') return undefined;
      n += 1;
      if (n < 3) return { ok: false, error: 'ratelimited' };
      state.channels[CHAN].connected_limited_team_ids = [];
      return { ok: true };
    },
  });
  is(h.exit(), 0, h.errText());
  is(h.count('conversations.externalInvitePermissions.set'), 3);
  is(h.sleeps.join(','), [grid.CONNECT_WRITE_RETRY_MS, grid.CONNECT_WRITE_RETRY_MS].join(','));
  ok(/attempt 3/.test(h.text()), h.text());
});

test('connect-permissions: ratelimited on every attempt gives up after CONNECT_WRITE_ATTEMPTS, exit 1', async () => {
  const h = await load({
    argv: PERM.concat(['--team=' + PARTNER, '--upgrade', '--confirm']),
    channels: { [CHAN]: sharedChan() },
    api: (m) => (m === 'conversations.externalInvitePermissions.set' ? { ok: false, error: 'ratelimited' } : undefined),
  });
  is(h.exit(), 1);
  is(h.count('conversations.externalInvitePermissions.set'), grid.CONNECT_WRITE_ATTEMPTS);
  is(h.sleeps.length, grid.CONNECT_WRITE_ATTEMPTS - 1);
  is(h.count('conversations.info'), 1);
  ok(/gave up after 4 attempts/.test(h.errText()), h.errText());
});

test('connect-permissions --confirm: ok:true but the read-back never shows it -> exit 3', async () => {
  const h = await load({
    argv: PERM.concat(['--team=' + PARTNER, '--upgrade', '--confirm']),
    channels: { [CHAN]: sharedChan() },
    api: (m) => (m === 'conversations.externalInvitePermissions.set' ? { ok: true } : undefined),
  });
  is(h.exit(), 3);
  is(h.count('conversations.info'), 1 + grid.CONNECT_READBACK_ATTEMPTS);
  is(h.sleeps.join(','), Array(grid.CONNECT_READBACK_ATTEMPTS - 1).fill(grid.CONNECT_READBACK_DELAY_MS).join(','));
  ok(/NOT confirmed/.test(h.text()), h.text());
  ok(/did not show E0EXAMPLE02 as Post and Invite/.test(h.errText()), h.errText());
});

test('connect-permissions --confirm: team DISCONNECTED during read-back is not a confirmed upgrade -> exit 3', async () => {
  const h = await load({
    argv: PERM.concat(['--team=' + PARTNER, '--upgrade', '--confirm']),
    channels: { [CHAN]: sharedChan() },
    api: (m, _params, state) => {
      if (m !== 'conversations.externalInvitePermissions.set') return undefined;
      const c = state.channels[CHAN];
      c.connected_team_ids = [OTHER, ORG];
      c.connected_limited_team_ids = [];
      return { ok: true };
    },
  });
  is(h.exit(), 3);
  is(h.count('conversations.info'), 1 + grid.CONNECT_READBACK_ATTEMPTS);
});

test('connect-permissions --confirm: truthy non-boolean ok is a FAILURE, no read-back', async () => {
  const h = await load({
    argv: PERM.concat(['--team=' + PARTNER, '--upgrade', '--confirm']),
    channels: { [CHAN]: sharedChan() },
    api: (m) => (m === 'conversations.externalInvitePermissions.set' ? { ok: 1 } : undefined),
  });
  is(h.exit(), 1);
  is(h.count('conversations.info'), 1);
  ok(/malformed_response/.test(h.errText()), h.errText());
});

// ── connect-status ──

test('connect-status: split Post and Invite vs post-only, names via team.info, read-only', async () => {
  const h = await load({
    argv: ['--ws=' + WS, '--org=' + ORG, 'connect-status', CHAN],
    channels: { [CHAN]: sharedChan({ pending_connected_team_ids: ['E0EXAMPLE05'] }) },
    teams: TEAMS,
  });
  is(h.exit(), 0, h.errText());
  is(mutations(h), 0);
  const t = h.text();
  ok(/Post\+Invite:\s+Other Partner \(E0EXAMPLE03\)/.test(t), t);
  ok(/Post-only:\s+Example Partner \(E0EXAMPLE02\)/.test(t), t);
  ok(/Pending:\s+E0EXAMPLE05\n/.test(t), t);
  ok(/Host:\s+Example Home Org \(E0EXAMPLE01\) — this org/.test(t), t);
  ok(/team\.info failed for E0EXAMPLE05 \(team_not_found\)/.test(t), t);
});

test('connect-status --json: one document; a never-shared channel reports lists as unknown, not []', async () => {
  const h = await load({ argv: ['--ws=' + WS, '--org=' + ORG, 'connect-status', CHAN, '--json'], channels: { [CHAN]: plainChan() } });
  is(h.exit(), 0, h.errText());
  const doc = JSON.parse(h.text());
  is(doc.status, 'ok');
  is(doc.state.host, 'not-shared');
  is(doc.state.connected_team_ids, null);
  is(doc.state.post_only_team_ids, null);
  is(doc.teams, {});
});

test('connect-status: team.info failing outright degrades to bare ids, exit 0', async () => {
  const h = await load({
    argv: ['--ws=' + WS, '--org=' + ORG, 'connect-status', CHAN],
    channels: { [CHAN]: sharedChan() },
    api: (m) => (m === 'team.info' ? { ok: false, error: 'missing_scope' } : undefined),
  });
  is(h.exit(), 0, h.errText());
  ok(/Post-only:\s+E0EXAMPLE02\n/.test(h.text()), h.text());
});

test('connect-status: not found exits 1; a bad channel id is refused before any call', async () => {
  const a = await load({ argv: ['--ws=' + WS, '--org=' + ORG, 'connect-status', CHAN] });
  is(a.exit(), 1);
  ok(/channel not found/.test(a.errText()), a.errText());
  const b = await load({ argv: ['--ws=' + WS, 'connect-status', 'general'] });
  is(b.exit(), 1);
  is(b.calls.length, 0);
});

// ── connect pure helpers ──

test('buildInviteSharedParams: one address; external_limited "false" by default, "true" only for postOnly===true', () => {
  is(grid.buildInviteSharedParams(CHAN, 'a@example.com').external_limited, 'false');
  is(grid.buildInviteSharedParams(CHAN, 'a@example.com', 'yes').external_limited, 'false');
  is(grid.buildInviteSharedParams(CHAN, 'a@example.com', true), {
    channel: CHAN,
    emails: 'a@example.com',
    external_limited: 'true',
  });
});

test('connect-invite --confirm: one address fails, the other is invited -> both reported, exit 1', async () => {
  const h = await load({
    argv: INV.concat(['--emails=a@example.com,b@example.org', '--confirm']),
    channels: { [CHAN]: plainChan() },
    api: (m, params) => {
      if (m !== 'conversations.inviteShared') return undefined;
      return params.emails === 'b@example.org' ? { ok: false, error: 'invalid_email' } : { ok: true, invite_id: 'I0EXAMPLE04' };
    },
  });
  is(h.exit(), 1);
  is(h.count('conversations.inviteShared'), 2);
  is(h.count('conversations.info'), 2);
  const t = h.text();
  ok(/a@example\.com:\s+invited I0EXAMPLE04/.test(t), t);
  ok(/b@example\.org:\s+FAILED: invalid_email/.test(t), t);
  ok(/1 of 2 address\(es\) failed/.test(h.errText()), h.errText());
});

test('connect-invite --confirm --json: every address fails -> status error, exit 1, no read-back', async () => {
  const h = await load({
    argv: INV.concat(['--emails=a@example.com,b@example.org', '--confirm', '--json']),
    channels: { [CHAN]: plainChan() },
    api: (m) => (m === 'conversations.inviteShared' ? { ok: false, error: 'too_many_emails' } : undefined),
  });
  is(h.exit(), 1);
  is(h.count('conversations.info'), 1);
  const doc = JSON.parse(h.text());
  is(doc.status, 'error');
  is(doc.write.failed, 2);
});

test('parseEmailList: lowercases, dedupes, keeps order, rejects bad shapes', () => {
  is(grid.parseEmailList(' A@Example.com;b@example.org a@example.com ').emails, ['a@example.com', 'b@example.org']);
  is(grid.parseEmailList('x@y').ok, false);
  is(grid.parseEmailList('a@@b.com').ok, false);
  is(grid.parseEmailList('<a@b.com>').ok, false);
  is(grid.parseEmailList(true).ok, false);
});

test('writeWithRetry: only ok===true succeeds; non-ratelimit errors are not retried', async () => {
  const seen = [];
  const call = async () => {
    seen.push(1);
    return { ok: false, error: 'not_allowed' };
  };
  const r = await grid.writeWithRetry(call, 'm', {}, { sleep: async () => {}, attempts: 4 });
  is(r.ok, false);
  is(seen.length, 1);
  const r2 = await grid.writeWithRetry(async () => ({ ok: 'yes' }), 'm', {}, { sleep: async () => {} });
  is(r2.ok, false);
  ok(/malformed_response/.test(r2.error), r2.error);
});

// ── rate-limit variants and ambiguous answers (Codex review on #499) ──

test('connect-invite: the documented "ratelimit" (no d) is retried too', async () => {
  let n = 0;
  const h = await load({
    argv: INV.concat(['--emails=a@example.com', '--confirm']),
    channels: { [CHAN]: plainChan() },
    api: (m) => {
      if (m !== 'conversations.inviteShared') return undefined;
      n += 1;
      return n === 1 ? { ok: false, error: 'ratelimit' } : { ok: true };
    },
  });
  is(h.exit(), 0, h.errText());
  is(h.count('conversations.inviteShared'), 2);
  is(h.sleeps.join(','), String(grid.CONNECT_WRITE_RETRY_MS));
});

test('connect-invite: internal_error is outcome UNKNOWN: not retried, not "nothing invited", read back, exit 3', async () => {
  const h = await load({
    argv: INV.concat(['--emails=a@example.com', '--confirm']),
    channels: { [CHAN]: plainChan() },
    api: (m) => (m === 'conversations.inviteShared' ? { ok: false, error: 'internal_error' } : undefined),
  });
  is(h.exit(), 3);
  is(h.count('conversations.inviteShared'), 1);
  is(h.count('conversations.info'), 2);
  ok(/a@example\.com:\s+UNKNOWN: internal_error/.test(h.text()), h.text());
  ok(!/Nothing was invited/.test(h.errText()), h.errText());
  ok(/Do not re-send blindly/.test(h.errText()), h.errText());
});

test('connect-invite --json: fatal_error + a definite failure -> partially-invited, exit 1, both counted', async () => {
  const h = await load({
    argv: INV.concat(['--emails=a@example.com,b@example.org', '--confirm', '--json']),
    channels: { [CHAN]: plainChan() },
    api: (m, params) => {
      if (m !== 'conversations.inviteShared') return undefined;
      return params.emails === 'a@example.com' ? { ok: false, error: 'fatal_error' } : { ok: false, error: 'invalid_email' };
    },
  });
  is(h.exit(), 1);
  const doc = JSON.parse(h.text());
  is(doc.status, 'partially-invited');
  is(doc.write.unknown, 1);
  is(doc.write.failed, 1);
  is(doc.write.invited, 0);
});

test('connect-permissions: internal_error is outcome UNKNOWN even when the read-back matches -> exit 3, no retry', async () => {
  const h = await load({
    argv: PERM.concat(['--team=' + PARTNER, '--upgrade', '--confirm']),
    channels: { [CHAN]: sharedChan() },
    api: (m, _params, state) => {
      if (m !== 'conversations.externalInvitePermissions.set') return undefined;
      state.channels[CHAN].connected_limited_team_ids = [];
      return { ok: false, error: 'internal_error' };
    },
  });
  is(h.exit(), 3);
  is(h.count('conversations.externalInvitePermissions.set'), 1);
  is(h.count('conversations.info'), 2);
  ok(/outcome UNKNOWN/.test(h.text()), h.text());
  ok(/The read-back shows E0EXAMPLE02 as Post and Invite/.test(h.errText()), h.errText());
});

test('writeOutcome: ok / failed / unknown classification', () => {
  is(grid.writeOutcome({ ok: true }), 'ok');
  is(grid.writeOutcome({ ok: 'true' }), 'failed');
  is(grid.writeOutcome({ ok: false, error: 'invalid_email' }), 'failed');
  is(grid.writeOutcome({ ok: false, error: 'internal_error' }), 'unknown');
  is(grid.writeOutcome({ ok: false, error: 'fatal_error' }), 'unknown');
  is(grid.writeOutcome({ ok: false, error: 'xhr_error' }), 'unknown');
  is(grid.writeOutcome(null), 'unknown');
});

test('connect-status: --ws=E… (an org id) is refused before any call; the workspace token is required', async () => {
  const h = await load({ argv: ['--ws=' + ORG, '--org=' + ORG, 'connect-status', CHAN], channels: { [CHAN]: sharedChan() } });
  is(h.exit(), 1);
  is(h.calls.length, 0);
  ok(/expected a T… WORKSPACE id/.test(h.errText()), h.errText());
});

test('connect-status: no --ws uses the tab workspace (T…) and its token', async () => {
  const h = await load({ argv: ['--org=' + ORG, 'connect-status', CHAN], channels: { [CHAN]: sharedChan() } });
  is(h.exit(), 0, h.errText());
  is(h.calls[0].token, 'xoxc-test-ws');
});

test('connect-status: an enterprise-scoped /client/E… tab is not used as the workspace -> asks for --ws', async () => {
  const h = await load({
    argv: ['--org=' + ORG, 'connect-status', CHAN, '--json'],
    channels: { [CHAN]: sharedChan() },
    tabUrl: 'https://app.slack.com/client/' + ORG + '/' + CHAN,
  });
  is(h.exit(), 1);
  is(h.calls.length, 0);
  const doc = JSON.parse(h.text());
  ok(/not a T… workspace\. Pass --ws=<T…>/.test(doc.error), doc.error);
});

test('connect-permissions: a malformed id list ([null]) is UNKNOWN, refused (connections-unknown), not read as []', async () => {
  for (const over of [{ connected_limited_team_ids: [null] }, { connected_team_ids: [PARTNER, '', ORG] }, { connected_limited_team_ids: [PARTNER, 7] }]) {
    for (const dir of ['--upgrade', '--downgrade']) {
      const h = await load({
        argv: PERM.concat(['--team=' + PARTNER, dir, '--confirm']),
        channels: { [CHAN]: sharedChan(over) },
      });
      is(h.exit(), 1, JSON.stringify(over) + dir);
      is(mutations(h), 0, JSON.stringify(over) + dir);
      ok(/refused: connections-unknown/.test(h.errText()), h.errText());
    }
  }
});

test('normalizeConnectState: malformed lists are null, so the Post and Invite / post-only split is unknown', () => {
  const st = grid.normalizeConnectState(sharedChan({ connected_limited_team_ids: [null] }), [ORG, WS]);
  is(st.connected_limited_team_ids, null);
  is(st.post_only_team_ids, null);
  is(st.post_and_invite_team_ids, null);
});

test('connect-permissions: documented team.info shape (id = workspace, enterprise_id = grid) resolves via enterprise_id', async () => {
  const h = await load({
    argv: PERM.concat(['--team=' + PARTNER_WS, '--upgrade', '--confirm', '--json']),
    channels: { [CHAN]: sharedChan() },
    teams: { [PARTNER_WS]: { id: PARTNER_WS, name: 'Example Partner WS', enterprise_id: PARTNER } },
  });
  is(h.exit(), 0, h.errText());
  is(h.calls[2].params, { channel: CHAN, target_team: PARTNER, action: 'upgrade' });
  is(JSON.parse(h.text()).team_resolved_via, 'team.info enterprise_id');
});

test('connect-permissions: team.info enterprise_id that is NOT connected is still team-not-connected', async () => {
  const h = await load({
    argv: PERM.concat(['--team=' + PARTNER_WS, '--upgrade', '--confirm']),
    channels: { [CHAN]: sharedChan() },
    teams: { [PARTNER_WS]: { id: PARTNER_WS, enterprise_id: 'E0EXAMPLE08' } },
  });
  is(h.exit(), 1);
  is(mutations(h), 0);
  ok(/refused: team-not-connected/.test(h.errText()), h.errText());
});

tile.json