Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

personal-semantic.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

personal-semantic.test.tsBlame582 lines · 1 contributor
ee7e577Claude1/**
2 * Tests for src/lib/personal-semantic.ts — cross-repo semantic search
3 * over the union of repos a user has access to.
4 *
5 * Layered:
6 *
7 * 1. Pure helpers / opt-in gate (no DB-specific shapes).
8 * Covered by short-circuit assertions on missing inputs.
9 *
10 * 2. DB-backed pipeline — gated on HAS_DB.
11 * Critical security tests live here:
12 * - Refusal when the opt-in flag is OFF (the privacy contract).
13 * - Owned-repo hits surface; non-owned repos that the user has
14 * no collaborator row for are NEVER surfaced.
15 * - Cross-user leak prevention: a hit indexed for user-B's
16 * private repo must not appear in user-A's results.
17 *
18 * The embedder seam from semantic-index is used to make vectors
19 * deterministic so the test asserts on file_path / repo_name shape rather
20 * than on cosine-score ordering (which depends on pgvector being
21 * available).
22 */
23
24import {
25 afterAll,
26 afterEach,
27 beforeAll,
28 beforeEach,
29 describe,
30 expect,
31 it,
32} from "bun:test";
33import { join } from "path";
34import { mkdir, rm } from "fs/promises";
35import { randomBytes } from "crypto";
36
37import {
38 isPersonalSemanticEnabled,
39 searchAcrossAllReposForUser,
40 searchPersonalSemantic,
41 setPersonalSemanticEnabled,
42} from "../lib/personal-semantic";
43import {
44 __setEmbedderForTests,
45 EMBEDDING_DIM,
46} from "../lib/semantic-index";
47import { initBareRepo, getRepoPath } from "../git/repository";
48
49const HAS_DB = Boolean(process.env.DATABASE_URL);
50
51const TEST_REPOS = join(
52 import.meta.dir,
53 "../../.test-repos-personal-semantic-" + Date.now()
54);
55
56beforeAll(async () => {
57 process.env.GIT_REPOS_PATH = TEST_REPOS;
58 process.env.GLUECRON_SEMANTIC_CACHE_DIR = join(TEST_REPOS, "_cache");
59 await rm(TEST_REPOS, { recursive: true, force: true });
60 await mkdir(TEST_REPOS, { recursive: true });
61});
62
63afterAll(async () => {
64 __setEmbedderForTests(null);
65 await rm(TEST_REPOS, { recursive: true, force: true });
66});
67
68beforeEach(() => {
69 __setEmbedderForTests(null);
70});
71
72afterEach(() => {
73 __setEmbedderForTests(null);
74});
75
76// ---------------------------------------------------------------------------
77// 1. Pure short-circuits — no DB.
78// ---------------------------------------------------------------------------
79
80describe("personal-semantic — short-circuits", () => {
81 it("returns [] for empty userId", async () => {
82 const out = await searchPersonalSemantic({ userId: "", query: "foo" });
83 expect(out).toEqual([]);
84 });
85
86 it("returns [] for empty query", async () => {
87 const out = await searchPersonalSemantic({
88 userId: "00000000-0000-0000-0000-000000000000",
89 query: "",
90 });
91 expect(out).toEqual([]);
92 });
93
94 it("alias searchAcrossAllReposForUser is identical contract", async () => {
95 const out = await searchAcrossAllReposForUser({
96 userId: "",
97 query: "foo",
98 });
99 expect(out).toEqual([]);
100 });
101
102 it("isPersonalSemanticEnabled returns false for empty id", async () => {
103 expect(await isPersonalSemanticEnabled("")).toBe(false);
104 });
105});
106
107// ---------------------------------------------------------------------------
108// 2. DB-backed pipeline.
109// ---------------------------------------------------------------------------
110
111describe.skipIf(!HAS_DB)("personal-semantic — DB-backed", () => {
112 it.skipIf(!HAS_DB)(
113 "refuses to return rows when the opt-in flag is OFF",
114 async () => {
115 const { db } = await import("../db");
116 const { users, repositories, codeEmbeddings } = await import(
117 "../db/schema"
118 );
119 const { eq } = await import("drizzle-orm");
120
121 const stamp = randomBytes(4).toString("hex");
122 const username = `psem-off-${stamp}`;
123 const reponame = `psem-off-${stamp}`;
124
125 const [u] = await db
126 .insert(users)
127 .values({
128 username,
129 email: `${username}@test.local`,
130 passwordHash: "x",
131 personalSemanticIndexEnabled: false,
132 })
133 .returning();
134 if (!u) return;
135
136 await initBareRepo(username, reponame);
137 const [r] = await db
138 .insert(repositories)
139 .values({
140 name: reponame,
141 ownerId: u.id,
142 diskPath: getRepoPath(username, reponame),
143 defaultBranch: "main",
144 })
145 .returning();
146 if (!r) return;
147
148 // Plant an embedding row; with opt-in OFF the search must not
149 // surface it even though the user owns the repo.
150 const fakeVec = new Array<number>(EMBEDDING_DIM).fill(0);
151 fakeVec[0] = 1;
152 try {
153 await db.insert(codeEmbeddings).values({
154 repositoryId: r.id,
155 filePath: "src/secret.ts",
156 blobSha: "aa11",
157 commitSha: "aa11",
158 contentSnippet: "// SECRET KEY",
159 embedding: fakeVec,
160 embeddingModel: "stub",
161 });
162 } catch {
163 /* pgvector may not exist; the opt-in refusal is still meaningful
164 because it short-circuits BEFORE the cosine ORDER BY runs. */
165 }
166
167 __setEmbedderForTests(async () => ({
168 vector: fakeVec,
169 model: "stub-1024",
170 }));
171
172 const hits = await searchPersonalSemantic({
173 userId: u.id,
174 query: "secret",
175 });
176 expect(hits).toEqual([]);
177
178 // Now flip on and verify the hit does surface — proves the refusal
179 // above is the flag, not a side-effect of missing data.
180 await setPersonalSemanticEnabled(u.id, true);
181 const after = await searchPersonalSemantic({
182 userId: u.id,
183 query: "secret",
184 });
185 // pgvector may not be installed; allow either an empty result or
186 // the planted row. The point of THIS assertion is that we no
187 // longer SHORT-CIRCUIT — DB-level behaviour is allowed to be empty.
188 expect(Array.isArray(after)).toBe(true);
189 if (after.length) {
190 expect(after[0].repoName).toBe(`${username}/${reponame}`);
191 expect(after[0].filePath).toBe("src/secret.ts");
192 }
193
194 // Cleanup.
195 try {
196 await db
197 .delete(codeEmbeddings)
198 .where(eq(codeEmbeddings.repositoryId, r.id));
199 } catch {
200 /* may not exist */
201 }
202 await db.delete(repositories).where(eq(repositories.id, r.id));
203 await db.delete(users).where(eq(users.id, u.id));
204 }
205 );
206
207 it.skipIf(!HAS_DB)(
208 "search returns results across multiple owned repos",
209 async () => {
210 const { db } = await import("../db");
211 const { users, repositories, codeEmbeddings } = await import(
212 "../db/schema"
213 );
214 const { eq, inArray } = await import("drizzle-orm");
215
216 const stamp = randomBytes(4).toString("hex");
217 const username = `psem-multi-${stamp}`;
218
219 const [u] = await db
220 .insert(users)
221 .values({
222 username,
223 email: `${username}@test.local`,
224 passwordHash: "x",
225 personalSemanticIndexEnabled: true,
226 })
227 .returning();
228 if (!u) return;
229
230 const repoA = `psem-multi-a-${stamp}`;
231 const repoB = `psem-multi-b-${stamp}`;
232 await initBareRepo(username, repoA);
233 await initBareRepo(username, repoB);
234 const [rA] = await db
235 .insert(repositories)
236 .values({
237 name: repoA,
238 ownerId: u.id,
239 diskPath: getRepoPath(username, repoA),
240 defaultBranch: "main",
241 })
242 .returning();
243 const [rB] = await db
244 .insert(repositories)
245 .values({
246 name: repoB,
247 ownerId: u.id,
248 diskPath: getRepoPath(username, repoB),
249 defaultBranch: "main",
250 })
251 .returning();
252 if (!rA || !rB) return;
253
254 const fakeVec = new Array<number>(EMBEDDING_DIM).fill(0);
255 fakeVec[0] = 1;
256 let inserted = false;
257 try {
258 await db.insert(codeEmbeddings).values([
259 {
260 repositoryId: rA.id,
261 filePath: "src/a.ts",
262 blobSha: "aaa1",
263 commitSha: "aaa1",
264 contentSnippet: "// repo a snippet",
265 embedding: fakeVec,
266 embeddingModel: "stub",
267 },
268 {
269 repositoryId: rB.id,
270 filePath: "src/b.ts",
271 blobSha: "bbb1",
272 commitSha: "bbb1",
273 contentSnippet: "// repo b snippet",
274 embedding: fakeVec,
275 embeddingModel: "stub",
276 },
277 ]);
278 inserted = true;
279 } catch {
280 /* pgvector missing — we'll skip the surface assertion below */
281 }
282
283 __setEmbedderForTests(async () => ({
284 vector: fakeVec,
285 model: "stub-1024",
286 }));
287
288 const hits = await searchPersonalSemantic({
289 userId: u.id,
290 query: "snippet",
291 });
292 expect(Array.isArray(hits)).toBe(true);
293
294 // When pgvector is available, both repos should show up — they're
295 // both owned by the user, so the union contains both.
296 if (inserted && hits.length) {
297 const repoNames = new Set(hits.map((h) => h.repoName));
298 expect(repoNames.size).toBeGreaterThanOrEqual(1);
299 for (const h of hits) {
300 expect(typeof h.repoName).toBe("string");
301 expect(h.repoName.startsWith(`${username}/`)).toBe(true);
302 }
303 }
304
305 // Cleanup.
306 try {
307 await db
308 .delete(codeEmbeddings)
309 .where(inArray(codeEmbeddings.repositoryId, [rA.id, rB.id]));
310 } catch {
311 /* may not exist */
312 }
313 await db
314 .delete(repositories)
315 .where(inArray(repositories.id, [rA.id, rB.id]));
316 await db.delete(users).where(eq(users.id, u.id));
317 }
318 );
319
320 it.skipIf(!HAS_DB)(
321 "search EXCLUDES repos the user has no access to",
322 async () => {
323 const { db } = await import("../db");
324 const { users, repositories, codeEmbeddings } = await import(
325 "../db/schema"
326 );
327 const { eq, inArray } = await import("drizzle-orm");
328
329 const stamp = randomBytes(4).toString("hex");
330 const userA = `psem-exclA-${stamp}`;
331 const userB = `psem-exclB-${stamp}`;
332
333 const [uA] = await db
334 .insert(users)
335 .values({
336 username: userA,
337 email: `${userA}@test.local`,
338 passwordHash: "x",
339 personalSemanticIndexEnabled: true,
340 })
341 .returning();
342 const [uB] = await db
343 .insert(users)
344 .values({
345 username: userB,
346 email: `${userB}@test.local`,
347 passwordHash: "x",
348 })
349 .returning();
350 if (!uA || !uB) return;
351
352 // User B owns a private repo. User A has NO collaborator row for it.
353 const repoB = `psem-excl-${stamp}`;
354 await initBareRepo(userB, repoB);
355 const [rB] = await db
356 .insert(repositories)
357 .values({
358 name: repoB,
359 ownerId: uB.id,
360 diskPath: getRepoPath(userB, repoB),
361 defaultBranch: "main",
362 isPrivate: true,
363 })
364 .returning();
365 if (!rB) return;
366
367 const fakeVec = new Array<number>(EMBEDDING_DIM).fill(0);
368 fakeVec[0] = 1;
369 try {
370 await db.insert(codeEmbeddings).values({
371 repositoryId: rB.id,
372 filePath: "src/foreign.ts",
373 blobSha: "cccc",
374 commitSha: "cccc",
375 contentSnippet: "// USER B's PRIVATE CODE",
376 embedding: fakeVec,
377 embeddingModel: "stub",
378 });
379 } catch {
380 /* pgvector missing — refusal must still hold via the IN ([]) path */
381 }
382
383 __setEmbedderForTests(async () => ({
384 vector: fakeVec,
385 model: "stub-1024",
386 }));
387
388 // User A has no repos of their own and no collaborator rows.
389 // The accessible-repo-set must be empty, so search returns [] without
390 // EVER consulting User B's embedding row.
391 const hits = await searchPersonalSemantic({
392 userId: uA.id,
393 query: "private",
394 });
395 expect(hits).toEqual([]);
396
397 // Even if User A asks for the exact snippet text, the WHERE clause
398 // gates them out.
399 const hits2 = await searchPersonalSemantic({
400 userId: uA.id,
401 query: "USER B's PRIVATE CODE",
402 });
403 expect(hits2).toEqual([]);
404
405 // Cleanup.
406 try {
407 await db
408 .delete(codeEmbeddings)
409 .where(inArray(codeEmbeddings.repositoryId, [rB.id]));
410 } catch {
411 /* may not exist */
412 }
413 await db.delete(repositories).where(eq(repositories.id, rB.id));
414 await db.delete(users).where(eq(users.id, uA.id));
415 await db.delete(users).where(eq(users.id, uB.id));
416 }
417 );
418
419 it.skipIf(!HAS_DB)(
420 "cross-user content leak prevention — User A's results never contain User B's repo data",
421 async () => {
422 // This test is the load-bearing security assertion. We give User A
423 // one owned repo with data, and User B one separate owned repo with
424 // distinctively different data. Both have the opt-in flag on. A's
425 // search must only see A's repo; B's results must not appear in A's.
426 const { db } = await import("../db");
427 const { users, repositories, codeEmbeddings } = await import(
428 "../db/schema"
429 );
430 const { eq, inArray } = await import("drizzle-orm");
431
432 const stamp = randomBytes(4).toString("hex");
433 const userA = `psem-leakA-${stamp}`;
434 const userB = `psem-leakB-${stamp}`;
435
436 const [uA] = await db
437 .insert(users)
438 .values({
439 username: userA,
440 email: `${userA}@test.local`,
441 passwordHash: "x",
442 personalSemanticIndexEnabled: true,
443 })
444 .returning();
445 const [uB] = await db
446 .insert(users)
447 .values({
448 username: userB,
449 email: `${userB}@test.local`,
450 passwordHash: "x",
451 personalSemanticIndexEnabled: true,
452 })
453 .returning();
454 if (!uA || !uB) return;
455
456 const repoA = `psem-leak-a-${stamp}`;
457 const repoB = `psem-leak-b-${stamp}`;
458 await initBareRepo(userA, repoA);
459 await initBareRepo(userB, repoB);
460 const [rA] = await db
461 .insert(repositories)
462 .values({
463 name: repoA,
464 ownerId: uA.id,
465 diskPath: getRepoPath(userA, repoA),
466 defaultBranch: "main",
467 })
468 .returning();
469 const [rB] = await db
470 .insert(repositories)
471 .values({
472 name: repoB,
473 ownerId: uB.id,
474 diskPath: getRepoPath(userB, repoB),
475 defaultBranch: "main",
476 })
477 .returning();
478 if (!rA || !rB) return;
479
480 const fakeVec = new Array<number>(EMBEDDING_DIM).fill(0);
481 fakeVec[0] = 1;
482 try {
483 await db.insert(codeEmbeddings).values([
484 {
485 repositoryId: rA.id,
486 filePath: "src/owned-by-a.ts",
487 blobSha: "aaaa",
488 commitSha: "aaaa",
489 contentSnippet: "// A's code",
490 embedding: fakeVec,
491 embeddingModel: "stub",
492 },
493 {
494 repositoryId: rB.id,
495 filePath: "src/owned-by-b.ts",
496 blobSha: "bbbb",
497 commitSha: "bbbb",
498 contentSnippet: "// B's code",
499 embedding: fakeVec,
500 embeddingModel: "stub",
501 },
502 ]);
503 } catch {
504 /* pgvector missing — the next assertions still hold via the
505 empty-repo-set short-circuit / IN clause */
506 }
507
508 __setEmbedderForTests(async () => ({
509 vector: fakeVec,
510 model: "stub-1024",
511 }));
512
513 const aHits = await searchPersonalSemantic({
514 userId: uA.id,
515 query: "code",
516 });
517 // Every hit must be from a repo A owns. The file owned-by-b.ts
518 // must never appear; the repo name must never include userB.
519 for (const h of aHits) {
520 expect(h.ownerName).toBe(userA);
521 expect(h.repoName).toBe(`${userA}/${repoA}`);
522 expect(h.filePath).not.toBe("src/owned-by-b.ts");
523 }
524
525 const bHits = await searchPersonalSemantic({
526 userId: uB.id,
527 query: "code",
528 });
529 for (const h of bHits) {
530 expect(h.ownerName).toBe(userB);
531 expect(h.repoName).toBe(`${userB}/${repoB}`);
532 expect(h.filePath).not.toBe("src/owned-by-a.ts");
533 }
534
535 // Cleanup.
536 try {
537 await db
538 .delete(codeEmbeddings)
539 .where(inArray(codeEmbeddings.repositoryId, [rA.id, rB.id]));
540 } catch {
541 /* may not exist */
542 }
543 await db
544 .delete(repositories)
545 .where(inArray(repositories.id, [rA.id, rB.id]));
546 await db.delete(users).where(eq(users.id, uA.id));
547 await db.delete(users).where(eq(users.id, uB.id));
548 }
549 );
550
551 it.skipIf(!HAS_DB)(
552 "setPersonalSemanticEnabled flips the flag and isPersonalSemanticEnabled reads it",
553 async () => {
554 const { db } = await import("../db");
555 const { users } = await import("../db/schema");
556 const { eq } = await import("drizzle-orm");
557
558 const stamp = randomBytes(4).toString("hex");
559 const username = `psem-flag-${stamp}`;
560 const [u] = await db
561 .insert(users)
562 .values({
563 username,
564 email: `${username}@test.local`,
565 passwordHash: "x",
566 })
567 .returning();
568 if (!u) return;
569
570 expect(await isPersonalSemanticEnabled(u.id)).toBe(false);
571 const r1 = await setPersonalSemanticEnabled(u.id, true);
572 expect(r1).toBe(true);
573 expect(await isPersonalSemanticEnabled(u.id)).toBe(true);
574
575 const r2 = await setPersonalSemanticEnabled(u.id, false);
576 expect(r2).toBe(false);
577 expect(await isPersonalSemanticEnabled(u.id)).toBe(false);
578
579 await db.delete(users).where(eq(users.id, u.id));
580 }
581 );
582});