The onboarding scan started on its own, discarded what it found, claimed to read email and calendars, simulated progress for everyone who could not scan, and its project creation always failed (wrong Space fields and a space lookup by title across tenants). Discovery now asks first and creates nothing without a choice: - The scan step appears only for the deployment owner when ONBOARDING_SCAN_HOME is set; everyone else skips it. The simulated scan is gone. - It shows the folder and asks. Only directory listings are read: git projects up to four levels deep, their languages and a few tools (Docker, GitHub Actions, Terraform). Symlinks are never followed and each chosen folder is resolved again before use, so nothing outside the folder is reached. Results stay with the account that scanned. - Found projects are a checklist (none preselected); found languages and tools are facts (preselected). "Add" creates one project space per chosen project with createSpaceForMember and wireProjectWorkspace in the user's own organization, reusing the user's own space already linked to that folder, and saves the chosen facts as onboarding facts under the profile lock and fact limit. - When a connection becomes connected (direct connect, authorization round trip, or catalog reconciliation), "Uses <App>" is remembered as a tools fact from the connector. Revoking keeps it. The E2E harness now points ONBOARDING_SCAN_HOME at a fixture folder and starts without a deployment owner, so owner-only onboarding steps appear only in specs that take the seat through /__e2e/deployment-owner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
193 lines
7.2 KiB
TypeScript
193 lines
7.2 KiB
TypeScript
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import type { PrismaClient } from "@rakazo/db";
|
|
import { afterAll, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
applyOnboardingScan,
|
|
discoverOnboardingScan,
|
|
onboardingScanFolder,
|
|
onboardingScanStatus,
|
|
startOnboardingScan,
|
|
} from "./onboarding-scan.js";
|
|
|
|
const owner = { userId: "user-owner", spaceId: "space-owner", isDeploymentOwner: true };
|
|
const member = { userId: "user-member", spaceId: "space-member", isDeploymentOwner: false };
|
|
const roots: string[] = [];
|
|
|
|
/** Fails the test if the scan writes anything before its checks pass. */
|
|
const untouchedPrisma = new Proxy(
|
|
{},
|
|
{
|
|
get() {
|
|
throw new Error("the database must not be touched");
|
|
},
|
|
},
|
|
) as PrismaClient;
|
|
|
|
async function tempDir(): Promise<string> {
|
|
const dir = await realpath(await mkdtemp(path.join(tmpdir(), "evoo-scan-")));
|
|
roots.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
async function files(root: string, entries: string[]): Promise<void> {
|
|
for (const entry of entries) {
|
|
const file = path.join(root, entry);
|
|
await mkdir(path.dirname(file), { recursive: true });
|
|
await writeFile(file, "x\n");
|
|
}
|
|
}
|
|
|
|
async function project(root: string, relative: string, entries: string[]): Promise<void> {
|
|
await mkdir(path.join(root, relative, ".git"), { recursive: true });
|
|
await files(path.join(root, relative), entries);
|
|
}
|
|
|
|
async function completed(scanId: string, userId: string) {
|
|
return await vi.waitFor(
|
|
() => {
|
|
const status = onboardingScanStatus(scanId, userId);
|
|
if (status.status === "scanning") throw new Error("pending");
|
|
return status;
|
|
},
|
|
{ timeout: 10_000, interval: 20 },
|
|
);
|
|
}
|
|
|
|
afterAll(async () => {
|
|
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
|
|
});
|
|
|
|
describe("onboarding scan", () => {
|
|
it("lists git projects with their languages and tools and never follows a symlink", async () => {
|
|
const home = await tempDir();
|
|
const outside = await tempDir();
|
|
// A home folder tracked by git is not itself a project.
|
|
await mkdir(path.join(home, ".git"));
|
|
await project(home, "code/atlas", [
|
|
"src/a.ts",
|
|
"src/b.ts",
|
|
"src/c.ts",
|
|
"src/d.tsx",
|
|
"scripts/build.js",
|
|
"Dockerfile",
|
|
".github/workflows/ci.yml",
|
|
"infra/main.tf",
|
|
"src/odd.constructor",
|
|
"node_modules/dep/index.js",
|
|
"node_modules/dep/more.js",
|
|
]);
|
|
await project(home, "code/garden", ["main.py"]);
|
|
await project(home, "deep/a/b/edge", ["tool.py"]);
|
|
await project(home, "deep/a/b/c/too-deep", ["main.go"]);
|
|
await project(home, ".hidden/secret", ["main.go"]);
|
|
await project(home, "notes/node_modules/vendored", ["main.go"]);
|
|
await files(home, ["notes/todo.md"]);
|
|
await project(outside, "escaped", ["main.rs"]);
|
|
await symlink(path.join(outside, "escaped"), path.join(home, "linked"));
|
|
await symlink(outside, path.join(home, "code/atlas/src/outside"));
|
|
|
|
const findings = await discoverOnboardingScan(home);
|
|
|
|
expect(findings.projects).toEqual([
|
|
{
|
|
id: "project-0",
|
|
name: "atlas",
|
|
path: "code/atlas",
|
|
languages: ["TypeScript", "JavaScript"],
|
|
tools: ["Docker", "GitHub Actions", "Terraform"],
|
|
},
|
|
{ id: "project-1", name: "garden", path: "code/garden", languages: ["Python"], tools: [] },
|
|
{ id: "project-2", name: "edge", path: "deep/a/b/edge", languages: ["Python"], tools: [] },
|
|
]);
|
|
expect(findings.facts).toEqual([
|
|
{ id: "language:Python", label: "Python", category: "work", value: "Codes in Python" },
|
|
{
|
|
id: "language:JavaScript",
|
|
label: "JavaScript",
|
|
category: "work",
|
|
value: "Codes in JavaScript",
|
|
},
|
|
{
|
|
id: "language:TypeScript",
|
|
label: "TypeScript",
|
|
category: "work",
|
|
value: "Codes in TypeScript",
|
|
},
|
|
{ id: "tool:Docker", label: "Docker", category: "tools", value: "Uses Docker" },
|
|
{
|
|
id: "tool:GitHub Actions",
|
|
label: "GitHub Actions",
|
|
category: "tools",
|
|
value: "Uses GitHub Actions",
|
|
},
|
|
{ id: "tool:Terraform", label: "Terraform", category: "tools", value: "Uses Terraform" },
|
|
]);
|
|
});
|
|
|
|
it("offers a scan folder only to the deployment owner and only when it exists", async () => {
|
|
const home = await tempDir();
|
|
await files(home, ["file.txt"]);
|
|
await expect(onboardingScanFolder(member, home)).resolves.toBeNull();
|
|
await expect(onboardingScanFolder(owner, undefined)).resolves.toBeNull();
|
|
await expect(onboardingScanFolder(owner, path.join(home, "missing"))).resolves.toBeNull();
|
|
await expect(onboardingScanFolder(owner, path.join(home, "file.txt"))).resolves.toBeNull();
|
|
await expect(onboardingScanFolder(owner, home)).resolves.toBe(home);
|
|
await expect(startOnboardingScan(member, home)).rejects.toMatchObject({ code: "FORBIDDEN" });
|
|
});
|
|
|
|
it("keeps a scan with the account that started it and returns only relative paths", async () => {
|
|
const home = await tempDir();
|
|
await project(home, "code/atlas", ["index.ts"]);
|
|
const { scanId } = await startOnboardingScan(owner, home);
|
|
|
|
const status = await completed(scanId, owner.userId);
|
|
expect(status).toEqual({
|
|
status: "complete",
|
|
projects: [{ id: "project-0", name: "atlas", path: "code/atlas" }],
|
|
facts: [{ id: "language:TypeScript", label: "TypeScript" }],
|
|
});
|
|
expect(JSON.stringify(status)).not.toContain(home);
|
|
expect(() => onboardingScanStatus(scanId, member.userId)).toThrow(
|
|
expect.objectContaining({ code: "NOT_FOUND" }),
|
|
);
|
|
expect(() => onboardingScanStatus("missing-scan", owner.userId)).toThrow(
|
|
expect.objectContaining({ code: "NOT_FOUND" }),
|
|
);
|
|
await expect(
|
|
applyOnboardingScan(untouchedPrisma, member, home, {
|
|
scanId,
|
|
projectIds: ["project-0"],
|
|
factIds: [],
|
|
}),
|
|
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
|
});
|
|
|
|
it("checks ownership, the folder and every chosen project again before writing", async () => {
|
|
const home = await tempDir();
|
|
const outside = await tempDir();
|
|
await project(home, "code/atlas", ["index.ts"]);
|
|
await project(home, "code/garden", ["main.py"]);
|
|
await project(outside, "elsewhere", ["main.rs"]);
|
|
const { scanId } = await startOnboardingScan(owner, home);
|
|
await completed(scanId, owner.userId);
|
|
const choice = { scanId, projectIds: ["project-1"], factIds: ["language:Python"] };
|
|
|
|
await expect(
|
|
applyOnboardingScan(untouchedPrisma, { ...owner, isDeploymentOwner: false }, home, choice),
|
|
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
|
await expect(
|
|
applyOnboardingScan(untouchedPrisma, owner, outside, choice),
|
|
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
|
|
|
// The chosen project became a symlink out of the scan folder after the scan.
|
|
await rm(path.join(home, "code/garden"), { recursive: true });
|
|
await symlink(path.join(outside, "elsewhere"), path.join(home, "code/garden"));
|
|
await expect(applyOnboardingScan(untouchedPrisma, owner, home, choice)).rejects.toMatchObject({
|
|
code: "BAD_REQUEST",
|
|
message: "That folder does not exist or is not readable.",
|
|
});
|
|
});
|
|
});
|