When an unknown sender writes into a wired messaging group, surface the
situation to an admin instead of silently dropping. Flow:
1. Router → access gate → handleUnknownSender (policy='request_approval')
2. Fire-and-forget requestSenderApproval: pickApprover + pickApprovalDelivery
pick a reachable admin DM; deliver an Approve / Deny card; insert a
pending_sender_approvals row carrying the original InboundEvent JSON.
3. In-flight dedup: UNIQUE(messaging_group_id, sender_identity) — a retry
from the same stranger while pending is silently dropped, not re-carded.
4. Admin clicks → Chat SDK bridge → onAction → host response-registry.
The new handleSenderApprovalResponse in the permissions module claims
responses whose questionId matches a pending_sender_approvals row.
5. approve: addMember(stranger, agent_group) + replay the stored event via
routeInbound — the second attempt clears the gate because the user is
now known.
6. deny: delete the pending row. No denial persistence (ACTION-ITEMS item 5
decision) — a future attempt triggers a fresh card.
Schema:
- Migration 011 adds pending_sender_approvals (id, mg_id, agent_group_id,
sender_identity, sender_name, original_message JSON, approver_user_id,
created_at, UNIQUE(mg_id, sender_identity)).
- Also flips messaging_groups.unknown_sender_policy default from 'strict'
to 'request_approval' (rebuild-table). Existing rows unchanged — only
the default applied to new rows flips.
- Router auto-create for unknown platform/chat drops the hardcoded
'strict' override; schema default applies.
- src/db/schema.ts reference updated to match.
Why default-flip: users wire their DM during setup and don't discover that
'strict' means "silent drop of everyone not in user_roles/members". The
approval flow is the safe default — the admin sees the stranger, explicitly
decides. 'public' stays opt-in for truly open channels.
Failure modes (row NOT created so a future attempt can try again):
- No eligible approver configured (fresh install before first owner).
- No reachable DM for any approver.
- Delivery adapter missing.
Tests (src/modules/permissions/sender-approval.test.ts, 4 cases):
- First unknown message → card delivered + row created
- Retry while pending → dedup'd (1 card, 1 row)
- Approve → member added + message replayed + container woken
- Deny → row cleared + no member added
Closes: ACTION-ITEMS item 5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
266 lines
8.9 KiB
TypeScript
266 lines
8.9 KiB
TypeScript
/**
|
|
* Integration tests for the unknown-sender request_approval flow
|
|
* (ACTION-ITEMS item 5).
|
|
*
|
|
* Covers:
|
|
* - request_approval policy fires `requestSenderApproval` on first unknown
|
|
* message from a sender
|
|
* - In-flight dedup: second message from the same sender while pending is
|
|
* silently dropped (no second card, no second row)
|
|
* - Approve path: member added, original message replayed via routeInbound,
|
|
* container woken
|
|
* - Deny path: pending row deleted, no member added
|
|
*/
|
|
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, createMessagingGroupAgent } 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 — record card deliveries for assertions.
|
|
const deliverMock = vi.fn().mockResolvedValue('plat-msg-id');
|
|
vi.mock('../../delivery.js', () => ({
|
|
getDeliveryAdapter: () => ({
|
|
deliver: deliverMock,
|
|
}),
|
|
}));
|
|
|
|
// Mock ensureUserDm to return the approver's existing messaging group
|
|
// 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-sender-approval' };
|
|
});
|
|
|
|
const TEST_DIR = '/tmp/nanoclaw-test-sender-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);
|
|
|
|
// Side-effect imports: register hooks (permissions module) AFTER the
|
|
// mocks are in place so the access gate / response handler pick up the
|
|
// mocked delivery + user-dm helpers.
|
|
await import('./index.js');
|
|
|
|
// Fixtures: agent group, messaging group with request_approval, wiring,
|
|
// owner + DM messaging group for approver delivery.
|
|
createAgentGroup({ id: 'ag-1', name: 'Agent', folder: 'agent', agent_provider: null, created_at: now() });
|
|
|
|
createMessagingGroup({
|
|
id: 'mg-chat',
|
|
channel_type: 'telegram',
|
|
platform_id: 'chat-123',
|
|
name: 'Group Chat',
|
|
is_group: 1,
|
|
unknown_sender_policy: 'request_approval',
|
|
created_at: now(),
|
|
});
|
|
createMessagingGroupAgent({
|
|
id: 'mga-1',
|
|
messaging_group_id: 'mg-chat',
|
|
agent_group_id: 'ag-1',
|
|
engage_mode: 'pattern',
|
|
engage_pattern: '.',
|
|
sender_scope: 'all',
|
|
ignored_message_policy: 'drop',
|
|
session_mode: 'shared',
|
|
priority: 0,
|
|
created_at: now(),
|
|
});
|
|
|
|
// Owner user + their DM messaging group (pickApprover + ensureUserDm target).
|
|
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(),
|
|
});
|
|
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 stranger(text: string) {
|
|
return {
|
|
channelType: 'telegram',
|
|
platformId: 'chat-123',
|
|
threadId: null,
|
|
message: {
|
|
id: `stranger-${Math.random().toString(36).slice(2, 8)}`,
|
|
kind: 'chat' as const,
|
|
content: JSON.stringify({
|
|
senderId: 'tg:stranger',
|
|
senderName: 'Stranger',
|
|
text,
|
|
}),
|
|
timestamp: now(),
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('unknown-sender request_approval flow', () => {
|
|
it('delivers an approval card on first unknown message', async () => {
|
|
const { routeInbound } = await import('../../router.js');
|
|
await routeInbound(stranger('hi'));
|
|
|
|
// Wait for the fire-and-forget requestSenderApproval to resolve.
|
|
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');
|
|
expect(payload.questionId).toMatch(/^nsa-/);
|
|
|
|
const { getDb } = await import('../../db/connection.js');
|
|
const rows = getDb().prepare('SELECT * FROM pending_sender_approvals').all();
|
|
expect(rows).toHaveLength(1);
|
|
});
|
|
|
|
it('dedups a second message from the same stranger while pending', async () => {
|
|
const { routeInbound } = await import('../../router.js');
|
|
await routeInbound(stranger('hello'));
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
await routeInbound(stranger('are you there?'));
|
|
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_sender_approvals').get() as { c: number }
|
|
).c;
|
|
expect(count).toBe(1);
|
|
});
|
|
|
|
it('approve → adds member and replays the original message', 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(stranger('please let me in'));
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
|
|
const { getDb } = await import('../../db/connection.js');
|
|
const pending = getDb().prepare('SELECT id FROM pending_sender_approvals').get() as { id: string };
|
|
expect(pending).toBeDefined();
|
|
|
|
// Fire the approve click through the response-handler chain.
|
|
for (const handler of getResponseHandlers()) {
|
|
const claimed = await handler({
|
|
questionId: pending.id,
|
|
value: 'approve',
|
|
userId: 'telegram:owner',
|
|
channelType: 'telegram',
|
|
platformId: 'dm-owner',
|
|
threadId: null,
|
|
});
|
|
if (claimed) break;
|
|
}
|
|
|
|
// Member row added for the stranger against the wired agent group.
|
|
const member = getDb()
|
|
.prepare('SELECT 1 AS x FROM agent_group_members WHERE user_id = ? AND agent_group_id = ?')
|
|
.get('tg:stranger', 'ag-1');
|
|
expect(member).toBeDefined();
|
|
|
|
// Pending row cleared.
|
|
const stillPending = getDb().prepare('SELECT COUNT(*) AS c FROM pending_sender_approvals').get() as { c: number };
|
|
expect(stillPending.c).toBe(0);
|
|
|
|
// Message replayed + container woken.
|
|
expect(wakeContainer).toHaveBeenCalled();
|
|
});
|
|
|
|
it('deny → deletes the pending row without adding a member', async () => {
|
|
const { routeInbound } = await import('../../router.js');
|
|
const { getResponseHandlers } = await import('../../response-registry.js');
|
|
|
|
await routeInbound(stranger('hello'));
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
|
|
const { getDb } = await import('../../db/connection.js');
|
|
const pending = getDb().prepare('SELECT id FROM pending_sender_approvals').get() as { id: string };
|
|
expect(pending).toBeDefined();
|
|
|
|
for (const handler of getResponseHandlers()) {
|
|
const claimed = await handler({
|
|
questionId: pending.id,
|
|
value: 'reject',
|
|
userId: 'telegram:owner',
|
|
channelType: 'telegram',
|
|
platformId: 'dm-owner',
|
|
threadId: null,
|
|
});
|
|
if (claimed) break;
|
|
}
|
|
|
|
const count = (
|
|
getDb().prepare('SELECT COUNT(*) AS c FROM pending_sender_approvals').get() as { c: number }
|
|
).c;
|
|
expect(count).toBe(0);
|
|
const member = getDb()
|
|
.prepare('SELECT 1 AS x FROM agent_group_members WHERE user_id = ? AND agent_group_id = ?')
|
|
.get('tg:stranger', 'ag-1');
|
|
expect(member).toBeUndefined();
|
|
});
|
|
});
|