@vellumai/assistant 0.3.18 → 0.3.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +155 -15
- package/Dockerfile +1 -0
- package/README.md +40 -4
- package/docs/architecture/integrations.md +7 -11
- package/docs/architecture/security.md +80 -0
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -0
- package/src/__tests__/approval-primitive.test.ts +540 -0
- package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
- package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
- package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
- package/src/__tests__/call-controller.test.ts +605 -104
- package/src/__tests__/channel-invite-transport.test.ts +264 -0
- package/src/__tests__/checker.test.ts +60 -0
- package/src/__tests__/cli.test.ts +42 -1
- package/src/__tests__/config-schema.test.ts +11 -127
- package/src/__tests__/config-watcher.test.ts +0 -8
- package/src/__tests__/daemon-lifecycle.test.ts +1 -0
- package/src/__tests__/daemon-server-session-init.test.ts +8 -2
- package/src/__tests__/diff.test.ts +22 -0
- package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
- package/src/__tests__/guardian-action-grant-mint-consume.test.ts +779 -0
- package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
- package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
- package/src/__tests__/guardian-dispatch.test.ts +185 -1
- package/src/__tests__/guardian-grant-minting.test.ts +532 -0
- package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
- package/src/__tests__/invite-redemption-service.test.ts +306 -0
- package/src/__tests__/ipc-snapshot.test.ts +58 -0
- package/src/__tests__/notification-decision-fallback.test.ts +88 -0
- package/src/__tests__/remote-skill-policy.test.ts +215 -0
- package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
- package/src/__tests__/sandbox-host-parity.test.ts +6 -13
- package/src/__tests__/scoped-approval-grants.test.ts +521 -0
- package/src/__tests__/scoped-grant-security-matrix.test.ts +444 -0
- package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
- package/src/__tests__/session-load-history-repair.test.ts +169 -2
- package/src/__tests__/session-runtime-assembly.test.ts +33 -5
- package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
- package/src/__tests__/skill-feature-flags.test.ts +188 -0
- package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
- package/src/__tests__/skill-mirror-parity.test.ts +1 -0
- package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
- package/src/__tests__/system-prompt.test.ts +1 -1
- package/src/__tests__/terminal-sandbox.test.ts +142 -9
- package/src/__tests__/terminal-tools.test.ts +2 -93
- package/src/__tests__/thread-seed-composer.test.ts +18 -0
- package/src/__tests__/tool-approval-handler.test.ts +350 -0
- package/src/__tests__/trust-store.test.ts +2 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +533 -0
- package/src/agent/loop.ts +36 -1
- package/src/approvals/approval-primitive.ts +381 -0
- package/src/approvals/guardian-decision-primitive.ts +191 -0
- package/src/calls/call-controller.ts +276 -212
- package/src/calls/call-domain.ts +56 -6
- package/src/calls/guardian-dispatch.ts +56 -0
- package/src/calls/relay-server.ts +13 -0
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +59 -4
- package/src/cli/core-commands.ts +0 -4
- package/src/cli.ts +76 -34
- package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
- package/src/config/assistant-feature-flags.ts +162 -0
- package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
- package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
- package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
- package/src/config/bundled-skills/notifications/SKILL.md +18 -0
- package/src/config/bundled-skills/reminder/SKILL.md +49 -2
- package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
- package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
- package/src/config/core-schema.ts +1 -1
- package/src/config/env-registry.ts +10 -0
- package/src/config/feature-flag-registry.json +61 -0
- package/src/config/loader.ts +22 -1
- package/src/config/sandbox-schema.ts +0 -39
- package/src/config/schema.ts +12 -2
- package/src/config/skill-state.ts +34 -0
- package/src/config/skills-schema.ts +26 -0
- package/src/config/skills.ts +9 -0
- package/src/config/system-prompt.ts +110 -46
- package/src/config/templates/SOUL.md +1 -1
- package/src/config/types.ts +19 -1
- package/src/config/vellum-skills/catalog.json +1 -1
- package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
- package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
- package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -1
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +104 -3
- package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
- package/src/daemon/config-watcher.ts +0 -1
- package/src/daemon/daemon-control.ts +1 -1
- package/src/daemon/guardian-invite-intent.ts +124 -0
- package/src/daemon/handlers/avatar.ts +68 -0
- package/src/daemon/handlers/browser.ts +2 -2
- package/src/daemon/handlers/config-channels.ts +18 -0
- package/src/daemon/handlers/guardian-actions.ts +120 -0
- package/src/daemon/handlers/index.ts +4 -0
- package/src/daemon/handlers/sessions.ts +19 -0
- package/src/daemon/handlers/shared.ts +3 -1
- package/src/daemon/handlers/skills.ts +45 -2
- package/src/daemon/install-cli-launchers.ts +58 -13
- package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
- package/src/daemon/ipc-contract/sessions.ts +8 -2
- package/src/daemon/ipc-contract/settings.ts +25 -2
- package/src/daemon/ipc-contract/skills.ts +1 -0
- package/src/daemon/ipc-contract-inventory.json +10 -0
- package/src/daemon/ipc-contract.ts +4 -0
- package/src/daemon/lifecycle.ts +6 -2
- package/src/daemon/main.ts +1 -0
- package/src/daemon/server.ts +1 -0
- package/src/daemon/session-lifecycle.ts +52 -7
- package/src/daemon/session-memory.ts +45 -0
- package/src/daemon/session-process.ts +260 -422
- package/src/daemon/session-runtime-assembly.ts +12 -0
- package/src/daemon/session-skill-tools.ts +14 -1
- package/src/daemon/session-tool-setup.ts +5 -0
- package/src/daemon/session.ts +11 -0
- package/src/daemon/tool-side-effects.ts +35 -9
- package/src/index.ts +0 -2
- package/src/memory/conversation-display-order-migration.ts +44 -0
- package/src/memory/conversation-queries.ts +2 -0
- package/src/memory/conversation-store.ts +91 -0
- package/src/memory/db-init.ts +13 -1
- package/src/memory/embedding-local.ts +22 -8
- package/src/memory/guardian-action-store.ts +133 -2
- package/src/memory/guardian-verification.ts +1 -1
- package/src/memory/ingress-invite-store.ts +95 -1
- package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
- package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
- package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
- package/src/memory/migrations/index.ts +3 -0
- package/src/memory/schema.ts +35 -1
- package/src/memory/scoped-approval-grants.ts +518 -0
- package/src/messaging/providers/slack/client.ts +12 -0
- package/src/messaging/providers/slack/types.ts +5 -0
- package/src/notifications/decision-engine.ts +49 -12
- package/src/notifications/emit-signal.ts +7 -0
- package/src/notifications/signal.ts +7 -0
- package/src/notifications/thread-seed-composer.ts +2 -1
- package/src/permissions/checker.ts +27 -0
- package/src/runtime/channel-approval-types.ts +16 -6
- package/src/runtime/channel-approvals.ts +19 -15
- package/src/runtime/channel-invite-transport.ts +85 -0
- package/src/runtime/channel-invite-transports/telegram.ts +105 -0
- package/src/runtime/guardian-action-grant-minter.ts +154 -0
- package/src/runtime/guardian-action-message-composer.ts +30 -0
- package/src/runtime/guardian-decision-types.ts +91 -0
- package/src/runtime/http-server.ts +23 -1
- package/src/runtime/ingress-service.ts +22 -0
- package/src/runtime/invite-redemption-service.ts +181 -0
- package/src/runtime/invite-redemption-templates.ts +39 -0
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/guardian-action-routes.ts +206 -0
- package/src/runtime/routes/guardian-approval-interception.ts +66 -74
- package/src/runtime/routes/inbound-message-handler.ts +568 -409
- package/src/runtime/routes/pairing-routes.ts +4 -0
- package/src/security/encrypted-store.ts +31 -17
- package/src/security/keychain.ts +176 -2
- package/src/security/secure-keys.ts +97 -0
- package/src/security/tool-approval-digest.ts +67 -0
- package/src/skills/remote-skill-policy.ts +131 -0
- package/src/tools/browser/browser-execution.ts +2 -2
- package/src/tools/browser/browser-manager.ts +46 -32
- package/src/tools/browser/browser-screencast.ts +2 -2
- package/src/tools/calls/call-start.ts +1 -1
- package/src/tools/executor.ts +22 -17
- package/src/tools/network/script-proxy/session-manager.ts +1 -5
- package/src/tools/skills/load.ts +22 -8
- package/src/tools/system/avatar-generator.ts +119 -0
- package/src/tools/system/navigate-settings.ts +65 -0
- package/src/tools/system/open-system-settings.ts +75 -0
- package/src/tools/system/voice-config.ts +121 -32
- package/src/tools/terminal/backends/native.ts +40 -19
- package/src/tools/terminal/backends/types.ts +3 -3
- package/src/tools/terminal/parser.ts +1 -1
- package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
- package/src/tools/terminal/sandbox.ts +1 -12
- package/src/tools/terminal/shell.ts +3 -31
- package/src/tools/tool-approval-handler.ts +141 -3
- package/src/tools/tool-manifest.ts +6 -0
- package/src/tools/types.ts +6 -0
- package/src/util/diff.ts +36 -13
- package/Dockerfile.sandbox +0 -5
- package/src/__tests__/doordash-client.test.ts +0 -187
- package/src/__tests__/doordash-session.test.ts +0 -154
- package/src/__tests__/signup-e2e.test.ts +0 -354
- package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
- package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
- package/src/cli/doordash.ts +0 -1057
- package/src/config/bundled-skills/doordash/SKILL.md +0 -163
- package/src/config/templates/LOOKS.md +0 -25
- package/src/doordash/cart-queries.ts +0 -787
- package/src/doordash/client.ts +0 -1016
- package/src/doordash/order-queries.ts +0 -85
- package/src/doordash/queries.ts +0 -13
- package/src/doordash/query-extractor.ts +0 -94
- package/src/doordash/search-queries.ts +0 -203
- package/src/doordash/session.ts +0 -84
- package/src/doordash/store-queries.ts +0 -246
- package/src/doordash/types.ts +0 -367
- package/src/tools/terminal/backends/docker.ts +0 -379
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for the inbound invite redemption intercept.
|
|
3
|
+
*
|
|
4
|
+
* Validates that non-members with valid `/start iv_<token>` payloads are
|
|
5
|
+
* granted access without guardian approval, and that invalid/expired/revoked
|
|
6
|
+
* tokens produce the correct deterministic refusal messages.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Test isolation: in-memory SQLite via temp directory
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
const testDir = mkdtempSync(join(tmpdir(), 'inbound-invite-redemption-test-'));
|
|
19
|
+
|
|
20
|
+
mock.module('../util/platform.js', () => ({
|
|
21
|
+
getRootDir: () => testDir,
|
|
22
|
+
getDataDir: () => testDir,
|
|
23
|
+
isMacOS: () => process.platform === 'darwin',
|
|
24
|
+
isLinux: () => process.platform === 'linux',
|
|
25
|
+
isWindows: () => process.platform === 'win32',
|
|
26
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
27
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
28
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
29
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
30
|
+
ensureDataDir: () => {},
|
|
31
|
+
normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
|
|
32
|
+
readHttpToken: () => 'test-bearer-token',
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
mock.module('../util/logger.js', () => ({
|
|
36
|
+
getLogger: () => new Proxy({} as Record<string, unknown>, {
|
|
37
|
+
get: () => () => {},
|
|
38
|
+
}),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
mock.module('../security/secret-ingress.js', () => ({
|
|
42
|
+
checkIngressForSecrets: () => ({ blocked: false }),
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
mock.module('../config/env.js', () => ({
|
|
46
|
+
getGatewayInternalBaseUrl: () => 'http://127.0.0.1:7830',
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
// Mock the credential metadata store so the Telegram transport adapter
|
|
50
|
+
// resolves without touching the filesystem.
|
|
51
|
+
mock.module('../tools/credentials/metadata-store.js', () => ({
|
|
52
|
+
getCredentialMetadata: () => undefined,
|
|
53
|
+
upsertCredentialMetadata: () => {},
|
|
54
|
+
deleteCredentialMetadata: () => {},
|
|
55
|
+
listCredentialMetadata: () => [],
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
const emitSignalCalls: Array<Record<string, unknown>> = [];
|
|
59
|
+
mock.module('../notifications/emit-signal.js', () => ({
|
|
60
|
+
emitNotificationSignal: async (params: Record<string, unknown>) => {
|
|
61
|
+
emitSignalCalls.push(params);
|
|
62
|
+
return {
|
|
63
|
+
signalId: 'mock-signal-id',
|
|
64
|
+
deduplicated: false,
|
|
65
|
+
dispatched: true,
|
|
66
|
+
reason: 'mock',
|
|
67
|
+
deliveryResults: [],
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
const deliverReplyCalls: Array<{ url: string; payload: Record<string, unknown> }> = [];
|
|
73
|
+
mock.module('../runtime/gateway-client.js', () => ({
|
|
74
|
+
deliverChannelReply: async (url: string, payload: Record<string, unknown>) => {
|
|
75
|
+
deliverReplyCalls.push({ url, payload });
|
|
76
|
+
},
|
|
77
|
+
}));
|
|
78
|
+
|
|
79
|
+
mock.module('../runtime/approval-message-composer.js', () => ({
|
|
80
|
+
composeApprovalMessage: () => 'mock approval message',
|
|
81
|
+
composeApprovalMessageGenerative: async () => 'mock generative message',
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
import { getDb, initializeDb, resetDb } from '../memory/db.js';
|
|
85
|
+
import { createInvite, revokeInvite } from '../memory/ingress-invite-store.js';
|
|
86
|
+
import { findMember, upsertMember } from '../memory/ingress-member-store.js';
|
|
87
|
+
import { handleChannelInbound } from '../runtime/routes/channel-routes.js';
|
|
88
|
+
|
|
89
|
+
initializeDb();
|
|
90
|
+
|
|
91
|
+
afterAll(() => {
|
|
92
|
+
resetDb();
|
|
93
|
+
try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Helpers
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
const TEST_BEARER_TOKEN = 'test-token';
|
|
101
|
+
let msgCounter = 0;
|
|
102
|
+
|
|
103
|
+
function resetState(): void {
|
|
104
|
+
const db = getDb();
|
|
105
|
+
db.run('DELETE FROM assistant_ingress_members');
|
|
106
|
+
db.run('DELETE FROM assistant_ingress_invites');
|
|
107
|
+
db.run('DELETE FROM channel_inbound_events');
|
|
108
|
+
db.run('DELETE FROM conversations');
|
|
109
|
+
db.run('DELETE FROM channel_guardian_approval_requests');
|
|
110
|
+
db.run('DELETE FROM channel_guardian_bindings');
|
|
111
|
+
db.run('DELETE FROM notification_events');
|
|
112
|
+
emitSignalCalls.length = 0;
|
|
113
|
+
deliverReplyCalls.length = 0;
|
|
114
|
+
msgCounter = 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function buildInboundRequest(overrides: Record<string, unknown> = {}): Request {
|
|
118
|
+
msgCounter++;
|
|
119
|
+
const body: Record<string, unknown> = {
|
|
120
|
+
sourceChannel: 'telegram',
|
|
121
|
+
interface: 'telegram',
|
|
122
|
+
externalChatId: 'chat-invite-test',
|
|
123
|
+
externalMessageId: `msg-invite-${Date.now()}-${msgCounter}`,
|
|
124
|
+
content: '/start iv_sometoken',
|
|
125
|
+
senderExternalUserId: 'user-invite-123',
|
|
126
|
+
senderName: 'Invite User',
|
|
127
|
+
senderUsername: 'invite_user',
|
|
128
|
+
replyCallbackUrl: 'http://localhost:7830/deliver/telegram',
|
|
129
|
+
sourceMetadata: {
|
|
130
|
+
commandIntent: { type: 'start', payload: 'iv_sometoken' },
|
|
131
|
+
},
|
|
132
|
+
...overrides,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
return new Request('http://localhost:8080/channels/inbound', {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: {
|
|
138
|
+
'Content-Type': 'application/json',
|
|
139
|
+
'X-Gateway-Origin': TEST_BEARER_TOKEN,
|
|
140
|
+
},
|
|
141
|
+
body: JSON.stringify(body),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Build a request with a specific invite token, using the structured
|
|
147
|
+
* commandIntent that the gateway produces for `/start <payload>`.
|
|
148
|
+
*/
|
|
149
|
+
function buildInviteRequest(rawToken: string, overrides: Record<string, unknown> = {}): Request {
|
|
150
|
+
return buildInboundRequest({
|
|
151
|
+
content: `/start iv_${rawToken}`,
|
|
152
|
+
sourceMetadata: {
|
|
153
|
+
commandIntent: { type: 'start', payload: `iv_${rawToken}` },
|
|
154
|
+
},
|
|
155
|
+
...overrides,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Tests
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
describe('inbound invite redemption intercept', () => {
|
|
164
|
+
beforeEach(resetState);
|
|
165
|
+
|
|
166
|
+
test('non-member with valid invite token becomes active member without guardian approval', async () => {
|
|
167
|
+
const { rawToken } = createInvite({ sourceChannel: 'telegram', maxUses: 5 });
|
|
168
|
+
|
|
169
|
+
const req = buildInviteRequest(rawToken);
|
|
170
|
+
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
171
|
+
const json = await resp.json() as Record<string, unknown>;
|
|
172
|
+
|
|
173
|
+
expect(json.accepted).toBe(true);
|
|
174
|
+
expect(json.inviteRedemption).toBe('redeemed');
|
|
175
|
+
expect(json.memberId).toEqual(expect.any(String));
|
|
176
|
+
expect(json.denied).toBeUndefined();
|
|
177
|
+
|
|
178
|
+
// Verify the user is now an active member
|
|
179
|
+
const member = findMember({
|
|
180
|
+
assistantId: 'self',
|
|
181
|
+
sourceChannel: 'telegram',
|
|
182
|
+
externalUserId: 'user-invite-123',
|
|
183
|
+
});
|
|
184
|
+
expect(member).not.toBeNull();
|
|
185
|
+
expect(member!.status).toBe('active');
|
|
186
|
+
|
|
187
|
+
// Verify a welcome reply was delivered
|
|
188
|
+
expect(deliverReplyCalls.length).toBe(1);
|
|
189
|
+
const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>).text;
|
|
190
|
+
expect(replyText).toContain("Welcome! You've been granted access via invite link.");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('non-member with invalid token gets refusal text', async () => {
|
|
194
|
+
const req = buildInviteRequest('completely-bogus-token-xyz');
|
|
195
|
+
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
196
|
+
const json = await resp.json() as Record<string, unknown>;
|
|
197
|
+
|
|
198
|
+
expect(json.accepted).toBe(true);
|
|
199
|
+
expect(json.denied).toBe(true);
|
|
200
|
+
expect(json.inviteRedemption).toBe('invalid_token');
|
|
201
|
+
|
|
202
|
+
// Verify refusal reply was delivered
|
|
203
|
+
expect(deliverReplyCalls.length).toBe(1);
|
|
204
|
+
const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>).text;
|
|
205
|
+
expect(replyText).toContain('no longer valid');
|
|
206
|
+
|
|
207
|
+
// Verify the user was NOT made a member
|
|
208
|
+
const member = findMember({
|
|
209
|
+
assistantId: 'self',
|
|
210
|
+
sourceChannel: 'telegram',
|
|
211
|
+
externalUserId: 'user-invite-123',
|
|
212
|
+
});
|
|
213
|
+
expect(member).toBeNull();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('non-member with expired token gets appropriate message', async () => {
|
|
217
|
+
const { rawToken } = createInvite({
|
|
218
|
+
sourceChannel: 'telegram',
|
|
219
|
+
maxUses: 1,
|
|
220
|
+
expiresInMs: -1, // already expired
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const req = buildInviteRequest(rawToken);
|
|
224
|
+
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
225
|
+
const json = await resp.json() as Record<string, unknown>;
|
|
226
|
+
|
|
227
|
+
expect(json.accepted).toBe(true);
|
|
228
|
+
expect(json.denied).toBe(true);
|
|
229
|
+
expect(json.inviteRedemption).toBe('expired');
|
|
230
|
+
|
|
231
|
+
expect(deliverReplyCalls.length).toBe(1);
|
|
232
|
+
const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>).text;
|
|
233
|
+
expect(replyText).toContain('no longer valid');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('non-member with revoked token gets refusal text', async () => {
|
|
237
|
+
const { rawToken, invite } = createInvite({
|
|
238
|
+
sourceChannel: 'telegram',
|
|
239
|
+
maxUses: 5,
|
|
240
|
+
});
|
|
241
|
+
revokeInvite(invite.id);
|
|
242
|
+
|
|
243
|
+
const req = buildInviteRequest(rawToken);
|
|
244
|
+
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
245
|
+
const json = await resp.json() as Record<string, unknown>;
|
|
246
|
+
|
|
247
|
+
expect(json.accepted).toBe(true);
|
|
248
|
+
expect(json.denied).toBe(true);
|
|
249
|
+
expect(json.inviteRedemption).toBe('revoked');
|
|
250
|
+
|
|
251
|
+
expect(deliverReplyCalls.length).toBe(1);
|
|
252
|
+
const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>).text;
|
|
253
|
+
expect(replyText).toContain('no longer valid');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('existing /start gv_<token> guardian bootstrap flow is unaffected', async () => {
|
|
257
|
+
// Send a /start gv_ command — should not be intercepted by the invite flow.
|
|
258
|
+
// Without a valid bootstrap session, it should be denied at the ACL gate.
|
|
259
|
+
const req = buildInboundRequest({
|
|
260
|
+
content: '/start gv_some_bootstrap_token',
|
|
261
|
+
sourceMetadata: {
|
|
262
|
+
commandIntent: { type: 'start', payload: 'gv_some_bootstrap_token' },
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
266
|
+
const json = await resp.json() as Record<string, unknown>;
|
|
267
|
+
|
|
268
|
+
// Should be denied as a non-member (bootstrap token is invalid/no session)
|
|
269
|
+
expect(json.denied).toBe(true);
|
|
270
|
+
expect(json.reason).toBe('not_a_member');
|
|
271
|
+
// Should NOT have invite redemption fields
|
|
272
|
+
expect(json.inviteRedemption).toBeUndefined();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('duplicate Telegram webhook deliveries do not double-redeem', async () => {
|
|
276
|
+
const { rawToken } = createInvite({ sourceChannel: 'telegram', maxUses: 5 });
|
|
277
|
+
|
|
278
|
+
const sharedMessageId = `msg-dedup-${Date.now()}`;
|
|
279
|
+
const makeReq = () => buildInviteRequest(rawToken, {
|
|
280
|
+
externalMessageId: sharedMessageId,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// First delivery
|
|
284
|
+
const resp1 = await handleChannelInbound(makeReq(), undefined, TEST_BEARER_TOKEN);
|
|
285
|
+
const json1 = await resp1.json() as Record<string, unknown>;
|
|
286
|
+
expect(json1.inviteRedemption).toBe('redeemed');
|
|
287
|
+
|
|
288
|
+
// Second delivery (duplicate webhook)
|
|
289
|
+
const resp2 = await handleChannelInbound(makeReq(), undefined, TEST_BEARER_TOKEN);
|
|
290
|
+
const json2 = await resp2.json() as Record<string, unknown>;
|
|
291
|
+
// Dedup kicks in — the message is treated as a duplicate and no second
|
|
292
|
+
// redemption attempt occurs.
|
|
293
|
+
expect(json2.duplicate).toBe(true);
|
|
294
|
+
|
|
295
|
+
// Only one welcome reply was delivered
|
|
296
|
+
expect(deliverReplyCalls.length).toBe(1);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('existing active member sending normal message is unaffected', async () => {
|
|
300
|
+
// Pre-create an active member
|
|
301
|
+
upsertMember({
|
|
302
|
+
assistantId: 'self',
|
|
303
|
+
sourceChannel: 'telegram',
|
|
304
|
+
externalUserId: 'user-active-member',
|
|
305
|
+
externalChatId: 'chat-active',
|
|
306
|
+
status: 'active',
|
|
307
|
+
policy: 'allow',
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// Active member sends a normal message (no invite token)
|
|
311
|
+
const req = buildInboundRequest({
|
|
312
|
+
content: 'Hello, just a normal message!',
|
|
313
|
+
senderExternalUserId: 'user-active-member',
|
|
314
|
+
externalChatId: 'chat-active',
|
|
315
|
+
sourceMetadata: {},
|
|
316
|
+
});
|
|
317
|
+
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
318
|
+
const json = await resp.json() as Record<string, unknown>;
|
|
319
|
+
|
|
320
|
+
// Should be accepted normally, not denied, not invite-redeemed
|
|
321
|
+
expect(json.accepted).toBe(true);
|
|
322
|
+
expect(json.denied).toBeUndefined();
|
|
323
|
+
expect(json.inviteRedemption).toBeUndefined();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test('channel mismatch returns appropriate message', async () => {
|
|
327
|
+
// Create an invite for SMS, but try to redeem via Telegram
|
|
328
|
+
const { rawToken } = createInvite({ sourceChannel: 'sms', maxUses: 5 });
|
|
329
|
+
|
|
330
|
+
const req = buildInviteRequest(rawToken);
|
|
331
|
+
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
332
|
+
const json = await resp.json() as Record<string, unknown>;
|
|
333
|
+
|
|
334
|
+
expect(json.accepted).toBe(true);
|
|
335
|
+
expect(json.denied).toBe(true);
|
|
336
|
+
expect(json.inviteRedemption).toBe('channel_mismatch');
|
|
337
|
+
|
|
338
|
+
expect(deliverReplyCalls.length).toBe(1);
|
|
339
|
+
const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>).text;
|
|
340
|
+
expect(replyText).toContain('not valid for this channel');
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('already-active member with invite token gets acknowledgement', async () => {
|
|
344
|
+
const { rawToken } = createInvite({ sourceChannel: 'telegram', maxUses: 5 });
|
|
345
|
+
|
|
346
|
+
// Pre-create an active member that will click the invite link
|
|
347
|
+
upsertMember({
|
|
348
|
+
assistantId: 'self',
|
|
349
|
+
sourceChannel: 'telegram',
|
|
350
|
+
externalUserId: 'user-already-active',
|
|
351
|
+
externalChatId: 'chat-invite-test',
|
|
352
|
+
status: 'active',
|
|
353
|
+
policy: 'allow',
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const req = buildInviteRequest(rawToken, {
|
|
357
|
+
senderExternalUserId: 'user-already-active',
|
|
358
|
+
});
|
|
359
|
+
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
360
|
+
const json = await resp.json() as Record<string, unknown>;
|
|
361
|
+
|
|
362
|
+
// Active members pass through the ACL gate, so the invite intercept
|
|
363
|
+
// does not fire. The message proceeds to normal processing.
|
|
364
|
+
expect(json.accepted).toBe(true);
|
|
365
|
+
expect(json.denied).toBeUndefined();
|
|
366
|
+
});
|
|
367
|
+
});
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
6
|
+
|
|
7
|
+
const testDir = mkdtempSync(join(tmpdir(), 'invite-redemption-service-test-'));
|
|
8
|
+
|
|
9
|
+
mock.module('../util/platform.js', () => ({
|
|
10
|
+
getDataDir: () => testDir,
|
|
11
|
+
isMacOS: () => process.platform === 'darwin',
|
|
12
|
+
isLinux: () => process.platform === 'linux',
|
|
13
|
+
isWindows: () => process.platform === 'win32',
|
|
14
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
15
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
16
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
17
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
18
|
+
ensureDataDir: () => {},
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
mock.module('../util/logger.js', () => ({
|
|
22
|
+
getLogger: () => new Proxy({} as Record<string, unknown>, {
|
|
23
|
+
get: () => () => {},
|
|
24
|
+
}),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
import { getSqlite, initializeDb, resetDb } from '../memory/db.js';
|
|
28
|
+
import { createInvite, revokeInvite as revokeStoreFn } from '../memory/ingress-invite-store.js';
|
|
29
|
+
import { upsertMember } from '../memory/ingress-member-store.js';
|
|
30
|
+
import { type InviteRedemptionOutcome,redeemInvite } from '../runtime/invite-redemption-service.js';
|
|
31
|
+
|
|
32
|
+
initializeDb();
|
|
33
|
+
|
|
34
|
+
afterAll(() => {
|
|
35
|
+
resetDb();
|
|
36
|
+
try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function resetTables() {
|
|
40
|
+
getSqlite().run('DELETE FROM assistant_ingress_members');
|
|
41
|
+
getSqlite().run('DELETE FROM assistant_ingress_invites');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('invite-redemption-service', () => {
|
|
45
|
+
beforeEach(resetTables);
|
|
46
|
+
|
|
47
|
+
test('redeems a valid invite and returns typed outcome', () => {
|
|
48
|
+
const { rawToken, invite } = createInvite({ sourceChannel: 'telegram', maxUses: 1 });
|
|
49
|
+
|
|
50
|
+
const outcome = redeemInvite({
|
|
51
|
+
rawToken,
|
|
52
|
+
sourceChannel: 'telegram',
|
|
53
|
+
externalUserId: 'user-1',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(outcome.ok).toBe(true);
|
|
57
|
+
expect(outcome).toEqual({
|
|
58
|
+
ok: true,
|
|
59
|
+
type: 'redeemed',
|
|
60
|
+
memberId: expect.any(String),
|
|
61
|
+
inviteId: invite.id,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('returns invalid_token for a bogus token', () => {
|
|
66
|
+
const outcome = redeemInvite({
|
|
67
|
+
rawToken: 'totally-bogus-token',
|
|
68
|
+
sourceChannel: 'telegram',
|
|
69
|
+
externalUserId: 'user-1',
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(outcome).toEqual({ ok: false, reason: 'invalid_token' });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('returns expired for an expired invite', () => {
|
|
76
|
+
// Create an invite that expired 1 ms ago
|
|
77
|
+
const { rawToken } = createInvite({
|
|
78
|
+
sourceChannel: 'telegram',
|
|
79
|
+
maxUses: 1,
|
|
80
|
+
expiresInMs: -1,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const outcome = redeemInvite({
|
|
84
|
+
rawToken,
|
|
85
|
+
sourceChannel: 'telegram',
|
|
86
|
+
externalUserId: 'user-1',
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(outcome).toEqual({ ok: false, reason: 'expired' });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('returns revoked for a revoked invite', () => {
|
|
93
|
+
const { rawToken, invite } = createInvite({
|
|
94
|
+
sourceChannel: 'telegram',
|
|
95
|
+
maxUses: 1,
|
|
96
|
+
});
|
|
97
|
+
revokeStoreFn(invite.id);
|
|
98
|
+
|
|
99
|
+
const outcome = redeemInvite({
|
|
100
|
+
rawToken,
|
|
101
|
+
sourceChannel: 'telegram',
|
|
102
|
+
externalUserId: 'user-1',
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(outcome).toEqual({ ok: false, reason: 'revoked' });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('returns max_uses_reached when invite is fully consumed', () => {
|
|
109
|
+
const { rawToken } = createInvite({ sourceChannel: 'telegram', maxUses: 1 });
|
|
110
|
+
|
|
111
|
+
// First redemption should succeed
|
|
112
|
+
const first = redeemInvite({
|
|
113
|
+
rawToken,
|
|
114
|
+
sourceChannel: 'telegram',
|
|
115
|
+
externalUserId: 'user-1',
|
|
116
|
+
});
|
|
117
|
+
expect(first.ok).toBe(true);
|
|
118
|
+
|
|
119
|
+
// Second attempt should fail — the invite is now fully redeemed
|
|
120
|
+
const second = redeemInvite({
|
|
121
|
+
rawToken,
|
|
122
|
+
sourceChannel: 'telegram',
|
|
123
|
+
externalUserId: 'user-2',
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(second).toEqual({ ok: false, reason: 'max_uses_reached' });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('returns channel_mismatch when redeeming on wrong channel', () => {
|
|
130
|
+
const { rawToken } = createInvite({ sourceChannel: 'telegram', maxUses: 1 });
|
|
131
|
+
|
|
132
|
+
const outcome = redeemInvite({
|
|
133
|
+
rawToken,
|
|
134
|
+
sourceChannel: 'sms',
|
|
135
|
+
externalUserId: 'user-1',
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(outcome).toEqual({ ok: false, reason: 'channel_mismatch' });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('returns missing_identity when no externalUserId or externalChatId', () => {
|
|
142
|
+
const { rawToken } = createInvite({ sourceChannel: 'telegram', maxUses: 1 });
|
|
143
|
+
|
|
144
|
+
const outcome = redeemInvite({
|
|
145
|
+
rawToken,
|
|
146
|
+
sourceChannel: 'telegram',
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(outcome).toEqual({ ok: false, reason: 'missing_identity' });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('returns already_member when user is already an active member', () => {
|
|
153
|
+
const { rawToken } = createInvite({ sourceChannel: 'telegram', maxUses: 5 });
|
|
154
|
+
|
|
155
|
+
// Pre-create an active member
|
|
156
|
+
upsertMember({
|
|
157
|
+
sourceChannel: 'telegram',
|
|
158
|
+
externalUserId: 'existing-user',
|
|
159
|
+
status: 'active',
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const outcome = redeemInvite({
|
|
163
|
+
rawToken,
|
|
164
|
+
sourceChannel: 'telegram',
|
|
165
|
+
externalUserId: 'existing-user',
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(outcome.ok).toBe(true);
|
|
169
|
+
expect((outcome as Extract<InviteRedemptionOutcome, { type: 'already_member' }>).type).toBe('already_member');
|
|
170
|
+
expect((outcome as Extract<InviteRedemptionOutcome, { type: 'already_member' }>).memberId).toEqual(expect.any(String));
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('returns invalid_token for a blocked member to avoid leaking membership status', () => {
|
|
174
|
+
const { rawToken } = createInvite({ sourceChannel: 'telegram', maxUses: 5 });
|
|
175
|
+
|
|
176
|
+
// Pre-create a blocked member — simulates a guardian-initiated block
|
|
177
|
+
upsertMember({
|
|
178
|
+
sourceChannel: 'telegram',
|
|
179
|
+
externalUserId: 'blocked-user',
|
|
180
|
+
status: 'blocked',
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const outcome = redeemInvite({
|
|
184
|
+
rawToken,
|
|
185
|
+
sourceChannel: 'telegram',
|
|
186
|
+
externalUserId: 'blocked-user',
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(outcome).toEqual({ ok: false, reason: 'invalid_token' });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('does not return already_member for a revoked member', () => {
|
|
193
|
+
const { rawToken } = createInvite({ sourceChannel: 'telegram', maxUses: 5 });
|
|
194
|
+
|
|
195
|
+
// Pre-create a revoked member
|
|
196
|
+
const member = upsertMember({
|
|
197
|
+
sourceChannel: 'telegram',
|
|
198
|
+
externalUserId: 'revoked-user',
|
|
199
|
+
status: 'revoked',
|
|
200
|
+
});
|
|
201
|
+
expect(member.status).toBe('revoked');
|
|
202
|
+
|
|
203
|
+
const outcome = redeemInvite({
|
|
204
|
+
rawToken,
|
|
205
|
+
sourceChannel: 'telegram',
|
|
206
|
+
externalUserId: 'revoked-user',
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Should redeem, not return already_member
|
|
210
|
+
expect(outcome.ok).toBe(true);
|
|
211
|
+
expect((outcome as Extract<InviteRedemptionOutcome, { type: 'redeemed' }>).type).toBe('redeemed');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('raw token is not present in the outcome object', () => {
|
|
215
|
+
const { rawToken } = createInvite({ sourceChannel: 'telegram', maxUses: 1 });
|
|
216
|
+
|
|
217
|
+
const outcome = redeemInvite({
|
|
218
|
+
rawToken,
|
|
219
|
+
sourceChannel: 'telegram',
|
|
220
|
+
externalUserId: 'user-1',
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Verify the raw token does not appear anywhere in the serialized outcome
|
|
224
|
+
const serialized = JSON.stringify(outcome);
|
|
225
|
+
expect(serialized).not.toContain(rawToken);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('channel enforcement blocks cross-channel redemption (voice invite via slack)', () => {
|
|
229
|
+
const { rawToken } = createInvite({ sourceChannel: 'voice', maxUses: 1 });
|
|
230
|
+
|
|
231
|
+
const outcome = redeemInvite({
|
|
232
|
+
rawToken,
|
|
233
|
+
sourceChannel: 'slack',
|
|
234
|
+
externalUserId: 'user-1',
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
expect(outcome).toEqual({ ok: false, reason: 'channel_mismatch' });
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('returns invalid_token for an active member with a bogus token (no membership probing)', () => {
|
|
241
|
+
// Pre-create an active member
|
|
242
|
+
upsertMember({
|
|
243
|
+
sourceChannel: 'telegram',
|
|
244
|
+
externalUserId: 'probed-user',
|
|
245
|
+
status: 'active',
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Attempt to redeem with a bogus token — must NOT leak membership status
|
|
249
|
+
const outcome = redeemInvite({
|
|
250
|
+
rawToken: 'completely-bogus-token',
|
|
251
|
+
sourceChannel: 'telegram',
|
|
252
|
+
externalUserId: 'probed-user',
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
expect(outcome).toEqual({ ok: false, reason: 'invalid_token' });
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('returns expired for an active member with an expired invite token', () => {
|
|
259
|
+
// Create an expired invite
|
|
260
|
+
const { rawToken } = createInvite({
|
|
261
|
+
sourceChannel: 'telegram',
|
|
262
|
+
maxUses: 5,
|
|
263
|
+
expiresInMs: -1,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Pre-create an active member
|
|
267
|
+
upsertMember({
|
|
268
|
+
sourceChannel: 'telegram',
|
|
269
|
+
externalUserId: 'expired-token-user',
|
|
270
|
+
status: 'active',
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// Expired token must return expired, not already_member
|
|
274
|
+
const outcome = redeemInvite({
|
|
275
|
+
rawToken,
|
|
276
|
+
sourceChannel: 'telegram',
|
|
277
|
+
externalUserId: 'expired-token-user',
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
expect(outcome).toEqual({ ok: false, reason: 'expired' });
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('returns channel_mismatch for an active member with a valid token for a different channel', () => {
|
|
284
|
+
// Create an invite for SMS
|
|
285
|
+
const { rawToken } = createInvite({
|
|
286
|
+
sourceChannel: 'sms',
|
|
287
|
+
maxUses: 5,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// Pre-create an active member on telegram
|
|
291
|
+
upsertMember({
|
|
292
|
+
sourceChannel: 'telegram',
|
|
293
|
+
externalUserId: 'cross-channel-user',
|
|
294
|
+
status: 'active',
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// Valid token for wrong channel must return channel_mismatch, not already_member
|
|
298
|
+
const outcome = redeemInvite({
|
|
299
|
+
rawToken,
|
|
300
|
+
sourceChannel: 'telegram',
|
|
301
|
+
externalUserId: 'cross-channel-user',
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
expect(outcome).toEqual({ ok: false, reason: 'channel_mismatch' });
|
|
305
|
+
});
|
|
306
|
+
});
|