feat(permissions): unknown-channel registration flow with owner approval
When the router sees a mention or DM on a messaging group that isn't wired
to any agent, it now escalates to an owner for approval instead of silently
dropping. Mirrors the existing unknown-sender approval pattern (ACTION-ITEMS
item 22).
Schema (migration 012):
- `messaging_groups.denied_at TEXT NULL` — timestamp set on deny so future
mentions stop escalating. ALTER TABLE ADD COLUMN, FK-safe (unlike the
rebuild that bit migration 011).
- `pending_channel_approvals` — PK on `messaging_group_id` gives free
in-flight dedup. One card per channel, no spam on rapid retries.
Router:
- New hook `setChannelRequestGate(mg, event) => Promise<void>`, invoked
from the no-wirings branch when the message was addressed to the bot
(isMention=true). Hook is fire-and-forget.
- Checks `mg.denied_at` before escalating — denied channels drop silently
and do not re-prompt.
- The two "no-wirings" branches (fresh auto-create and existing mg with
no agents) are consolidated into one escalation path that calls the
gate once. Without the module, behavior is log + record (no regression).
Permissions module:
- `channel-approval.ts::requestChannelApproval` — MVP picker: target
agent is `getAllAgentGroups()[0]`, card names it explicitly ("Wire it
to <Andy>?"). Approver via existing `pickApprover` + `pickApprovalDelivery`
primitives.
- Response handler: same click-auth pattern as sender-approval (clicker
must be the designated approver OR have admin privilege over the
target agent group).
- Approve defaults per the feature spec:
engage_mode = 'mention-sticky' for groups, 'pattern' + '.' for DMs
sender_scope = 'known'
ignored_message_policy = 'accumulate'
session_mode = 'shared'
DM vs group inferred from the original event's threadId (non-null →
group) because the auto-created mg has a placeholder is_group=0 until
the adapter fills it in.
- Triggering sender is auto-added to agent_group_members so sender_scope=
'known' doesn't bounce the replayed message into a sender-approval
cascade.
- Deny: stamps messaging_groups.denied_at, clears pending row.
- Failure modes — no owner, no agent groups, no reachable DM — log and
drop without creating a pending row, letting a future attempt try
again (same as sender-approval).
9 new integration tests cover every branch: mention triggers card, DM
triggers card, dedup, approve creates correct wiring + admits sender +
replays, approve-on-DM uses pattern/'.' defaults, deny sets denied_at
and future mentions drop silently, unauthorized clicker rejected,
no-owner drops, no-agent-groups drops.
168 tests pass (was 159; +9).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
392
src/modules/permissions/channel-approval.test.ts
Normal file
392
src/modules/permissions/channel-approval.test.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* Integration tests for the unknown-channel registration flow (ACTION-ITEMS
|
||||
* item 22).
|
||||
*
|
||||
* Covers:
|
||||
* - Mention on an unwired channel fires an owner-approval card
|
||||
* - DM on an unwired channel fires a card (engage_mode will default to pattern='.')
|
||||
* - In-flight dedup: second mention while a card is pending doesn't spam
|
||||
* - Approve: wiring created with correct defaults, triggering sender added
|
||||
* as member, replay wakes the container
|
||||
* - Deny: messaging_groups.denied_at set, future mentions drop silently
|
||||
* - Unauthorized clicker is rejected (same pattern as sender-approval)
|
||||
* - No-owner install: no card, no row
|
||||
* - No agent groups configured: no card, no row
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { initTestDb, closeDb, runMigrations } from '../../db/index.js';
|
||||
import { createAgentGroup } from '../../db/agent-groups.js';
|
||||
import { createMessagingGroup, getMessagingGroupByPlatform } from '../../db/messaging-groups.js';
|
||||
import { upsertUser } from './db/users.js';
|
||||
import { grantRole } from './db/user-roles.js';
|
||||
|
||||
// Mock container runner — prevent actual docker spawn.
|
||||
vi.mock('../../container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
isContainerRunning: vi.fn().mockReturnValue(false),
|
||||
getActiveContainerCount: vi.fn().mockReturnValue(0),
|
||||
killContainer: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock delivery adapter.
|
||||
const deliverMock = vi.fn().mockResolvedValue('plat-msg-id');
|
||||
vi.mock('../../delivery.js', () => ({
|
||||
getDeliveryAdapter: () => ({ deliver: deliverMock }),
|
||||
}));
|
||||
|
||||
// Mock ensureUserDm — look up the owner's preconfigured DM row instead of
|
||||
// hitting a real openDM RPC.
|
||||
vi.mock('./user-dm.js', () => ({
|
||||
ensureUserDm: vi.fn(async (userId: string) => {
|
||||
const { getDb } = await import('../../db/connection.js');
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
`SELECT mg.* FROM messaging_groups mg
|
||||
JOIN user_dms ud ON ud.messaging_group_id = mg.id
|
||||
WHERE ud.user_id = ?`,
|
||||
)
|
||||
.get(userId);
|
||||
return row;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual('../../config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-channel-approval' };
|
||||
});
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-channel-approval';
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
|
||||
await import('./index.js'); // register hooks
|
||||
|
||||
// Base fixtures: one agent group + owner with a DM on 'telegram'.
|
||||
createAgentGroup({ id: 'ag-1', name: 'Andy', folder: 'andy', agent_provider: null, created_at: now() });
|
||||
|
||||
upsertUser({ id: 'telegram:owner', kind: 'telegram', display_name: 'Owner', created_at: now() });
|
||||
grantRole({
|
||||
user_id: 'telegram:owner',
|
||||
role: 'owner',
|
||||
agent_group_id: null,
|
||||
granted_by: null,
|
||||
granted_at: now(),
|
||||
});
|
||||
|
||||
// Pre-seed owner's DM messaging group + user_dms mapping.
|
||||
createMessagingGroup({
|
||||
id: 'mg-dm-owner',
|
||||
channel_type: 'telegram',
|
||||
platform_id: 'dm-owner',
|
||||
name: 'Owner DM',
|
||||
is_group: 0,
|
||||
unknown_sender_policy: 'public',
|
||||
created_at: now(),
|
||||
});
|
||||
const { getDb } = await import('../../db/connection.js');
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO user_dms (user_id, channel_type, messaging_group_id, resolved_at)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
)
|
||||
.run('telegram:owner', 'telegram', 'mg-dm-owner', now());
|
||||
|
||||
deliverMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
function groupMention(platformId: string, text = '@bot hello') {
|
||||
return {
|
||||
channelType: 'telegram',
|
||||
platformId,
|
||||
threadId: 'thread-1', // non-null → is_group=true per channel-approval default-picker logic
|
||||
message: {
|
||||
id: `msg-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'chat' as const,
|
||||
content: JSON.stringify({ senderId: 'caller', senderName: 'Caller', text }),
|
||||
timestamp: now(),
|
||||
isMention: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dmEvent(platformId: string, text = 'hello') {
|
||||
return {
|
||||
channelType: 'telegram',
|
||||
platformId,
|
||||
threadId: null,
|
||||
message: {
|
||||
id: `msg-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'chat' as const,
|
||||
content: JSON.stringify({ senderId: 'stranger', senderName: 'Stranger', text }),
|
||||
timestamp: now(),
|
||||
isMention: true, // DM bridge sets isMention=true
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('unknown-channel registration flow', () => {
|
||||
it('delivers an approval card on mention into an unwired group', async () => {
|
||||
const { routeInbound } = await import('../../router.js');
|
||||
await routeInbound(groupMention('chat-new'));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
expect(deliverMock).toHaveBeenCalledTimes(1);
|
||||
const [channel, platformId, thread, kind, content] = deliverMock.mock.calls[0];
|
||||
expect(channel).toBe('telegram');
|
||||
expect(platformId).toBe('dm-owner'); // delivered to owner's DM
|
||||
expect(thread).toBeNull();
|
||||
expect(kind).toBe('chat-sdk');
|
||||
const payload = JSON.parse(content as string);
|
||||
expect(payload.type).toBe('ask_question');
|
||||
// Card names the target agent so the owner knows what they're wiring to.
|
||||
expect(payload.question).toContain('Andy');
|
||||
|
||||
const { getDb } = await import('../../db/connection.js');
|
||||
const rows = getDb().prepare('SELECT * FROM pending_channel_approvals').all() as Array<{
|
||||
messaging_group_id: string;
|
||||
}>;
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('delivers a card on DM too (non-threaded event)', async () => {
|
||||
const { routeInbound } = await import('../../router.js');
|
||||
await routeInbound(dmEvent('dm-new-user'));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
expect(deliverMock).toHaveBeenCalledTimes(1);
|
||||
const { getDb } = await import('../../db/connection.js');
|
||||
const count = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }).c;
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it('dedups a second mention while the card is pending', async () => {
|
||||
const { routeInbound } = await import('../../router.js');
|
||||
await routeInbound(groupMention('chat-busy'));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await routeInbound(groupMention('chat-busy', '@bot still here'));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
expect(deliverMock).toHaveBeenCalledTimes(1);
|
||||
const { getDb } = await import('../../db/connection.js');
|
||||
const count = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }).c;
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it('approve → creates wiring, admits triggering sender, replays', async () => {
|
||||
const { routeInbound } = await import('../../router.js');
|
||||
const { getResponseHandlers } = await import('../../response-registry.js');
|
||||
const { wakeContainer } = await import('../../container-runner.js');
|
||||
(wakeContainer as unknown as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
await routeInbound(groupMention('chat-approve'));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
const { getDb } = await import('../../db/connection.js');
|
||||
const pending = getDb()
|
||||
.prepare('SELECT messaging_group_id FROM pending_channel_approvals')
|
||||
.get() as { messaging_group_id: string };
|
||||
expect(pending).toBeDefined();
|
||||
|
||||
// Owner clicks approve.
|
||||
for (const handler of getResponseHandlers()) {
|
||||
const claimed = await handler({
|
||||
questionId: pending.messaging_group_id,
|
||||
value: 'approve',
|
||||
userId: 'owner', // raw platform id — handler namespaces it
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-owner',
|
||||
threadId: null,
|
||||
});
|
||||
if (claimed) break;
|
||||
}
|
||||
|
||||
// Wiring created with MVP defaults.
|
||||
const mga = getDb()
|
||||
.prepare('SELECT * FROM messaging_group_agents WHERE messaging_group_id = ?')
|
||||
.get(pending.messaging_group_id) as {
|
||||
engage_mode: string;
|
||||
engage_pattern: string | null;
|
||||
sender_scope: string;
|
||||
ignored_message_policy: string;
|
||||
agent_group_id: string;
|
||||
};
|
||||
expect(mga).toBeDefined();
|
||||
expect(mga.engage_mode).toBe('mention-sticky'); // group (threadId != null)
|
||||
expect(mga.engage_pattern).toBeNull();
|
||||
expect(mga.sender_scope).toBe('known');
|
||||
expect(mga.ignored_message_policy).toBe('accumulate');
|
||||
expect(mga.agent_group_id).toBe('ag-1');
|
||||
|
||||
// Triggering sender auto-admitted so sender_scope='known' doesn't
|
||||
// bounce the replay into sender-approval.
|
||||
const member = getDb()
|
||||
.prepare('SELECT 1 AS x FROM agent_group_members WHERE user_id = ? AND agent_group_id = ?')
|
||||
.get('telegram:caller', 'ag-1');
|
||||
expect(member).toBeDefined();
|
||||
|
||||
// Pending row cleared and container woken via replay.
|
||||
const stillPending = (
|
||||
getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }
|
||||
).c;
|
||||
expect(stillPending).toBe(0);
|
||||
expect(wakeContainer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('approve on a DM wires with pattern="." defaults', async () => {
|
||||
const { routeInbound } = await import('../../router.js');
|
||||
const { getResponseHandlers } = await import('../../response-registry.js');
|
||||
|
||||
await routeInbound(dmEvent('dm-approve-user'));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
const { getDb } = await import('../../db/connection.js');
|
||||
const pending = getDb()
|
||||
.prepare('SELECT messaging_group_id FROM pending_channel_approvals')
|
||||
.get() as { messaging_group_id: string };
|
||||
|
||||
for (const handler of getResponseHandlers()) {
|
||||
const claimed = await handler({
|
||||
questionId: pending.messaging_group_id,
|
||||
value: 'approve',
|
||||
userId: 'owner',
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-owner',
|
||||
threadId: null,
|
||||
});
|
||||
if (claimed) break;
|
||||
}
|
||||
|
||||
const mga = getDb()
|
||||
.prepare('SELECT engage_mode, engage_pattern FROM messaging_group_agents WHERE messaging_group_id = ?')
|
||||
.get(pending.messaging_group_id) as { engage_mode: string; engage_pattern: string };
|
||||
expect(mga.engage_mode).toBe('pattern');
|
||||
expect(mga.engage_pattern).toBe('.');
|
||||
});
|
||||
|
||||
it('deny → sets denied_at; future mentions drop silently without a second card', async () => {
|
||||
const { routeInbound } = await import('../../router.js');
|
||||
const { getResponseHandlers } = await import('../../response-registry.js');
|
||||
|
||||
await routeInbound(groupMention('chat-deny'));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const { getDb } = await import('../../db/connection.js');
|
||||
const pending = getDb()
|
||||
.prepare('SELECT messaging_group_id FROM pending_channel_approvals')
|
||||
.get() as { messaging_group_id: string };
|
||||
|
||||
for (const handler of getResponseHandlers()) {
|
||||
const claimed = await handler({
|
||||
questionId: pending.messaging_group_id,
|
||||
value: 'reject',
|
||||
userId: 'owner',
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-owner',
|
||||
threadId: null,
|
||||
});
|
||||
if (claimed) break;
|
||||
}
|
||||
|
||||
// denied_at set, pending row cleared, no wiring.
|
||||
const mg = getMessagingGroupByPlatform('telegram', 'chat-deny');
|
||||
expect(mg?.denied_at).not.toBeNull();
|
||||
expect(mg?.denied_at).toBeTruthy();
|
||||
const mgaCount = (
|
||||
getDb()
|
||||
.prepare('SELECT COUNT(*) AS c FROM messaging_group_agents WHERE messaging_group_id = ?')
|
||||
.get(pending.messaging_group_id) as { c: number }
|
||||
).c;
|
||||
expect(mgaCount).toBe(0);
|
||||
|
||||
// A follow-up mention on the denied channel: no new card, no new pending row.
|
||||
deliverMock.mockClear();
|
||||
await routeInbound(groupMention('chat-deny', '@bot please'));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(deliverMock).not.toHaveBeenCalled();
|
||||
const stillPending = (
|
||||
getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }
|
||||
).c;
|
||||
expect(stillPending).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects clicks from an unauthorized user (prevents self-admit via forwarded card)', async () => {
|
||||
const { routeInbound } = await import('../../router.js');
|
||||
const { getResponseHandlers } = await import('../../response-registry.js');
|
||||
|
||||
await routeInbound(groupMention('chat-unauth'));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const { getDb } = await import('../../db/connection.js');
|
||||
const pending = getDb()
|
||||
.prepare('SELECT messaging_group_id FROM pending_channel_approvals')
|
||||
.get() as { messaging_group_id: string };
|
||||
|
||||
for (const handler of getResponseHandlers()) {
|
||||
const claimed = await handler({
|
||||
questionId: pending.messaging_group_id,
|
||||
value: 'approve',
|
||||
userId: 'random-bystander',
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-random',
|
||||
threadId: null,
|
||||
});
|
||||
if (claimed) break;
|
||||
}
|
||||
|
||||
// No wiring created, pending row preserved so a real approver can act on it.
|
||||
const mgaCount = (
|
||||
getDb()
|
||||
.prepare('SELECT COUNT(*) AS c FROM messaging_group_agents WHERE messaging_group_id = ?')
|
||||
.get(pending.messaging_group_id) as { c: number }
|
||||
).c;
|
||||
expect(mgaCount).toBe(0);
|
||||
const stillPending = (
|
||||
getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }
|
||||
).c;
|
||||
expect(stillPending).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('no-owner / no-agent failure modes', () => {
|
||||
it('no owner → no card, no pending row (fresh-install bootstrap path)', async () => {
|
||||
// Wipe the owner grant set up in the outer beforeEach.
|
||||
const { getDb } = await import('../../db/connection.js');
|
||||
getDb().prepare('DELETE FROM user_roles').run();
|
||||
|
||||
const { routeInbound } = await import('../../router.js');
|
||||
await routeInbound(groupMention('chat-noowner'));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
expect(deliverMock).not.toHaveBeenCalled();
|
||||
const count = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }).c;
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it('no agent groups → no card, no pending row', async () => {
|
||||
const { getDb } = await import('../../db/connection.js');
|
||||
// Drop foreign-key-dependent rows first, then the agent group itself.
|
||||
getDb().prepare('DELETE FROM user_roles').run();
|
||||
getDb().prepare('DELETE FROM agent_groups').run();
|
||||
|
||||
const { routeInbound } = await import('../../router.js');
|
||||
await routeInbound(groupMention('chat-noagent'));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
expect(deliverMock).not.toHaveBeenCalled();
|
||||
const count = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }).c;
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user