@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
|
@@ -7,19 +7,61 @@ import {
|
|
|
7
7
|
createUser,
|
|
8
8
|
deleteUser,
|
|
9
9
|
getAllUsers,
|
|
10
|
+
getDb,
|
|
10
11
|
getTaskById,
|
|
11
12
|
getUserById,
|
|
12
13
|
initDb,
|
|
13
|
-
resolveUser,
|
|
14
14
|
updateUser,
|
|
15
15
|
} from "../be/db";
|
|
16
|
+
import {
|
|
17
|
+
findOrCreateUserByEmail,
|
|
18
|
+
findUserByEmail,
|
|
19
|
+
findUserByExternalId,
|
|
20
|
+
findUserById,
|
|
21
|
+
fingerprintApiKey,
|
|
22
|
+
getUserIdentities,
|
|
23
|
+
type IdentityActor,
|
|
24
|
+
linkIdentity,
|
|
25
|
+
mintToken,
|
|
26
|
+
recordIdentityEvent,
|
|
27
|
+
resolveUserByToken,
|
|
28
|
+
revokeToken,
|
|
29
|
+
unlinkIdentity,
|
|
30
|
+
} from "../be/users";
|
|
16
31
|
|
|
17
32
|
const TEST_DB_PATH = "./test-user-identity.sqlite";
|
|
18
33
|
|
|
19
34
|
let leadAgent: ReturnType<typeof createAgent>;
|
|
20
35
|
let workerAgent: ReturnType<typeof createAgent>;
|
|
21
36
|
|
|
37
|
+
const SYSTEM_ACTOR: IdentityActor = { kind: "system", id: "test-suite" };
|
|
38
|
+
const OPERATOR_ACTOR: IdentityActor = { kind: "operator", id: "op:0000000000000000" };
|
|
39
|
+
|
|
40
|
+
function eventsFor(userId: string): Array<{
|
|
41
|
+
eventType: string;
|
|
42
|
+
actor: string;
|
|
43
|
+
beforeJson: string | null;
|
|
44
|
+
afterJson: string | null;
|
|
45
|
+
}> {
|
|
46
|
+
// Order by createdAt then rowid — events emitted within the same
|
|
47
|
+
// millisecond (synchronous bursts in tests) need a stable tiebreaker.
|
|
48
|
+
return getDb()
|
|
49
|
+
.prepare<
|
|
50
|
+
{ eventType: string; actor: string; beforeJson: string | null; afterJson: string | null },
|
|
51
|
+
string
|
|
52
|
+
>(
|
|
53
|
+
"SELECT eventType, actor, beforeJson, afterJson FROM user_identity_events WHERE userId = ? ORDER BY createdAt ASC, rowid ASC",
|
|
54
|
+
)
|
|
55
|
+
.all(userId);
|
|
56
|
+
}
|
|
57
|
+
|
|
22
58
|
beforeAll(() => {
|
|
59
|
+
// Best-effort cleanup of any lingering test DB from a previous crashed run.
|
|
60
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
61
|
+
try {
|
|
62
|
+
unlinkSync(`${TEST_DB_PATH}${suffix}`);
|
|
63
|
+
} catch {}
|
|
64
|
+
}
|
|
23
65
|
initDb(TEST_DB_PATH);
|
|
24
66
|
leadAgent = createAgent({ name: "TestLead", isLead: true, status: "idle" });
|
|
25
67
|
workerAgent = createAgent({ name: "TestWorker", isLead: false, status: "idle" });
|
|
@@ -27,19 +69,19 @@ beforeAll(() => {
|
|
|
27
69
|
|
|
28
70
|
afterAll(() => {
|
|
29
71
|
closeDb();
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
72
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
73
|
+
try {
|
|
74
|
+
unlinkSync(`${TEST_DB_PATH}${suffix}`);
|
|
75
|
+
} catch {
|
|
76
|
+
// ignore
|
|
77
|
+
}
|
|
36
78
|
}
|
|
37
79
|
});
|
|
38
80
|
|
|
39
81
|
// ─── User CRUD ────────────────────────────────────────────────────────────────
|
|
40
82
|
|
|
41
83
|
describe("createUser", () => {
|
|
42
|
-
test("creates a user with required fields only", () => {
|
|
84
|
+
test("creates a user with required fields only — no identities yet", () => {
|
|
43
85
|
const user = createUser({ name: "Alice" });
|
|
44
86
|
expect(user.id).toBeDefined();
|
|
45
87
|
expect(user.name).toBe("Alice");
|
|
@@ -47,49 +89,100 @@ describe("createUser", () => {
|
|
|
47
89
|
expect(user.role).toBeUndefined();
|
|
48
90
|
expect(user.emailAliases).toEqual([]);
|
|
49
91
|
expect(user.preferredChannel).toBe("slack");
|
|
92
|
+
expect(user.status).toBe("active");
|
|
93
|
+
expect(user.dailyBudgetUsd).toBeNull();
|
|
50
94
|
expect(user.createdAt).toBeDefined();
|
|
51
95
|
expect(user.lastUpdatedAt).toBeDefined();
|
|
96
|
+
expect(getUserIdentities(user.id)).toEqual([]);
|
|
52
97
|
});
|
|
53
98
|
|
|
54
|
-
test("
|
|
99
|
+
test("links identities one-by-one via linkIdentity", () => {
|
|
55
100
|
const user = createUser({
|
|
56
101
|
name: "Bob",
|
|
57
102
|
email: "bob@example.com",
|
|
58
103
|
role: "engineer",
|
|
59
104
|
notes: "Test user",
|
|
60
|
-
slackUserId: "U_BOB",
|
|
61
|
-
linearUserId: "lin-bob-uuid",
|
|
62
|
-
githubUsername: "bob-gh",
|
|
63
|
-
gitlabUsername: "bob-gl",
|
|
64
105
|
emailAliases: ["bob2@example.com", "robert@example.com"],
|
|
65
106
|
preferredChannel: "email",
|
|
66
107
|
timezone: "America/New_York",
|
|
67
108
|
});
|
|
68
109
|
expect(user.name).toBe("Bob");
|
|
69
110
|
expect(user.email).toBe("bob@example.com");
|
|
70
|
-
expect(user.role).toBe("engineer");
|
|
71
|
-
expect(user.notes).toBe("Test user");
|
|
72
|
-
expect(user.slackUserId).toBe("U_BOB");
|
|
73
|
-
expect(user.linearUserId).toBe("lin-bob-uuid");
|
|
74
|
-
expect(user.githubUsername).toBe("bob-gh");
|
|
75
|
-
expect(user.gitlabUsername).toBe("bob-gl");
|
|
76
111
|
expect(user.emailAliases).toEqual(["bob2@example.com", "robert@example.com"]);
|
|
77
|
-
|
|
78
|
-
|
|
112
|
+
|
|
113
|
+
linkIdentity(user.id, "slack", "U_BOB", SYSTEM_ACTOR);
|
|
114
|
+
linkIdentity(user.id, "linear", "lin-bob-uuid", SYSTEM_ACTOR);
|
|
115
|
+
linkIdentity(user.id, "github", "bob-gh", SYSTEM_ACTOR);
|
|
116
|
+
linkIdentity(user.id, "gitlab", "bob-gl", SYSTEM_ACTOR);
|
|
117
|
+
|
|
118
|
+
const ids = getUserIdentities(user.id);
|
|
119
|
+
expect(ids).toContainEqual({ kind: "slack", externalId: "U_BOB" });
|
|
120
|
+
expect(ids).toContainEqual({ kind: "linear", externalId: "lin-bob-uuid" });
|
|
121
|
+
expect(ids).toContainEqual({ kind: "github", externalId: "bob-gh" });
|
|
122
|
+
expect(ids).toContainEqual({ kind: "gitlab", externalId: "bob-gl" });
|
|
79
123
|
});
|
|
80
124
|
|
|
81
|
-
test("
|
|
82
|
-
createUser({
|
|
83
|
-
|
|
125
|
+
test("supports new Phase 064 fields", () => {
|
|
126
|
+
const user = createUser({
|
|
127
|
+
name: "Budgeted",
|
|
128
|
+
dailyBudgetUsd: 12.5,
|
|
129
|
+
status: "invited",
|
|
130
|
+
metadata: { hint: "test" },
|
|
131
|
+
});
|
|
132
|
+
expect(user.dailyBudgetUsd).toBe(12.5);
|
|
133
|
+
expect(user.status).toBe("invited");
|
|
134
|
+
expect(user.metadata).toEqual({ hint: "test" });
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("linkIdentity", () => {
|
|
139
|
+
test("rejects duplicate (kind, externalId) — PK collision", () => {
|
|
140
|
+
const u1 = createUser({ name: "Dup1" });
|
|
141
|
+
const u2 = createUser({ name: "Dup2" });
|
|
142
|
+
linkIdentity(u1.id, "slack", "U_DUP", SYSTEM_ACTOR);
|
|
143
|
+
expect(() => linkIdentity(u2.id, "slack", "U_DUP", SYSTEM_ACTOR)).toThrow();
|
|
84
144
|
});
|
|
85
145
|
|
|
86
|
-
test("rejects duplicate
|
|
87
|
-
createUser({ name: "
|
|
88
|
-
|
|
146
|
+
test("rejects duplicate (kind, externalId) — same user, second call", () => {
|
|
147
|
+
const u = createUser({ name: "SelfDup" });
|
|
148
|
+
linkIdentity(u.id, "github", "self-dup-gh", SYSTEM_ACTOR);
|
|
149
|
+
expect(() => linkIdentity(u.id, "github", "self-dup-gh", SYSTEM_ACTOR)).toThrow();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("emits identity_added event in the same transaction", () => {
|
|
153
|
+
const u = createUser({ name: "EventLink" });
|
|
154
|
+
linkIdentity(u.id, "slack", "U_EVENTLINK", SYSTEM_ACTOR);
|
|
155
|
+
const events = eventsFor(u.id);
|
|
156
|
+
expect(events.length).toBe(1);
|
|
157
|
+
expect(events[0]!.eventType).toBe("identity_added");
|
|
158
|
+
expect(events[0]!.actor).toBe("system:test-suite");
|
|
159
|
+
expect(JSON.parse(events[0]!.afterJson!)).toEqual({
|
|
160
|
+
kind: "slack",
|
|
161
|
+
externalId: "U_EVENTLINK",
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("unlinkIdentity", () => {
|
|
167
|
+
test("removes the mapping and emits identity_removed", () => {
|
|
168
|
+
const u = createUser({ name: "Unlink" });
|
|
169
|
+
linkIdentity(u.id, "slack", "U_UNLINK", SYSTEM_ACTOR);
|
|
170
|
+
expect(findUserByExternalId("slack", "U_UNLINK")).not.toBeNull();
|
|
171
|
+
|
|
172
|
+
unlinkIdentity(u.id, "slack", "U_UNLINK", SYSTEM_ACTOR);
|
|
173
|
+
expect(findUserByExternalId("slack", "U_UNLINK")).toBeNull();
|
|
174
|
+
|
|
175
|
+
const events = eventsFor(u.id);
|
|
176
|
+
expect(events.map((e) => e.eventType)).toEqual(["identity_added", "identity_removed"]);
|
|
177
|
+
expect(JSON.parse(events[1]!.beforeJson!)).toEqual({
|
|
178
|
+
kind: "slack",
|
|
179
|
+
externalId: "U_UNLINK",
|
|
180
|
+
});
|
|
181
|
+
expect(events[1]!.afterJson).toBeNull();
|
|
89
182
|
});
|
|
90
183
|
});
|
|
91
184
|
|
|
92
|
-
describe("getUserById", () => {
|
|
185
|
+
describe("getUserById / getUserIdentities", () => {
|
|
93
186
|
test("returns user by ID", () => {
|
|
94
187
|
const created = createUser({ name: "GetById" });
|
|
95
188
|
const fetched = getUserById(created.id);
|
|
@@ -98,8 +191,20 @@ describe("getUserById", () => {
|
|
|
98
191
|
expect(fetched!.id).toBe(created.id);
|
|
99
192
|
});
|
|
100
193
|
|
|
101
|
-
test("returns null for non-existent ID", () => {
|
|
102
|
-
expect(
|
|
194
|
+
test("findUserById returns null for non-existent ID", () => {
|
|
195
|
+
expect(findUserById("nonexistent")).toBeNull();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("getUserIdentities returns sorted (kind, externalId) tuples", () => {
|
|
199
|
+
const u = createUser({ name: "IdList" });
|
|
200
|
+
linkIdentity(u.id, "slack", "U_LIST", SYSTEM_ACTOR);
|
|
201
|
+
linkIdentity(u.id, "github", "list-gh", SYSTEM_ACTOR);
|
|
202
|
+
const list = getUserIdentities(u.id);
|
|
203
|
+
expect(list.length).toBe(2);
|
|
204
|
+
expect(list).toEqual([
|
|
205
|
+
{ kind: "github", externalId: "list-gh" },
|
|
206
|
+
{ kind: "slack", externalId: "U_LIST" },
|
|
207
|
+
]);
|
|
103
208
|
});
|
|
104
209
|
});
|
|
105
210
|
|
|
@@ -126,6 +231,18 @@ describe("updateUser", () => {
|
|
|
126
231
|
expect(updated!.emailAliases).toEqual(["alias1@test.com", "alias2@test.com"]);
|
|
127
232
|
});
|
|
128
233
|
|
|
234
|
+
test("updates new Phase 064 fields", () => {
|
|
235
|
+
const user = createUser({ name: "BudgetUser" });
|
|
236
|
+
const updated = updateUser(user.id, {
|
|
237
|
+
dailyBudgetUsd: 25.0,
|
|
238
|
+
status: "suspended",
|
|
239
|
+
metadata: { reason: "test" },
|
|
240
|
+
});
|
|
241
|
+
expect(updated!.dailyBudgetUsd).toBe(25.0);
|
|
242
|
+
expect(updated!.status).toBe("suspended");
|
|
243
|
+
expect(updated!.metadata).toEqual({ reason: "test" });
|
|
244
|
+
});
|
|
245
|
+
|
|
129
246
|
test("returns null for non-existent user", () => {
|
|
130
247
|
expect(updateUser("nonexistent", { name: "Nope" })).toBeNull();
|
|
131
248
|
});
|
|
@@ -149,8 +266,11 @@ describe("deleteUser", () => {
|
|
|
149
266
|
expect(deleteUser("nonexistent")).toBe(false);
|
|
150
267
|
});
|
|
151
268
|
|
|
152
|
-
test("clears requestedByUserId on tasks
|
|
153
|
-
const user = createUser({ name: "TaskOwner"
|
|
269
|
+
test("clears requestedByUserId on tasks AND cascades user_external_ids", () => {
|
|
270
|
+
const user = createUser({ name: "TaskOwner" });
|
|
271
|
+
linkIdentity(user.id, "slack", "U_TASKOWNER", SYSTEM_ACTOR);
|
|
272
|
+
expect(findUserByExternalId("slack", "U_TASKOWNER")).not.toBeNull();
|
|
273
|
+
|
|
154
274
|
const task = createTaskExtended("test task with user", {
|
|
155
275
|
agentId: workerAgent.id,
|
|
156
276
|
source: "slack",
|
|
@@ -160,92 +280,242 @@ describe("deleteUser", () => {
|
|
|
160
280
|
|
|
161
281
|
deleteUser(user.id);
|
|
162
282
|
expect(getTaskById(task.id)!.requestedByUserId).toBeUndefined();
|
|
283
|
+
// ON DELETE CASCADE on user_external_ids.userId should clear the mapping.
|
|
284
|
+
expect(findUserByExternalId("slack", "U_TASKOWNER")).toBeNull();
|
|
163
285
|
});
|
|
164
286
|
});
|
|
165
287
|
|
|
166
|
-
// ───
|
|
288
|
+
// ─── findUserByExternalId ─────────────────────────────────────────────────────
|
|
167
289
|
|
|
168
|
-
describe("
|
|
290
|
+
describe("findUserByExternalId", () => {
|
|
169
291
|
let testUser: ReturnType<typeof createUser>;
|
|
170
292
|
|
|
171
293
|
beforeAll(() => {
|
|
172
294
|
testUser = createUser({
|
|
173
295
|
name: "Resolve TestUser",
|
|
174
296
|
email: "resolve-test@example.com",
|
|
175
|
-
slackUserId: "U_RESOLVE_SLACK",
|
|
176
|
-
linearUserId: "lin-resolve-uuid",
|
|
177
|
-
githubUsername: "resolve-gh",
|
|
178
|
-
gitlabUsername: "resolve-gl",
|
|
179
|
-
emailAliases: ["resolve-alias@example.com"],
|
|
180
297
|
});
|
|
298
|
+
linkIdentity(testUser.id, "slack", "U_RESOLVE_SLACK", SYSTEM_ACTOR);
|
|
299
|
+
linkIdentity(testUser.id, "linear", "lin-resolve-uuid", SYSTEM_ACTOR);
|
|
300
|
+
linkIdentity(testUser.id, "github", "resolve-gh", SYSTEM_ACTOR);
|
|
301
|
+
linkIdentity(testUser.id, "gitlab", "resolve-gl", SYSTEM_ACTOR);
|
|
181
302
|
});
|
|
182
303
|
|
|
183
|
-
test("resolves by
|
|
184
|
-
const user =
|
|
304
|
+
test("resolves by slack identity", () => {
|
|
305
|
+
const user = findUserByExternalId("slack", "U_RESOLVE_SLACK");
|
|
185
306
|
expect(user).toBeDefined();
|
|
186
307
|
expect(user!.id).toBe(testUser.id);
|
|
187
308
|
});
|
|
188
309
|
|
|
189
|
-
test("resolves by
|
|
190
|
-
const user =
|
|
191
|
-
expect(user).toBeDefined();
|
|
310
|
+
test("resolves by linear identity", () => {
|
|
311
|
+
const user = findUserByExternalId("linear", "lin-resolve-uuid");
|
|
192
312
|
expect(user!.id).toBe(testUser.id);
|
|
193
313
|
});
|
|
194
314
|
|
|
195
|
-
test("resolves by
|
|
196
|
-
const user =
|
|
197
|
-
expect(user).toBeDefined();
|
|
315
|
+
test("resolves by github identity", () => {
|
|
316
|
+
const user = findUserByExternalId("github", "resolve-gh");
|
|
198
317
|
expect(user!.id).toBe(testUser.id);
|
|
199
318
|
});
|
|
200
319
|
|
|
201
|
-
test("resolves by
|
|
202
|
-
const user =
|
|
203
|
-
expect(user).toBeDefined();
|
|
320
|
+
test("resolves by gitlab identity", () => {
|
|
321
|
+
const user = findUserByExternalId("gitlab", "resolve-gl");
|
|
204
322
|
expect(user!.id).toBe(testUser.id);
|
|
205
323
|
});
|
|
206
324
|
|
|
207
|
-
test("
|
|
208
|
-
|
|
209
|
-
expect(
|
|
210
|
-
expect(user!.id).toBe(testUser.id);
|
|
325
|
+
test("returns null for unknown externalId", () => {
|
|
326
|
+
expect(findUserByExternalId("slack", "U_NONEXISTENT")).toBeNull();
|
|
327
|
+
expect(findUserByExternalId("github", "no-such-account")).toBeNull();
|
|
211
328
|
});
|
|
212
329
|
|
|
213
|
-
test("
|
|
214
|
-
|
|
215
|
-
expect(user).toBeDefined();
|
|
216
|
-
expect(user!.id).toBe(testUser.id);
|
|
330
|
+
test("kind is exact — slack externalId does not resolve under github", () => {
|
|
331
|
+
expect(findUserByExternalId("github", "U_RESOLVE_SLACK")).toBeNull();
|
|
217
332
|
});
|
|
333
|
+
});
|
|
218
334
|
|
|
219
|
-
|
|
220
|
-
const user = resolveUser({ name: "resolve testuser" });
|
|
221
|
-
expect(user).toBeDefined();
|
|
222
|
-
expect(user!.id).toBe(testUser.id);
|
|
223
|
-
});
|
|
335
|
+
// ─── findUserByEmail ──────────────────────────────────────────────────────────
|
|
224
336
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
337
|
+
describe("findUserByEmail", () => {
|
|
338
|
+
test("matches primary email (case-insensitive)", () => {
|
|
339
|
+
const user = createUser({
|
|
340
|
+
name: "EmailPrimary",
|
|
341
|
+
email: "primary@example.com",
|
|
342
|
+
});
|
|
343
|
+
expect(findUserByEmail("primary@example.com")!.id).toBe(user.id);
|
|
344
|
+
expect(findUserByEmail("PRIMARY@example.com")!.id).toBe(user.id);
|
|
229
345
|
});
|
|
230
346
|
|
|
231
|
-
test("
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
});
|
|
237
|
-
// When resolving with both slackUserId and email, slackUserId wins
|
|
238
|
-
const resolved = resolveUser({
|
|
239
|
-
slackUserId: "U_OTHER_PRIORITY",
|
|
240
|
-
email: "resolve-test@example.com", // belongs to testUser
|
|
347
|
+
test("matches an emailAlias (case-insensitive)", () => {
|
|
348
|
+
const user = createUser({
|
|
349
|
+
name: "EmailAlias",
|
|
350
|
+
email: "main@example.com",
|
|
351
|
+
emailAliases: ["alt@example.com", "other@example.com"],
|
|
241
352
|
});
|
|
242
|
-
expect(
|
|
243
|
-
|
|
244
|
-
|
|
353
|
+
expect(findUserByEmail("alt@example.com")!.id).toBe(user.id);
|
|
354
|
+
expect(findUserByEmail("OTHER@EXAMPLE.COM")!.id).toBe(user.id);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("returns null on no match", () => {
|
|
358
|
+
expect(findUserByEmail("nobody@nowhere.com")).toBeNull();
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// ─── findOrCreateUserByEmail ──────────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
describe("findOrCreateUserByEmail", () => {
|
|
365
|
+
test("creates a fresh user + emits identity_added when no match", () => {
|
|
366
|
+
const result = findOrCreateUserByEmail(
|
|
367
|
+
"new-foc@example.com",
|
|
368
|
+
{ name: "FocNew" },
|
|
369
|
+
{ kind: "system", id: "webhook:test" },
|
|
370
|
+
);
|
|
371
|
+
expect(result.created).toBe(true);
|
|
372
|
+
expect(result.user.email).toBe("new-foc@example.com");
|
|
373
|
+
expect(result.user.name).toBe("FocNew");
|
|
374
|
+
const events = eventsFor(result.user.id);
|
|
375
|
+
expect(events.length).toBe(1);
|
|
376
|
+
expect(events[0]!.eventType).toBe("identity_added");
|
|
377
|
+
expect(events[0]!.actor).toBe("system:webhook:test");
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("returns the existing user + emits auto_merge on a second call", () => {
|
|
381
|
+
const first = findOrCreateUserByEmail(
|
|
382
|
+
"merge-foc@example.com",
|
|
383
|
+
{ name: "FocMerge" },
|
|
384
|
+
SYSTEM_ACTOR,
|
|
385
|
+
);
|
|
386
|
+
expect(first.created).toBe(true);
|
|
387
|
+
|
|
388
|
+
const second = findOrCreateUserByEmail(
|
|
389
|
+
"merge-foc@example.com",
|
|
390
|
+
{ name: "FocMergeRetry" },
|
|
391
|
+
SYSTEM_ACTOR,
|
|
392
|
+
);
|
|
393
|
+
expect(second.created).toBe(false);
|
|
394
|
+
expect(second.user.id).toBe(first.user.id);
|
|
395
|
+
|
|
396
|
+
const events = eventsFor(first.user.id);
|
|
397
|
+
// identity_added (initial) + auto_merge (second call) = 2 events.
|
|
398
|
+
expect(events.map((e) => e.eventType)).toEqual(["identity_added", "auto_merge"]);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("falls back to email local-part when no name hint provided", () => {
|
|
402
|
+
const result = findOrCreateUserByEmail("auto-name@example.com", {}, SYSTEM_ACTOR);
|
|
403
|
+
expect(result.created).toBe(true);
|
|
404
|
+
expect(result.user.name).toBe("auto-name");
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// ─── Tokens ───────────────────────────────────────────────────────────────────
|
|
409
|
+
|
|
410
|
+
describe("mintToken / revokeToken / resolveUserByToken", () => {
|
|
411
|
+
test("mintToken returns aswt_-prefixed plaintext and stores hash + 4-char preview", () => {
|
|
412
|
+
const user = createUser({ name: "TokenUser" });
|
|
413
|
+
const { tokenId, plaintext } = mintToken(user.id, "CI test", OPERATOR_ACTOR);
|
|
414
|
+
|
|
415
|
+
expect(plaintext.startsWith("aswt_")).toBe(true);
|
|
416
|
+
expect(plaintext.length).toBeGreaterThanOrEqual(25);
|
|
417
|
+
expect(tokenId).toBeDefined();
|
|
418
|
+
|
|
419
|
+
// Stored row should have hash != plaintext and preview = last 4 chars.
|
|
420
|
+
const row = getDb()
|
|
421
|
+
.prepare<{ tokenHash: string; tokenPreview: string }, string>(
|
|
422
|
+
"SELECT tokenHash, tokenPreview FROM user_tokens WHERE id = ?",
|
|
423
|
+
)
|
|
424
|
+
.get(tokenId);
|
|
425
|
+
expect(row).toBeDefined();
|
|
426
|
+
expect(row!.tokenHash).not.toBe(plaintext);
|
|
427
|
+
expect(row!.tokenHash.length).toBe(64); // sha256 hex
|
|
428
|
+
expect(row!.tokenPreview).toBe(plaintext.slice(-4));
|
|
429
|
+
|
|
430
|
+
// token_minted event landed with operator actor.
|
|
431
|
+
const events = eventsFor(user.id);
|
|
432
|
+
expect(events.find((e) => e.eventType === "token_minted")).toBeDefined();
|
|
433
|
+
expect(events.find((e) => e.eventType === "token_minted")!.actor).toBe(
|
|
434
|
+
"operator:op:0000000000000000",
|
|
435
|
+
);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("resolveUserByToken returns the owning user and bumps lastUsedAt", () => {
|
|
439
|
+
const user = createUser({ name: "ResolveTokenUser" });
|
|
440
|
+
const { tokenId, plaintext } = mintToken(user.id, null, OPERATOR_ACTOR);
|
|
441
|
+
|
|
442
|
+
const resolved = resolveUserByToken(plaintext);
|
|
443
|
+
expect(resolved).not.toBeNull();
|
|
444
|
+
expect(resolved!.id).toBe(user.id);
|
|
445
|
+
|
|
446
|
+
const row = getDb()
|
|
447
|
+
.prepare<{ lastUsedAt: string | null }, string>(
|
|
448
|
+
"SELECT lastUsedAt FROM user_tokens WHERE id = ?",
|
|
449
|
+
)
|
|
450
|
+
.get(tokenId);
|
|
451
|
+
expect(row!.lastUsedAt).not.toBeNull();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("revokeToken sets revokedAt + emits token_revoked + resolveUserByToken returns null", () => {
|
|
455
|
+
const user = createUser({ name: "RevokeUser" });
|
|
456
|
+
const { tokenId, plaintext } = mintToken(user.id, "to-revoke", OPERATOR_ACTOR);
|
|
457
|
+
revokeToken(tokenId, OPERATOR_ACTOR);
|
|
458
|
+
|
|
459
|
+
const row = getDb()
|
|
460
|
+
.prepare<{ revokedAt: string | null }, string>(
|
|
461
|
+
"SELECT revokedAt FROM user_tokens WHERE id = ?",
|
|
462
|
+
)
|
|
463
|
+
.get(tokenId);
|
|
464
|
+
expect(row!.revokedAt).not.toBeNull();
|
|
465
|
+
|
|
466
|
+
expect(resolveUserByToken(plaintext)).toBeNull();
|
|
467
|
+
|
|
468
|
+
const events = eventsFor(user.id);
|
|
469
|
+
expect(events.map((e) => e.eventType)).toContain("token_revoked");
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test("resolveUserByToken returns null for unknown plaintext", () => {
|
|
473
|
+
expect(resolveUserByToken("aswt_unknown000000000000000000000")).toBeNull();
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// ─── fingerprintApiKey ────────────────────────────────────────────────────────
|
|
478
|
+
|
|
479
|
+
describe("fingerprintApiKey", () => {
|
|
480
|
+
test("returns op:<sha256-16-hex> format", () => {
|
|
481
|
+
expect(fingerprintApiKey("some-key")).toMatch(/^op:[0-9a-f]{16}$/);
|
|
482
|
+
expect(fingerprintApiKey("")).toMatch(/^op:[0-9a-f]{16}$/);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("is deterministic", () => {
|
|
486
|
+
expect(fingerprintApiKey("same-input")).toBe(fingerprintApiKey("same-input"));
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
// ─── recordIdentityEvent (direct API) ─────────────────────────────────────────
|
|
491
|
+
|
|
492
|
+
describe("recordIdentityEvent", () => {
|
|
493
|
+
test("can emit budget_changed / status_changed / email_* directly", () => {
|
|
494
|
+
const user = createUser({ name: "EventDirect" });
|
|
495
|
+
recordIdentityEvent(user.id, "budget_changed", OPERATOR_ACTOR, null, { dailyBudgetUsd: 10 });
|
|
496
|
+
recordIdentityEvent(
|
|
497
|
+
user.id,
|
|
498
|
+
"status_changed",
|
|
499
|
+
OPERATOR_ACTOR,
|
|
500
|
+
{ status: "active" },
|
|
501
|
+
{
|
|
502
|
+
status: "suspended",
|
|
503
|
+
},
|
|
504
|
+
);
|
|
505
|
+
recordIdentityEvent(user.id, "email_added", OPERATOR_ACTOR, null, { email: "x@y.com" });
|
|
506
|
+
recordIdentityEvent(user.id, "email_removed", OPERATOR_ACTOR, { email: "x@y.com" }, null);
|
|
507
|
+
|
|
508
|
+
const events = eventsFor(user.id);
|
|
509
|
+
expect(events.map((e) => e.eventType)).toEqual([
|
|
510
|
+
"budget_changed",
|
|
511
|
+
"status_changed",
|
|
512
|
+
"email_added",
|
|
513
|
+
"email_removed",
|
|
514
|
+
]);
|
|
245
515
|
});
|
|
246
516
|
});
|
|
247
517
|
|
|
248
|
-
// ─── requestedByUserId on tasks
|
|
518
|
+
// ─── requestedByUserId on tasks ───────────────────────────────────────────────
|
|
249
519
|
|
|
250
520
|
describe("requestedByUserId in tasks", () => {
|
|
251
521
|
test("createTaskExtended stores requestedByUserId", () => {
|