feat(FN-1743): merge fusion/fn-1743

This commit is contained in:
gsxdsm
2026-04-16 01:08:05 -07:00
parent b184468419
commit 41fa75d4f2
5 changed files with 917 additions and 16 deletions

View File

@@ -1280,4 +1280,500 @@ describe("createServer scoped scheduling resolver regressions", () => {
expect(res.body.some((s: any) => s.scope === "project")).toBe(false);
});
});
// ── Cross-project isolation integration tests (FN-1743) ─────────────────────
//
// These tests verify end-to-end cross-project isolation for scoped scheduling routes.
// They ensure that:
// 1. Requests with projectId=proj-a never touch proj-b stores
// 2. Requests with projectId=proj-b never touch proj-a stores
// 3. Fallback paths are deterministic and do not opportunistically hop lanes
// 4. Scope filtering in routes ensures only project-scoped items are returned
//
// NOTE: The current implementation uses the same automation/routine store for both
// global and project scopes, with scope filtering applied at query time. This means
// engineManager.getEngine is not used for scope resolution - the store itself
// handles scope filtering through getDueSchedules(scope) or listSchedules filtering.
describe("cross-project isolation integration", () => {
// Test fixtures for multi-project scenarios
const FAKE_PROJ_A_SCHEDULE = {
id: "sched-proj-a",
name: "Project A Schedule",
scope: "project" as const,
scheduleType: "daily" as const,
command: "echo proj-a",
enabled: true,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const FAKE_PROJ_B_SCHEDULE = {
id: "sched-proj-b",
name: "Project B Schedule",
scope: "project" as const,
scheduleType: "daily" as const,
command: "echo proj-b",
enabled: true,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const FAKE_PROJ_A_ROUTINE = {
id: "routine-proj-a",
name: "Project A Routine",
scope: "project" as const,
trigger: { type: "manual" as const },
enabled: true,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const FAKE_PROJ_B_ROUTINE = {
id: "routine-proj-b",
name: "Project B Routine",
scope: "project" as const,
trigger: { type: "manual" as const },
enabled: true,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
it("GET /api/automations?scope=project filters to only project-scoped schedules", async () => {
const globalStore = createMockAutomationStore("global");
// Store returns both global and project schedules
globalStore.listSchedules.mockResolvedValue([
FAKE_GLOBAL_SCHEDULE,
FAKE_PROJ_A_SCHEDULE,
FAKE_PROJ_B_SCHEDULE,
]);
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
});
// Request with scope=project
const res = await GET(app, "/api/automations?scope=project");
expect(res.status).toBe(200);
// Route filters by scope, so only project-scoped schedules are returned
expect(res.body.every((s: any) => s.scope === "project")).toBe(true);
// Global schedules should not be in the response
expect(res.body.some((s: any) => s.scope === "global")).toBe(false);
});
it("GET /api/automations?scope=global filters to only global-scoped schedules", async () => {
const globalStore = createMockAutomationStore("global");
globalStore.listSchedules.mockResolvedValue([
FAKE_GLOBAL_SCHEDULE,
FAKE_PROJ_A_SCHEDULE,
FAKE_PROJ_B_SCHEDULE,
]);
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
});
// Request with scope=global
const res = await GET(app, "/api/automations?scope=global");
expect(res.status).toBe(200);
// Route filters by scope, so only global-scoped schedules are returned
expect(res.body.every((s: any) => s.scope === "global")).toBe(true);
// Project schedules should not be in the response
expect(res.body.some((s: any) => s.scope === "project")).toBe(false);
});
it("GET /api/routines?scope=project filters to only project-scoped routines", async () => {
const globalStore = createMockRoutineStore("global");
globalStore.listRoutines.mockResolvedValue([
FAKE_GLOBAL_ROUTINE,
FAKE_PROJ_A_ROUTINE,
FAKE_PROJ_B_ROUTINE,
]);
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
});
// Request with scope=project
const res = await GET(app, "/api/routines?scope=project");
expect(res.status).toBe(200);
// Route filters by scope
expect(res.body.every((r: any) => r.scope === "project")).toBe(true);
expect(res.body.some((r: any) => r.scope === "global")).toBe(false);
});
it("GET /api/routines?scope=global filters to only global-scoped routines", async () => {
const globalStore = createMockRoutineStore("global");
globalStore.listRoutines.mockResolvedValue([
FAKE_GLOBAL_ROUTINE,
FAKE_PROJ_A_ROUTINE,
FAKE_PROJ_B_ROUTINE,
]);
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
});
// Request with scope=global
const res = await GET(app, "/api/routines?scope=global");
expect(res.status).toBe(200);
// Route filters by scope
expect(res.body.every((r: any) => r.scope === "global")).toBe(true);
expect(res.body.some((r: any) => r.scope === "project")).toBe(false);
});
it("POST /api/routines/:id/run with scope=global executes global routine", async () => {
const globalStore = createMockRoutineStore("global");
const routineRunner = createMockRoutineRunner();
globalStore.getRoutine.mockResolvedValue({ ...FAKE_GLOBAL_ROUTINE });
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
routineRunner: routineRunner as any,
});
const res = await REQUEST(app, "POST", "/api/routines/routine-global-1/run?scope=global");
expect(res.status).toBe(200);
expect(routineRunner.triggerManual).toHaveBeenCalledWith("routine-global-1");
});
it("POST /api/routines/:id/run with scope=project for global routine returns 404", async () => {
const globalStore = createMockRoutineStore("global");
const routineRunner = createMockRoutineRunner();
// Routine is global-scoped
globalStore.getRoutine.mockResolvedValue({ ...FAKE_GLOBAL_ROUTINE });
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
routineRunner: routineRunner as any,
});
// Request with scope=project but routine is global
const res = await REQUEST(app, "POST", "/api/routines/routine-global-1/run?scope=project");
expect(res.status).toBe(404);
// RoutineRunner should NOT be called - scope mismatch
expect(routineRunner.triggerManual).not.toHaveBeenCalled();
});
it("fallback to global automation store is deterministic (no lane hopping)", async () => {
const globalStore = createMockAutomationStore("global");
// Only project-scoped schedules exist in the store
globalStore.listSchedules.mockResolvedValue([FAKE_PROJ_A_SCHEDULE, FAKE_PROJ_B_SCHEDULE]);
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
});
// scope=global should return empty (no global schedules exist)
const res = await GET(app, "/api/automations?scope=global");
expect(res.status).toBe(200);
// Should NOT have hopped to project lane - should return empty
expect(res.body).toHaveLength(0);
});
it("fallback to global routine store is deterministic (no lane hopping)", async () => {
const globalStore = createMockRoutineStore("global");
// Only project-scoped routines exist
globalStore.listRoutines.mockResolvedValue([FAKE_PROJ_A_ROUTINE, FAKE_PROJ_B_ROUTINE]);
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
});
// scope=global should return empty (no global routines exist)
const res = await GET(app, "/api/routines?scope=global");
expect(res.status).toBe(200);
// Should NOT have hopped to project lane - should return empty
expect(res.body).toHaveLength(0);
});
it("POST /api/automations with scope=project creates in project lane", async () => {
const globalStore = createMockAutomationStore("global");
globalStore.createSchedule.mockResolvedValue({ ...FAKE_PROJ_A_SCHEDULE });
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
});
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
name: "New Project Schedule",
command: "echo test",
scheduleType: "daily",
scope: "project",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
// Verify the schedule was created with project scope
expect(globalStore.createSchedule).toHaveBeenCalledWith(
expect.objectContaining({ scope: "project" }),
);
});
it("POST /api/routines with scope=project creates in project lane", async () => {
const globalStore = createMockRoutineStore("global");
globalStore.createRoutine.mockResolvedValue({ ...FAKE_PROJ_A_ROUTINE });
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
});
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({
name: "New Project Routine",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "project",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
// Verify the routine was created with project scope
expect(globalStore.createRoutine).toHaveBeenCalledWith(
expect.objectContaining({ scope: "project" }),
);
});
// ── engineManager integration tests ─────────────────────────────────────────────
//
// These tests verify that engineManager.getEngine(projectId) is called for
// project-scoped automation/routine routes when projectId is provided.
it("GET /api/automations?scope=project&projectId=proj-a calls engineManager.getEngine(proj-a)", async () => {
const globalStore = createMockAutomationStore("global");
// Engine A's store has unique data
const projAStore = {
listSchedules: vi.fn().mockResolvedValue([FAKE_PROJ_A_SCHEDULE]),
};
const projAEngine = { getAutomationStore: vi.fn().mockReturnValue(projAStore) };
const engineManager = createMockEngineManager();
engineManager.getEngine.mockImplementation((id: string) => {
if (id === "proj-a") return projAEngine;
return undefined;
});
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
engineManager: engineManager as any,
});
const res = await GET(app, "/api/automations?scope=project&projectId=proj-a");
expect(res.status).toBe(200);
// Verify engine was consulted
expect(engineManager.getEngine).toHaveBeenCalledWith("proj-a");
// Verify engine's store was used
expect(projAStore.listSchedules).toHaveBeenCalled();
// Default store should NOT have been called
expect(globalStore.listSchedules).not.toHaveBeenCalled();
});
it("GET /api/automations?scope=project&projectId=proj-a never touches proj-b engine", async () => {
const globalStore = createMockAutomationStore("global");
const projAStore = { listSchedules: vi.fn().mockResolvedValue([FAKE_PROJ_A_SCHEDULE]) };
const projAEngine = { getAutomationStore: vi.fn().mockReturnValue(projAStore) };
const projBEngine = { getAutomationStore: vi.fn() }; // Should never be accessed
const engineManager = createMockEngineManager();
engineManager.getEngine.mockImplementation((id: string) => {
if (id === "proj-a") return projAEngine;
if (id === "proj-b") return projBEngine;
return undefined;
});
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
engineManager: engineManager as any,
});
await GET(app, "/api/automations?scope=project&projectId=proj-a");
// proj-b engine should NEVER be accessed
expect(engineManager.getEngine).toHaveBeenCalledWith("proj-a");
expect(engineManager.getEngine).not.toHaveBeenCalledWith("proj-b");
});
it("GET /api/routines?scope=project&projectId=proj-b calls engineManager.getEngine(proj-b)", async () => {
const globalStore = createMockRoutineStore("global");
const projBStore = {
listRoutines: vi.fn().mockResolvedValue([FAKE_PROJ_B_ROUTINE]),
};
const projBEngine = { getRoutineStore: vi.fn().mockReturnValue(projBStore) };
const engineManager = createMockEngineManager();
engineManager.getEngine.mockImplementation((id: string) => {
if (id === "proj-b") return projBEngine;
return undefined;
});
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
engineManager: engineManager as any,
});
const res = await GET(app, "/api/routines?scope=project&projectId=proj-b");
expect(res.status).toBe(200);
// Verify engine was consulted
expect(engineManager.getEngine).toHaveBeenCalledWith("proj-b");
// Verify engine's store was used
expect(projBStore.listRoutines).toHaveBeenCalled();
// Default store should NOT have been called
expect(globalStore.listRoutines).not.toHaveBeenCalled();
});
it("GET /api/routines?scope=project&projectId=proj-b never touches proj-a engine", async () => {
const globalStore = createMockRoutineStore("global");
const projAEngine = { getRoutineStore: vi.fn() }; // Should never be accessed
const projBStore = { listRoutines: vi.fn().mockResolvedValue([FAKE_PROJ_B_ROUTINE]) };
const projBEngine = { getRoutineStore: vi.fn().mockReturnValue(projBStore) };
const engineManager = createMockEngineManager();
engineManager.getEngine.mockImplementation((id: string) => {
if (id === "proj-a") return projAEngine;
if (id === "proj-b") return projBEngine;
return undefined;
});
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
engineManager: engineManager as any,
});
await GET(app, "/api/routines?scope=project&projectId=proj-b");
// proj-a engine should NEVER be accessed
expect(engineManager.getEngine).toHaveBeenCalledWith("proj-b");
expect(engineManager.getEngine).not.toHaveBeenCalledWith("proj-a");
});
it("POST /api/routines/:id/run?scope=project&projectId=proj-a uses engine's RoutineRunner", async () => {
const globalStore = createMockRoutineStore("global");
const projARoutineRunner = {
triggerManual: vi.fn().mockResolvedValue({ success: true }),
triggerWebhook: vi.fn().mockResolvedValue({ success: true }),
};
const projAEngine = {
getRoutineStore: vi.fn().mockReturnValue({
getRoutine: vi.fn().mockResolvedValue({ ...FAKE_PROJ_A_ROUTINE }),
}),
getRoutineRunner: vi.fn().mockReturnValue(projARoutineRunner),
};
const engineManager = createMockEngineManager();
engineManager.getEngine.mockImplementation((id: string) => {
if (id === "proj-a") return projAEngine;
return undefined;
});
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
engineManager: engineManager as any,
});
const res = await REQUEST(app, "POST", "/api/routines/routine-proj-a/run?scope=project&projectId=proj-a");
expect(res.status).toBe(200);
// Verify engine was consulted
expect(engineManager.getEngine).toHaveBeenCalledWith("proj-a");
// Verify engine's runner was used
expect(projARoutineRunner.triggerManual).toHaveBeenCalledWith("routine-proj-a");
});
it("POST /api/routines/:id/run?scope=project&projectId=proj-b never uses proj-a engine", async () => {
const globalRoutineRunner = createMockRoutineRunner();
const projAEngine = { getRoutineRunner: vi.fn() }; // Should never be accessed
const projBEngine = {
getRoutineStore: vi.fn().mockReturnValue({
getRoutine: vi.fn().mockResolvedValue({ ...FAKE_PROJ_B_ROUTINE }),
}),
getRoutineRunner: vi.fn().mockReturnValue({
triggerManual: vi.fn().mockResolvedValue({ success: true }),
triggerWebhook: vi.fn().mockResolvedValue({ success: true }),
}),
};
const engineManager = createMockEngineManager();
engineManager.getEngine.mockImplementation((id: string) => {
if (id === "proj-a") return projAEngine;
if (id === "proj-b") return projBEngine;
return undefined;
});
const store = createMockStore();
const app = createServer(store, {
routineStore: createMockRoutineStore("global") as any,
routineRunner: globalRoutineRunner as any,
engineManager: engineManager as any,
});
await REQUEST(app, "POST", "/api/routines/routine-proj-b/run?scope=project&projectId=proj-b");
// proj-a engine should NEVER be accessed
expect(engineManager.getEngine).toHaveBeenCalledWith("proj-b");
expect(engineManager.getEngine).not.toHaveBeenCalledWith("proj-a");
});
it("GET /api/automations?scope=project without projectId falls back to default store", async () => {
const globalStore = createMockAutomationStore("global");
globalStore.listSchedules.mockResolvedValue([FAKE_PROJ_A_SCHEDULE]);
const engineManager = createMockEngineManager();
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
engineManager: engineManager as any,
});
const res = await GET(app, "/api/automations?scope=project");
expect(res.status).toBe(200);
// Engine should NOT have been consulted (no projectId)
expect(engineManager.getEngine).not.toHaveBeenCalled();
// Default store should be used
expect(globalStore.listSchedules).toHaveBeenCalled();
});
it("GET /api/routines?scope=project without projectId falls back to default store", async () => {
const globalStore = createMockRoutineStore("global");
globalStore.listRoutines.mockResolvedValue([FAKE_PROJ_A_ROUTINE]);
const engineManager = createMockEngineManager();
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
engineManager: engineManager as any,
});
const res = await GET(app, "/api/routines?scope=project");
expect(res.status).toBe(200);
// Engine should NOT have been consulted (no projectId)
expect(engineManager.getEngine).not.toHaveBeenCalled();
// Default store should be used
expect(globalStore.listRoutines).toHaveBeenCalled();
});
});
});