@desplega.ai/agent-swarm 1.80.2 → 1.81.0
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/README.md +3 -0
- package/openapi.json +486 -29
- package/package.json +3 -3
- package/plugin/commands/user-management.md +85 -46
- package/plugin/pi-skills/user-management/SKILL.md +85 -46
- package/src/agentmail/handlers.ts +25 -3
- package/src/agentmail/types.ts +1 -0
- package/src/be/db.ts +33 -109
- package/src/be/migrations/067_users_first_class.sql +185 -0
- package/src/be/migrations/068_profile_changed_event_type.sql +56 -0
- package/src/be/unmapped-identities.ts +98 -0
- package/src/be/users.ts +531 -0
- package/src/github/handlers.ts +67 -7
- package/src/gitlab/handlers.ts +73 -5
- package/src/http/operator-actor.ts +59 -0
- package/src/http/users.ts +611 -21
- package/src/http/webhooks.ts +9 -0
- package/src/http/workflows.ts +2 -15
- package/src/linear/oauth.ts +61 -1
- package/src/linear/sync.ts +134 -21
- package/src/slack/actions.ts +8 -2
- package/src/slack/assistant.ts +12 -9
- package/src/slack/enrich.ts +162 -0
- package/src/slack/handlers.ts +11 -19
- package/src/tests/agentmail-handlers.test.ts +166 -0
- package/src/tests/github-handlers.test.ts +290 -0
- package/src/tests/gitlab-handlers.test.ts +293 -1
- package/src/tests/http-api-integration.test.ts +8 -4
- package/src/tests/http-users.test.ts +605 -0
- package/src/tests/linear-sync-identity.test.ts +427 -0
- package/src/tests/mcp-tools-user.test.ts +292 -0
- package/src/tests/slack-identity-resolution.test.ts +349 -0
- package/src/tests/user-identity.test.ts +351 -81
- package/src/tests/workflow-triggers-v2.test.ts +261 -20
- package/src/tools/manage-user.ts +119 -24
- package/src/tools/resolve-user.ts +43 -29
- package/src/types.ts +26 -4
- package/src/utils/secret-scrubber.ts +5 -0
- package/src/workflows/input.ts +7 -2
- package/src/workflows/triggers.ts +89 -9
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
import { closeDb, createAgent, createUser, getDb, initDb } from "../be/db";
|
|
6
|
+
import { getUserIdentities, type IdentityActor, linkIdentity } from "../be/users";
|
|
7
|
+
import { registerManageUserTool } from "../tools/manage-user";
|
|
8
|
+
import { registerResolveUserTool, resolveUserInputSchema } from "../tools/resolve-user";
|
|
9
|
+
|
|
10
|
+
const TEST_DB_PATH = "./test-mcp-tools-user.sqlite";
|
|
11
|
+
|
|
12
|
+
const LEAD_ID = "11111111-1111-4111-8111-111111111111";
|
|
13
|
+
const WORKER_ID = "22222222-2222-4222-8222-222222222222";
|
|
14
|
+
|
|
15
|
+
const SYSTEM_ACTOR: IdentityActor = { kind: "system", id: "test" };
|
|
16
|
+
|
|
17
|
+
type RegisteredTool = {
|
|
18
|
+
handler: (args: unknown, extra: unknown) => Promise<CallToolResult>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Invoke a registered MCP tool's handler directly. Mirrors the test pattern
|
|
23
|
+
* used by `src/tests/update-profile-auth.test.ts`.
|
|
24
|
+
*/
|
|
25
|
+
async function callTool(
|
|
26
|
+
server: McpServer,
|
|
27
|
+
name: string,
|
|
28
|
+
args: Record<string, unknown>,
|
|
29
|
+
callerAgentId: string = LEAD_ID,
|
|
30
|
+
): Promise<CallToolResult> {
|
|
31
|
+
const tools = (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
|
|
32
|
+
._registeredTools;
|
|
33
|
+
const tool = tools[name];
|
|
34
|
+
if (!tool) throw new Error(`tool ${name} not registered`);
|
|
35
|
+
const extra = {
|
|
36
|
+
sessionId: "test-session",
|
|
37
|
+
requestInfo: { headers: { "x-agent-id": callerAgentId } },
|
|
38
|
+
};
|
|
39
|
+
return tool.handler(args, extra);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function textOf(result: CallToolResult): string {
|
|
43
|
+
const first = result.content?.[0];
|
|
44
|
+
if (first && first.type === "text") return first.text;
|
|
45
|
+
return "";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function eventsFor(userId: string): Array<{ eventType: string; afterJson: string | null }> {
|
|
49
|
+
return getDb()
|
|
50
|
+
.prepare<{ eventType: string; afterJson: string | null }, string>(
|
|
51
|
+
"SELECT eventType, afterJson FROM user_identity_events WHERE userId = ? ORDER BY createdAt ASC, rowid ASC",
|
|
52
|
+
)
|
|
53
|
+
.all(userId);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
beforeAll(async () => {
|
|
57
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
58
|
+
try {
|
|
59
|
+
await unlink(TEST_DB_PATH + suffix);
|
|
60
|
+
} catch {}
|
|
61
|
+
}
|
|
62
|
+
closeDb();
|
|
63
|
+
initDb(TEST_DB_PATH);
|
|
64
|
+
createAgent({ id: LEAD_ID, name: "Test Lead", isLead: true, status: "idle" });
|
|
65
|
+
createAgent({ id: WORKER_ID, name: "Test Worker", isLead: false, status: "idle" });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterAll(async () => {
|
|
69
|
+
closeDb();
|
|
70
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
71
|
+
try {
|
|
72
|
+
await unlink(TEST_DB_PATH + suffix);
|
|
73
|
+
} catch {}
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("resolve-user MCP tool (new {kind, externalId, email} shape)", () => {
|
|
78
|
+
const server = new McpServer({ name: "test-resolve-user", version: "1.0.0" });
|
|
79
|
+
registerResolveUserTool(server);
|
|
80
|
+
|
|
81
|
+
test("matches by (kind, externalId) → findUserByExternalId hit", async () => {
|
|
82
|
+
const user = createUser({ name: "Slack User One", email: "one@example.com" });
|
|
83
|
+
linkIdentity(user.id, "slack", "U_SLACK_ONE", SYSTEM_ACTOR);
|
|
84
|
+
|
|
85
|
+
const result = await callTool(server, "resolve-user", {
|
|
86
|
+
kind: "slack",
|
|
87
|
+
externalId: "U_SLACK_ONE",
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const text = textOf(result);
|
|
91
|
+
expect(text).toContain(user.id);
|
|
92
|
+
expect(text).toContain("Slack User One");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("matches by email → findUserByEmail hit (primary + alias)", async () => {
|
|
96
|
+
const user = createUser({
|
|
97
|
+
name: "Email User",
|
|
98
|
+
email: "primary@example.com",
|
|
99
|
+
emailAliases: ["alias@example.com"],
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const byPrimary = await callTool(server, "resolve-user", { email: "primary@example.com" });
|
|
103
|
+
expect(textOf(byPrimary)).toContain(user.id);
|
|
104
|
+
|
|
105
|
+
const byAlias = await callTool(server, "resolve-user", { email: "alias@example.com" });
|
|
106
|
+
expect(textOf(byAlias)).toContain(user.id);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("returns 'No user found' when nothing matches", async () => {
|
|
110
|
+
const result = await callTool(server, "resolve-user", {
|
|
111
|
+
kind: "slack",
|
|
112
|
+
externalId: "U_DOES_NOT_EXIST",
|
|
113
|
+
});
|
|
114
|
+
expect(textOf(result)).toContain("No user found");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Schema-level validation tests. The MCP SDK runs Zod at the transport
|
|
118
|
+
// layer; calling the registered handler directly bypasses it. We test the
|
|
119
|
+
// schema directly to confirm the contract MCP clients see at the wire.
|
|
120
|
+
|
|
121
|
+
test("old shape {slackUserId: ...} fails Zod validation (unrecognized keys via .strict)", () => {
|
|
122
|
+
const parsed = resolveUserInputSchema.safeParse({ slackUserId: "U_X" });
|
|
123
|
+
expect(parsed.success).toBe(false);
|
|
124
|
+
if (!parsed.success) {
|
|
125
|
+
const msg = JSON.stringify(parsed.error.issues);
|
|
126
|
+
expect(msg.toLowerCase()).toMatch(/unrecognized|extra|invalid/);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("empty input {} fails the refine constraint", () => {
|
|
131
|
+
const parsed = resolveUserInputSchema.safeParse({});
|
|
132
|
+
expect(parsed.success).toBe(false);
|
|
133
|
+
if (!parsed.success) {
|
|
134
|
+
const msg = JSON.stringify(parsed.error.issues);
|
|
135
|
+
expect(msg).toMatch(/Provide either \(kind \+ externalId\) or email/);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("name parameter (old shape) is rejected", () => {
|
|
140
|
+
const parsed = resolveUserInputSchema.safeParse({ name: "Whoever" });
|
|
141
|
+
expect(parsed.success).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("partial input (kind only, no externalId) fails the refine constraint", () => {
|
|
145
|
+
const parsed = resolveUserInputSchema.safeParse({ kind: "slack" });
|
|
146
|
+
expect(parsed.success).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("valid {kind, externalId} input passes the schema", () => {
|
|
150
|
+
const parsed = resolveUserInputSchema.safeParse({
|
|
151
|
+
kind: "slack",
|
|
152
|
+
externalId: "U_X",
|
|
153
|
+
});
|
|
154
|
+
expect(parsed.success).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("valid {email} input passes the schema", () => {
|
|
158
|
+
const parsed = resolveUserInputSchema.safeParse({ email: "x@example.com" });
|
|
159
|
+
expect(parsed.success).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("manage-user MCP tool (identities array)", () => {
|
|
164
|
+
const server = new McpServer({ name: "test-manage-user", version: "1.0.0" });
|
|
165
|
+
registerManageUserTool(server);
|
|
166
|
+
|
|
167
|
+
test("create with identities[] → user created + linkIdentity per entry + identity_added events", async () => {
|
|
168
|
+
const result = await callTool(server, "manage-user", {
|
|
169
|
+
action: "create",
|
|
170
|
+
name: "Identities Create",
|
|
171
|
+
email: "ic@example.com",
|
|
172
|
+
identities: [
|
|
173
|
+
{ kind: "slack", externalId: "U_IC" },
|
|
174
|
+
{ kind: "linear", externalId: "L_IC" },
|
|
175
|
+
],
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const text = textOf(result);
|
|
179
|
+
expect(text).toContain("User created:");
|
|
180
|
+
const match = text.match(/"id":\s*"([^"]+)"/);
|
|
181
|
+
expect(match).not.toBeNull();
|
|
182
|
+
const userId = match![1];
|
|
183
|
+
|
|
184
|
+
// Verify identities are linked.
|
|
185
|
+
const idents = getUserIdentities(userId);
|
|
186
|
+
expect(idents).toHaveLength(2);
|
|
187
|
+
const kinds = idents.map((i) => `${i.kind}:${i.externalId}`).sort();
|
|
188
|
+
expect(kinds).toEqual(["linear:L_IC", "slack:U_IC"]);
|
|
189
|
+
|
|
190
|
+
// Two `identity_added` events were emitted via linkIdentity().
|
|
191
|
+
const events = eventsFor(userId);
|
|
192
|
+
const added = events.filter((e) => e.eventType === "identity_added");
|
|
193
|
+
expect(added).toHaveLength(2);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("update with identities diff → add one + remove one emits correct events", async () => {
|
|
197
|
+
const created = await callTool(server, "manage-user", {
|
|
198
|
+
action: "create",
|
|
199
|
+
name: "Identities Diff",
|
|
200
|
+
identities: [{ kind: "slack", externalId: "U_DIFF" }],
|
|
201
|
+
});
|
|
202
|
+
const userId = textOf(created).match(/"id":\s*"([^"]+)"/)![1];
|
|
203
|
+
const baselineEventCount = eventsFor(userId).length;
|
|
204
|
+
|
|
205
|
+
// Now update: keep slack, drop nothing yet — desired set has slack + add github.
|
|
206
|
+
await callTool(server, "manage-user", {
|
|
207
|
+
action: "update",
|
|
208
|
+
userId,
|
|
209
|
+
identities: [
|
|
210
|
+
{ kind: "slack", externalId: "U_DIFF" },
|
|
211
|
+
{ kind: "github", externalId: "gh_diff" },
|
|
212
|
+
],
|
|
213
|
+
});
|
|
214
|
+
let idents = getUserIdentities(userId);
|
|
215
|
+
expect(idents.map((i) => `${i.kind}:${i.externalId}`).sort()).toEqual([
|
|
216
|
+
"github:gh_diff",
|
|
217
|
+
"slack:U_DIFF",
|
|
218
|
+
]);
|
|
219
|
+
|
|
220
|
+
// Next update: drop slack, keep github. Diff = remove slack.
|
|
221
|
+
await callTool(server, "manage-user", {
|
|
222
|
+
action: "update",
|
|
223
|
+
userId,
|
|
224
|
+
identities: [{ kind: "github", externalId: "gh_diff" }],
|
|
225
|
+
});
|
|
226
|
+
idents = getUserIdentities(userId);
|
|
227
|
+
expect(idents.map((i) => `${i.kind}:${i.externalId}`)).toEqual(["github:gh_diff"]);
|
|
228
|
+
|
|
229
|
+
const events = eventsFor(userId).slice(baselineEventCount);
|
|
230
|
+
const added = events.filter((e) => e.eventType === "identity_added");
|
|
231
|
+
const removed = events.filter((e) => e.eventType === "identity_removed");
|
|
232
|
+
// First update: added github. Second update: removed slack.
|
|
233
|
+
expect(added).toHaveLength(1);
|
|
234
|
+
expect(removed).toHaveLength(1);
|
|
235
|
+
expect(added[0]!.afterJson).toContain("github");
|
|
236
|
+
expect(removed[0]!.afterJson).toBeNull();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("update with emailAliases diff emits email_added / email_removed", async () => {
|
|
240
|
+
const created = await callTool(server, "manage-user", {
|
|
241
|
+
action: "create",
|
|
242
|
+
name: "Alias Diff",
|
|
243
|
+
email: "ad@example.com",
|
|
244
|
+
emailAliases: ["a@example.com"],
|
|
245
|
+
});
|
|
246
|
+
const userId = textOf(created).match(/"id":\s*"([^"]+)"/)![1];
|
|
247
|
+
const baselineEventCount = eventsFor(userId).length;
|
|
248
|
+
|
|
249
|
+
// Update aliases: remove a@, add b@.
|
|
250
|
+
await callTool(server, "manage-user", {
|
|
251
|
+
action: "update",
|
|
252
|
+
userId,
|
|
253
|
+
emailAliases: ["b@example.com"],
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const events = eventsFor(userId).slice(baselineEventCount);
|
|
257
|
+
const added = events.filter((e) => e.eventType === "email_added");
|
|
258
|
+
const removed = events.filter((e) => e.eventType === "email_removed");
|
|
259
|
+
expect(added).toHaveLength(1);
|
|
260
|
+
expect(removed).toHaveLength(1);
|
|
261
|
+
expect(added[0]!.afterJson).toContain("b@example.com");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("non-lead caller is rejected", async () => {
|
|
265
|
+
const result = await callTool(server, "manage-user", { action: "list" }, WORKER_ID);
|
|
266
|
+
expect(textOf(result)).toContain("Only the lead agent");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("create no longer accepts old top-level slackUserId / linearUserId / githubUsername / gitlabUsername", async () => {
|
|
270
|
+
// The schema is `z.object({...})` without `.strict()` on manage-user
|
|
271
|
+
// (preserving forward-compat headroom for future fields). But the dropped
|
|
272
|
+
// identity fields are explicitly not in the schema — they are silently
|
|
273
|
+
// ignored, NOT routed to the dropped DB columns. Verify behaviour:
|
|
274
|
+
// passing them as extra keys does not affect the created user.
|
|
275
|
+
const result = await callTool(server, "manage-user", {
|
|
276
|
+
action: "create",
|
|
277
|
+
name: "Legacy Shape User",
|
|
278
|
+
slackUserId: "U_LEGACY",
|
|
279
|
+
linearUserId: "L_LEGACY",
|
|
280
|
+
githubUsername: "gh_legacy",
|
|
281
|
+
gitlabUsername: "gl_legacy",
|
|
282
|
+
});
|
|
283
|
+
const text = textOf(result);
|
|
284
|
+
expect(text).toContain("User created:");
|
|
285
|
+
const userId = text.match(/"id":\s*"([^"]+)"/)![1];
|
|
286
|
+
|
|
287
|
+
// None of the legacy fields should have been turned into linked identities
|
|
288
|
+
// — the new shape REQUIRES `identities` array to link.
|
|
289
|
+
const idents = getUserIdentities(userId);
|
|
290
|
+
expect(idents).toHaveLength(0);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step-2 verification: the Slack identity resolution cascade.
|
|
3
|
+
*
|
|
4
|
+
* Covers — without spinning up Bolt — the three behavioural slices that
|
|
5
|
+
* matter for the kv-backed enrichment + unmapped tracker rewire:
|
|
6
|
+
*
|
|
7
|
+
* 1. Cascade for a NEW Slack user with an email — creates a `users` row,
|
|
8
|
+
* writes a `user_external_ids` row, emits `identity_added` events.
|
|
9
|
+
* 2. Cascade for the SAME user a second time — fast-path through
|
|
10
|
+
* `findUserByExternalId`; no duplicate row, no extra `client.users.info`.
|
|
11
|
+
* 3. Cascade for a Slack user without an email — writes two kv rows under
|
|
12
|
+
* `integration:unmapped:slack` (`<U>:meta` JSON + `<U>:count` integer).
|
|
13
|
+
* Second sighting bumps count to 2 and refreshes the meta TTL.
|
|
14
|
+
* 4. `enrichSlackUserEmail` does NOT cache failures (null email → next
|
|
15
|
+
* call hits the API again).
|
|
16
|
+
* 5. `enrichSlackUserEmail` DOES cache successes for 24h (next call is a
|
|
17
|
+
* kv hit; `client.users.info` is not called).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
21
|
+
import { unlinkSync } from "node:fs";
|
|
22
|
+
import { closeDb, getDb, getKv, initDb } from "../be/db";
|
|
23
|
+
import { findUserByExternalId, getUserIdentities } from "../be/users";
|
|
24
|
+
import { enrichSlackUserEmail, resolveSlackUserId } from "../slack/enrich";
|
|
25
|
+
|
|
26
|
+
const TEST_DB_PATH = "./test-slack-identity-resolution.sqlite";
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Mock Slack WebClient
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
interface MockUsersInfoResponse {
|
|
33
|
+
user?: {
|
|
34
|
+
real_name?: string;
|
|
35
|
+
profile?: {
|
|
36
|
+
email?: string;
|
|
37
|
+
real_name?: string;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Minimal WebClient stub — only the `users.info` method is exercised by the
|
|
44
|
+
* code under test. Counts calls per user so cache-hit assertions can be
|
|
45
|
+
* unambiguous.
|
|
46
|
+
*/
|
|
47
|
+
function makeMockClient(byUserId: Record<string, MockUsersInfoResponse | "throw">) {
|
|
48
|
+
const callCounts: Record<string, number> = {};
|
|
49
|
+
const client = {
|
|
50
|
+
users: {
|
|
51
|
+
info: async ({ user }: { user: string }) => {
|
|
52
|
+
callCounts[user] = (callCounts[user] ?? 0) + 1;
|
|
53
|
+
const fixture = byUserId[user];
|
|
54
|
+
if (fixture === "throw") {
|
|
55
|
+
throw new Error(`Mock client.users.info(${user}) threw`);
|
|
56
|
+
}
|
|
57
|
+
if (!fixture) {
|
|
58
|
+
throw new Error(`No fixture for user ${user}`);
|
|
59
|
+
}
|
|
60
|
+
return fixture;
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
return {
|
|
65
|
+
// biome-ignore lint/suspicious/noExplicitAny: shape-matched to WebClient's users.info call site.
|
|
66
|
+
client: client as any,
|
|
67
|
+
callCounts,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// DB lifecycle
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
function cleanupDb() {
|
|
76
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
77
|
+
try {
|
|
78
|
+
unlinkSync(`${TEST_DB_PATH}${suffix}`);
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
beforeAll(() => {
|
|
84
|
+
cleanupDb();
|
|
85
|
+
initDb(TEST_DB_PATH);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
afterAll(() => {
|
|
89
|
+
closeDb();
|
|
90
|
+
cleanupDb();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
// Wipe state between tests — full isolation. Identity-related tables only;
|
|
95
|
+
// leave the schema and seeded singletons untouched.
|
|
96
|
+
const db = getDb();
|
|
97
|
+
db.exec("DELETE FROM user_identity_events");
|
|
98
|
+
db.exec("DELETE FROM user_external_ids");
|
|
99
|
+
db.exec("DELETE FROM users");
|
|
100
|
+
db.exec("DELETE FROM kv_entries WHERE namespace LIKE 'integration:%'");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
function countUsers(): number {
|
|
104
|
+
return getDb().prepare<{ n: number }, []>("SELECT COUNT(*) AS n FROM users").get()?.n ?? 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function externalIdRows(): Array<{ kind: string; externalId: string; userId: string }> {
|
|
108
|
+
return getDb()
|
|
109
|
+
.prepare<{ kind: string; externalId: string; userId: string }, []>(
|
|
110
|
+
"SELECT kind, externalId, userId FROM user_external_ids ORDER BY kind, externalId",
|
|
111
|
+
)
|
|
112
|
+
.all();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function identityEventTypes(userId: string): string[] {
|
|
116
|
+
return getDb()
|
|
117
|
+
.prepare<{ eventType: string }, string>(
|
|
118
|
+
"SELECT eventType FROM user_identity_events WHERE userId = ? ORDER BY createdAt ASC, rowid ASC",
|
|
119
|
+
)
|
|
120
|
+
.all(userId)
|
|
121
|
+
.map((r) => r.eventType);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Tests
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
describe("resolveSlackUserId — three-step cascade", () => {
|
|
129
|
+
test("NEW user with email → creates users + external_ids + emits identity_added", async () => {
|
|
130
|
+
const { client } = makeMockClient({
|
|
131
|
+
U_HUMAN: {
|
|
132
|
+
user: {
|
|
133
|
+
real_name: "Real Human",
|
|
134
|
+
profile: { email: "real@example.com", real_name: "Real Human" },
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const userId = await resolveSlackUserId(client, "U_HUMAN", {
|
|
140
|
+
sampleEventType: "message",
|
|
141
|
+
sampleContext: "hello swarm",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(userId).toBeDefined();
|
|
145
|
+
expect(countUsers()).toBe(1);
|
|
146
|
+
|
|
147
|
+
const ext = externalIdRows();
|
|
148
|
+
expect(ext).toHaveLength(1);
|
|
149
|
+
expect(ext[0]).toMatchObject({ kind: "slack", externalId: "U_HUMAN", userId });
|
|
150
|
+
|
|
151
|
+
const events = identityEventTypes(userId!);
|
|
152
|
+
// Brand-new email triggers two `identity_added` events:
|
|
153
|
+
// * `findOrCreateUserByEmail` creates the user → `identity_added`
|
|
154
|
+
// * `linkIdentity('slack', ...)` adds the alias → `identity_added`
|
|
155
|
+
expect(events).toEqual(["identity_added", "identity_added"]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("SAME user again → fast path, no duplicate users / external_ids / API call", async () => {
|
|
159
|
+
const { client, callCounts } = makeMockClient({
|
|
160
|
+
U_HUMAN: {
|
|
161
|
+
user: {
|
|
162
|
+
real_name: "Real Human",
|
|
163
|
+
profile: { email: "real@example.com", real_name: "Real Human" },
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const first = await resolveSlackUserId(client, "U_HUMAN", {
|
|
169
|
+
sampleEventType: "message",
|
|
170
|
+
sampleContext: "hello swarm",
|
|
171
|
+
});
|
|
172
|
+
const second = await resolveSlackUserId(client, "U_HUMAN", {
|
|
173
|
+
sampleEventType: "message",
|
|
174
|
+
sampleContext: "again",
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(second).toBe(first);
|
|
178
|
+
expect(countUsers()).toBe(1);
|
|
179
|
+
expect(externalIdRows()).toHaveLength(1);
|
|
180
|
+
// The second call must hit the alias fast path — `client.users.info` was
|
|
181
|
+
// called exactly once (on the first lookup).
|
|
182
|
+
expect(callCounts.U_HUMAN).toBe(1);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("NEW user with NO email → writes :meta + :count kv rows under integration:unmapped:slack", async () => {
|
|
186
|
+
const { client } = makeMockClient({
|
|
187
|
+
U_BOT: {
|
|
188
|
+
user: {
|
|
189
|
+
real_name: "Bot Account",
|
|
190
|
+
profile: {}, // no email
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const userId = await resolveSlackUserId(client, "U_BOT", {
|
|
196
|
+
sampleEventType: "message",
|
|
197
|
+
sampleContext: "noisy bot ping",
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(userId).toBeUndefined();
|
|
201
|
+
expect(countUsers()).toBe(0);
|
|
202
|
+
expect(externalIdRows()).toHaveLength(0);
|
|
203
|
+
|
|
204
|
+
const meta = getKv("integration:unmapped:slack", "U_BOT:meta");
|
|
205
|
+
expect(meta).not.toBeNull();
|
|
206
|
+
expect(meta!.valueType).toBe("json");
|
|
207
|
+
const metaValue = meta!.value as {
|
|
208
|
+
sampleEventType: string;
|
|
209
|
+
sampleContext: string;
|
|
210
|
+
lastSeenAt: string;
|
|
211
|
+
};
|
|
212
|
+
expect(metaValue.sampleEventType).toBe("message");
|
|
213
|
+
expect(metaValue.sampleContext).toBe("noisy bot ping");
|
|
214
|
+
expect(metaValue.lastSeenAt).toBeTruthy();
|
|
215
|
+
expect(meta!.expiresAt).not.toBeNull();
|
|
216
|
+
|
|
217
|
+
const count = getKv("integration:unmapped:slack", "U_BOT:count");
|
|
218
|
+
expect(count).not.toBeNull();
|
|
219
|
+
expect(count!.valueType).toBe("integer");
|
|
220
|
+
expect(count!.value).toBe(1);
|
|
221
|
+
expect(count!.expiresAt).not.toBeNull();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("NO-email user seen twice → count goes to 2, meta upserted", async () => {
|
|
225
|
+
const { client } = makeMockClient({
|
|
226
|
+
U_BOT: { user: { profile: {} } },
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
await resolveSlackUserId(client, "U_BOT", {
|
|
230
|
+
sampleEventType: "message",
|
|
231
|
+
sampleContext: "first",
|
|
232
|
+
});
|
|
233
|
+
await resolveSlackUserId(client, "U_BOT", {
|
|
234
|
+
sampleEventType: "message",
|
|
235
|
+
sampleContext: "second",
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const meta = getKv("integration:unmapped:slack", "U_BOT:meta");
|
|
239
|
+
expect((meta!.value as { sampleContext: string }).sampleContext).toBe("second");
|
|
240
|
+
|
|
241
|
+
const count = getKv("integration:unmapped:slack", "U_BOT:count");
|
|
242
|
+
expect(count!.value).toBe(2);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("auto-link merges into an existing user by email", async () => {
|
|
246
|
+
const { client } = makeMockClient({
|
|
247
|
+
U_HUMAN: {
|
|
248
|
+
user: { profile: { email: "shared@example.com", real_name: "Shared Email Human" } },
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// Seed an existing user with the same email — no slack alias yet.
|
|
253
|
+
const db = getDb();
|
|
254
|
+
db.prepare(
|
|
255
|
+
`INSERT INTO users (id, name, email, emailAliases, status, createdAt, lastUpdatedAt)
|
|
256
|
+
VALUES (?, ?, ?, ?, 'active', ?, ?)`,
|
|
257
|
+
).run(
|
|
258
|
+
"existing-id",
|
|
259
|
+
"Pre-existing",
|
|
260
|
+
"shared@example.com",
|
|
261
|
+
"[]",
|
|
262
|
+
new Date().toISOString(),
|
|
263
|
+
new Date().toISOString(),
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
const userId = await resolveSlackUserId(client, "U_HUMAN", {
|
|
267
|
+
sampleEventType: "message",
|
|
268
|
+
sampleContext: "hi",
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// Auto-merge: cascade lands on the existing row, no new `users` insert.
|
|
272
|
+
expect(userId).toBe("existing-id");
|
|
273
|
+
expect(countUsers()).toBe(1);
|
|
274
|
+
|
|
275
|
+
// One slack alias linked to the pre-existing user.
|
|
276
|
+
const identities = getUserIdentities("existing-id");
|
|
277
|
+
expect(identities).toEqual([{ kind: "slack", externalId: "U_HUMAN" }]);
|
|
278
|
+
|
|
279
|
+
// Audit trail: auto_merge (by email) + identity_added (alias link).
|
|
280
|
+
expect(identityEventTypes("existing-id")).toEqual(["auto_merge", "identity_added"]);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe("enrichSlackUserEmail — 24h success cache, no failure cache", () => {
|
|
285
|
+
test("cache hit on second call (success)", async () => {
|
|
286
|
+
const { client, callCounts } = makeMockClient({
|
|
287
|
+
U_OK: { user: { profile: { email: "ok@example.com", real_name: "OK User" } } },
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const first = await enrichSlackUserEmail(client, "U_OK");
|
|
291
|
+
const second = await enrichSlackUserEmail(client, "U_OK");
|
|
292
|
+
|
|
293
|
+
expect(first).toBe("ok@example.com");
|
|
294
|
+
expect(second).toBe("ok@example.com");
|
|
295
|
+
expect(callCounts.U_OK).toBe(1);
|
|
296
|
+
|
|
297
|
+
// Cached row carries the 24h TTL anchor.
|
|
298
|
+
const cached = getKv("integration:user-enrichment:slack", "U_OK");
|
|
299
|
+
expect(cached).not.toBeNull();
|
|
300
|
+
expect(cached!.expiresAt).not.toBeNull();
|
|
301
|
+
expect(cached!.expiresAt! - Date.now()).toBeGreaterThan(23 * 60 * 60 * 1000);
|
|
302
|
+
expect(cached!.expiresAt! - Date.now()).toBeLessThanOrEqual(24 * 60 * 60 * 1000);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("API throw → no cache, second call still calls the API", async () => {
|
|
306
|
+
const { client, callCounts } = makeMockClient({ U_ERR: "throw" });
|
|
307
|
+
|
|
308
|
+
const first = await enrichSlackUserEmail(client, "U_ERR");
|
|
309
|
+
const second = await enrichSlackUserEmail(client, "U_ERR");
|
|
310
|
+
|
|
311
|
+
expect(first).toBeNull();
|
|
312
|
+
expect(second).toBeNull();
|
|
313
|
+
expect(callCounts.U_ERR).toBe(2);
|
|
314
|
+
|
|
315
|
+
// Nothing persisted.
|
|
316
|
+
expect(getKv("integration:user-enrichment:slack", "U_ERR")).toBeNull();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("no-email profile → no cache, second call still calls the API", async () => {
|
|
320
|
+
const { client, callCounts } = makeMockClient({
|
|
321
|
+
U_NOEMAIL: { user: { profile: {} } },
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const first = await enrichSlackUserEmail(client, "U_NOEMAIL");
|
|
325
|
+
const second = await enrichSlackUserEmail(client, "U_NOEMAIL");
|
|
326
|
+
|
|
327
|
+
expect(first).toBeNull();
|
|
328
|
+
expect(second).toBeNull();
|
|
329
|
+
expect(callCounts.U_NOEMAIL).toBe(2);
|
|
330
|
+
|
|
331
|
+
expect(getKv("integration:user-enrichment:slack", "U_NOEMAIL")).toBeNull();
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
describe("findUserByExternalId — sanity check after cascade", () => {
|
|
336
|
+
test("post-cascade lookup matches the cascade's return id", async () => {
|
|
337
|
+
const { client } = makeMockClient({
|
|
338
|
+
U_HUMAN: { user: { profile: { email: "u@example.com", real_name: "U" } } },
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const id = await resolveSlackUserId(client, "U_HUMAN", {
|
|
342
|
+
sampleEventType: "message",
|
|
343
|
+
sampleContext: "test",
|
|
344
|
+
});
|
|
345
|
+
const looked = findUserByExternalId("slack", "U_HUMAN");
|
|
346
|
+
expect(looked).not.toBeNull();
|
|
347
|
+
expect(looked!.id).toBe(id);
|
|
348
|
+
});
|
|
349
|
+
});
|