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

This commit is contained in:
gsxdsm
2026-04-16 00:08:06 -07:00
parent 23ca8bd3ef
commit c020764107
4 changed files with 1453 additions and 15 deletions

View File

@@ -18,6 +18,7 @@ import {
saveOnboardingState,
clearOnboardingState,
markOnboardingCompleted,
getStepData,
type OnboardingStep,
} from "./model-onboarding-state";
import type { SectionId } from "./SettingsModal";
@@ -33,6 +34,12 @@ export interface ModelOnboardingModalProps {
onOpenGitHubImport?: () => void;
}
/** Outcome states for OAuth login attempts */
export type LoginOutcome = "pending" | "success" | "timeout" | "failed" | "cancelled";
/** Maximum number of poll cycles before timing out (150 × 2s = 5 minutes) */
const MAX_POLL_CYCLES = 150;
/**
* Multi-step onboarding modal that guides users through:
* 1. AI Setup - Provider credential setup (OAuth login or API key entry) and default model selection
@@ -67,6 +74,8 @@ export function ModelOnboardingModal({
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
const [apiKeyErrors, setApiKeyErrors] = useState<Record<string, string>>({});
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [loginOutcomes, setLoginOutcomes] = useState<Record<string, LoginOutcome>>({});
const pollCountRef = useRef<number>(0);
// Step definitions for progress indicator
const steps = [
@@ -126,6 +135,48 @@ export function ModelOnboardingModal({
);
}, [loadAuthStatus, loadModels, loadGlobalSettings]);
// Restore login outcomes from persisted state on mount
useEffect(() => {
const persistedStepData = getStepData("ai-setup");
if (persistedStepData?.loginOutcomes) {
const persistedOutcomes = persistedStepData.loginOutcomes as Record<string, LoginOutcome>;
// Filter out stale "pending" entries from previous sessions
const filteredOutcomes: Record<string, LoginOutcome> = {};
for (const [providerId, outcome] of Object.entries(persistedOutcomes)) {
if (outcome !== "pending") {
filteredOutcomes[providerId] = outcome;
}
}
if (Object.keys(filteredOutcomes).length > 0) {
setLoginOutcomes(filteredOutcomes);
}
}
}, []);
// Helper to persist login outcome to onboarding state
const persistLoginOutcome = useCallback((providerId: string, outcome: LoginOutcome) => {
saveOnboardingState(step, {
completedSteps,
stepData: {
"ai-setup": {
loginOutcomes: {
[providerId]: outcome,
},
},
},
});
}, [step, completedSteps]);
// Persist terminal login outcomes whenever they transition
useEffect(() => {
const terminalOutcomes = Object.entries(loginOutcomes).filter(
([_, outcome]) => outcome !== "pending"
);
for (const [providerId, outcome] of terminalOutcomes) {
persistLoginOutcome(providerId, outcome);
}
}, [loginOutcomes, persistLoginOutcome]);
// Check if we have GitHub provider
const githubProvider = authProviders.find((p) => p.id === "github");
const hasGithubProvider = !!githubProvider;
@@ -166,13 +217,41 @@ export function ModelOnboardingModal({
// OAuth login handler
const handleLogin = useCallback(
async (providerId: string) => {
// Clear any previous terminal outcome before starting a new login attempt
setLoginOutcomes((prev) => {
const outcome = prev[providerId];
if (outcome && outcome !== "pending") {
const { [providerId]: _, ...rest } = prev;
return rest;
}
return prev;
});
// Set outcome to pending
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "pending" }));
setAuthActionInProgress(providerId);
pollCountRef.current = 0;
try {
const { url } = await loginProvider(providerId);
window.open(url, "_blank");
// Poll for auth completion
pollIntervalRef.current = setInterval(async () => {
pollCountRef.current++;
// Check for timeout
if (pollCountRef.current >= MAX_POLL_CYCLES) {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
setAuthActionInProgress(null);
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "timeout" }));
addToast("Login timed out. Please try again.", "warning");
return;
}
try {
const { providers } = await fetchAuthStatus();
setAuthProviders(providers);
@@ -183,6 +262,7 @@ export function ModelOnboardingModal({
pollIntervalRef.current = null;
}
setAuthActionInProgress(null);
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "success" }));
addToast("Login successful", "success");
}
} catch {
@@ -190,16 +270,35 @@ export function ModelOnboardingModal({
}
}, 2000);
} catch (err: unknown) {
addToast(
err instanceof Error ? err.message : "Login failed",
"error",
);
// Check for concurrent login (409) conflict
const isConcurrentLogin =
(err instanceof Error && err.message.includes("already in progress")) ||
(err && typeof err === "object" && "status" in err && (err as { status: number }).status === 409);
if (isConcurrentLogin) {
addToast("Login already in progress. Please wait or cancel the current attempt.", "warning");
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "failed" }));
} else {
addToast(err instanceof Error ? err.message : "Login failed", "error");
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "failed" }));
}
setAuthActionInProgress(null);
}
},
[addToast],
);
// Cancellation handler for in-progress logins
const handleCancelLogin = useCallback((providerId: string) => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
setAuthActionInProgress(null);
pollCountRef.current = 0;
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "cancelled" }));
}, []);
// API key save handler
const handleSaveApiKey = useCallback(
async (providerId: string) => {
@@ -581,11 +680,20 @@ export function ModelOnboardingModal({
</div>
<div>
{authActionInProgress === provider.id ? (
<button className="btn btn-sm" disabled>
{provider.authenticated
? "Logging out…"
: "Waiting for login…"}
</button>
<>
<button className="btn btn-sm" disabled>
{provider.authenticated
? "Logging out…"
: "Waiting for login…"}
</button>
<button
className="btn btn-sm"
onClick={() => handleCancelLogin(provider.id)}
style={{ marginLeft: 8 }}
>
Cancel
</button>
</>
) : provider.authenticated ? (
<button
className="btn btn-sm"
@@ -602,6 +710,18 @@ export function ModelOnboardingModal({
</button>
)}
</div>
{/* Show timeout message */}
{loginOutcomes[provider.id] === "timeout" && authActionInProgress !== provider.id && (
<p className="onboarding-helper-text" style={{ marginTop: 4 }}>
Login timed out. Please try again.
</p>
)}
{/* Show failure message */}
{loginOutcomes[provider.id] === "failed" && authActionInProgress !== provider.id && (
<p className="field-error" style={{ marginTop: 4 }}>
Login failed. Please try again.
</p>
)}
</div>
))}
@@ -758,11 +878,20 @@ export function ModelOnboardingModal({
</div>
<div>
{authActionInProgress === "github" ? (
<button className="btn btn-sm" disabled>
{isGithubAuthenticated
? "Logging out…"
: "Waiting for login…"}
</button>
<>
<button className="btn btn-sm" disabled>
{isGithubAuthenticated
? "Logging out…"
: "Waiting for login…"}
</button>
<button
className="btn btn-sm"
onClick={() => handleCancelLogin("github")}
style={{ marginLeft: 8 }}
>
Cancel
</button>
</>
) : isGithubAuthenticated ? (
<button
className="btn btn-sm"
@@ -779,6 +908,18 @@ export function ModelOnboardingModal({
</button>
)}
</div>
{/* Show timeout message */}
{loginOutcomes["github"] === "timeout" && authActionInProgress !== "github" && (
<p className="onboarding-helper-text" style={{ marginTop: 4 }}>
Login timed out. Please try again.
</p>
)}
{/* Show failure message */}
{loginOutcomes["github"] === "failed" && authActionInProgress !== "github" && (
<p className="field-error" style={{ marginTop: 4 }}>
Login failed. Please try again.
</p>
)}
</div>
{!isGithubAuthenticated && (
<p className="onboarding-helper-text">

View File

@@ -44,12 +44,14 @@ const mockGetOnboardingState = vi.fn();
const mockSaveOnboardingState = vi.fn();
const mockClearOnboardingState = vi.fn();
const mockMarkOnboardingCompleted = vi.fn();
const mockGetStepData = vi.fn();
vi.mock("../model-onboarding-state", () => ({
getOnboardingState: (...args: unknown[]) => mockGetOnboardingState(...args),
saveOnboardingState: (...args: unknown[]) => mockSaveOnboardingState(...args),
clearOnboardingState: (...args: unknown[]) => mockClearOnboardingState(...args),
markOnboardingCompleted: (...args: unknown[]) => mockMarkOnboardingCompleted(...args),
getStepData: (...args: unknown[]) => mockGetStepData(...args),
}));
const defaultAuthProviders: AuthProvider[] = [
@@ -83,7 +85,6 @@ async function navigateToFirstTaskStep() {
beforeEach(() => {
vi.clearAllMocks();
mockFetchAuthStatus.mockResolvedValue({ providers: defaultAuthProviders });
mockFetchModels.mockResolvedValue({ models: defaultModels, favoriteProviders: [], favoriteModels: [] });
mockFetchGlobalSettings.mockResolvedValue({});
mockUpdateGlobalSettings.mockResolvedValue({});
@@ -96,6 +97,10 @@ beforeEach(() => {
mockSaveOnboardingState.mockImplementation(() => {});
mockClearOnboardingState.mockImplementation(() => {});
mockMarkOnboardingCompleted.mockImplementation(() => {});
mockGetStepData.mockReturnValue(null);
// Reset mockFetchAuthStatus to default - use mockImplementation for clear control
mockFetchAuthStatus.mockReset();
mockFetchAuthStatus.mockImplementation(() => Promise.resolve({ providers: defaultAuthProviders }));
});
afterEach(() => {
@@ -1228,4 +1233,296 @@ describe("ModelOnboardingModal", () => {
expect(screen.queryByText("You can connect GitHub later from Settings → Authentication.")).toBeNull();
});
});
describe("Login action handling", () => {
it("shows Cancel button while login is in progress", async () => {
const mockWindowOpen = vi.fn();
vi.spyOn(window, "open").mockImplementation(mockWindowOpen);
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Login")).toBeTruthy();
});
fireEvent.click(screen.getByText("Login"));
await waitFor(() => {
expect(screen.getByText("Waiting for login…")).toBeTruthy();
expect(screen.getByText("Cancel")).toBeTruthy();
});
});
it("cancels login when Cancel button is clicked", async () => {
const mockWindowOpen = vi.fn();
vi.spyOn(window, "open").mockImplementation(mockWindowOpen);
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Login")).toBeTruthy();
});
fireEvent.click(screen.getByText("Login"));
await waitFor(() => {
expect(screen.getByText("Cancel")).toBeTruthy();
});
fireEvent.click(screen.getByText("Cancel"));
await waitFor(() => {
// Login button should be shown again
expect(screen.getByText("Login")).toBeTruthy();
// Waiting for login should no longer be shown
expect(screen.queryByText("Waiting for login…")).toBeNull();
// Cancel button should no longer be shown
expect(screen.queryByText("Cancel")).toBeNull();
});
});
it("login failure shows error toast and sets outcome to failed", async () => {
mockLoginProvider.mockRejectedValueOnce(new Error("Login failed: Invalid credentials"));
const addToast = vi.fn();
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Login")).toBeTruthy();
});
fireEvent.click(screen.getByText("Login"));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Login failed: Invalid credentials", "error");
});
// Login button should be shown again
expect(screen.getByText("Login")).toBeTruthy();
// Error message should be shown
expect(screen.getByText("Login failed. Please try again.")).toBeTruthy();
});
it("409 concurrent login shows specific toast message", async () => {
const error = new Error("Login already in progress");
(error as unknown as { status: number }).status = 409;
mockLoginProvider.mockRejectedValueOnce(error);
const addToast = vi.fn();
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Login")).toBeTruthy();
});
fireEvent.click(screen.getByText("Login"));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
"Login already in progress. Please wait or cancel the current attempt.",
"warning"
);
});
});
it("successful login shows success toast and persists outcome", async () => {
const mockWindowOpen = vi.fn();
vi.spyOn(window, "open").mockImplementation(mockWindowOpen);
// Track call count to return different values
let callCount = 0;
mockFetchAuthStatus.mockImplementation(() => {
callCount++;
// First call (initial load) - provider is NOT authenticated
// Subsequent calls (polling) - provider IS authenticated
return Promise.resolve({
providers: [
{
id: "anthropic",
name: "Anthropic",
authenticated: callCount > 1,
type: "oauth",
},
],
});
});
const addToast = vi.fn();
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={addToast} />);
// Wait for Login button to appear (provider not authenticated initially)
await waitFor(() => {
expect(screen.getByText("Login")).toBeTruthy();
});
// Click Login
fireEvent.click(screen.getByText("Login"));
// Wait for the login to complete (poll detects authenticated on 2nd call)
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Login successful", "success");
}, { timeout: 3000 });
// Check that login outcome was persisted
const saveCall = mockSaveOnboardingState.mock.calls.find(
(call) => call[1]?.stepData?.["ai-setup"]?.loginOutcomes?.anthropic === "success"
);
expect(saveCall).toBeDefined();
});
it("login outcome persisted to stepData after successful login", async () => {
const mockWindowOpen = vi.fn();
vi.spyOn(window, "open").mockImplementation(mockWindowOpen);
// Track call count to return different values
let callCount = 0;
mockFetchAuthStatus.mockImplementation(() => {
callCount++;
// First call (initial load) - provider is NOT authenticated
// Subsequent calls (polling) - provider IS authenticated
return Promise.resolve({
providers: [
{
id: "anthropic",
name: "Anthropic",
authenticated: callCount > 1,
type: "oauth",
},
],
});
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
// Wait for Login button to appear
await waitFor(() => {
expect(screen.getByText("Login")).toBeTruthy();
});
// Click Login
fireEvent.click(screen.getByText("Login"));
// Wait for saveOnboardingState to be called with success outcome
await waitFor(() => {
const successCall = mockSaveOnboardingState.mock.calls.find(
(call) => call[1]?.stepData?.["ai-setup"]?.loginOutcomes?.anthropic === "success"
);
expect(successCall).toBeDefined();
}, { timeout: 3000 });
});
it("stale pending outcomes are filtered on mount", async () => {
// Mock getStepData to return a stale pending outcome
mockGetStepData.mockReturnValueOnce({
loginOutcomes: {
anthropic: "pending", // Stale - from previous session
openai: "success", // Valid terminal outcome
},
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Set Up AI")).toBeTruthy();
});
// Navigate to trigger a save that would persist the filtered outcomes
// The pending outcome should NOT be persisted (filtered out)
const saveCalls = mockSaveOnboardingState.mock.calls;
const hasAnthropicPending = saveCalls.some((call) => {
const stepData = call[1]?.stepData;
return stepData?.["ai-setup"]?.loginOutcomes?.anthropic === "pending";
});
expect(hasAnthropicPending).toBe(false);
});
it("retry after timeout shows Login button again", async () => {
// Set up getStepData to return a timeout outcome
mockGetStepData.mockReturnValueOnce({
loginOutcomes: {
anthropic: "timeout",
},
});
const addToast = vi.fn();
await act(async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={addToast} />);
});
await waitFor(() => {
expect(screen.getByText("Set Up AI")).toBeTruthy();
});
// The timeout message should be shown (rendered from persisted outcome)
// Note: This test verifies the persistence layer works - the timeout message
// appears based on loginOutcomes state, not current auth status
expect(screen.getByText("Login timed out. Please try again.")).toBeTruthy();
// Cancel button should not be shown (no login in progress)
expect(screen.queryByText("Cancel")).toBeNull();
});
it("navigation remains enabled during login", async () => {
const mockWindowOpen = vi.fn();
vi.spyOn(window, "open").mockImplementation(mockWindowOpen);
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Login")).toBeTruthy();
});
fireEvent.click(screen.getByText("Login"));
await waitFor(() => {
expect(screen.getByText("Waiting for login…")).toBeTruthy();
});
// Navigation buttons should NOT be disabled during login
const nextButton = screen.getByText("Next →") as HTMLButtonElement;
expect(nextButton.disabled).toBe(false);
const skipButton = screen.getByText("Skip setup →");
expect(skipButton).toBeTruthy();
});
it("login timeout after max polls (simulated with fake timers)", async () => {
// This test verifies the timeout mechanism works correctly
// Using vi.useFakeTimers for deterministic timing
vi.useFakeTimers();
const mockWindowOpen = vi.fn();
vi.spyOn(window, "open").mockImplementation(mockWindowOpen);
const addToast = vi.fn();
// Use act to render with fake timers
await act(async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={addToast} />);
});
// Click Login to start the login flow
await act(async () => {
fireEvent.click(screen.getByText("Login"));
});
expect(screen.getByText("Waiting for login…")).toBeTruthy();
// Advance time past MAX_POLL_CYCLES * 2000ms = 300000ms
// MAX_POLL_CYCLES = 150, so 151 polls to trigger timeout
await act(async () => {
await vi.advanceTimersByTimeAsync(302000);
});
// Should show timeout toast
expect(addToast).toHaveBeenCalledWith("Login timed out. Please try again.", "warning");
// Cancel button should not be shown after timeout
expect(screen.queryByText("Cancel")).toBeNull();
vi.useRealTimers();
});
});
});

View File

@@ -10195,6 +10195,95 @@ describe("Automation routes", () => {
expect(res.status).toBe(200);
expect(res.body).toHaveLength(0);
});
// ── Cross-project isolation and fallback path tests ─────────────────
it("cross-project isolation: scope=project never leaks global schedules into results", async () => {
const mockStore = createMockAutomationStore();
// Store returns mixed results
const globalSchedule = { ...FAKE_SCHEDULE, id: "sched-global-1", scope: "global" as const };
const projectSchedule = { ...FAKE_SCHEDULE, id: "sched-proj-a-1", scope: "project" as const };
mockStore.listSchedules.mockResolvedValue([globalSchedule, projectSchedule]);
const { app } = buildApp(mockStore);
// Request for project scope (simulating proj-a request)
const res = await GET(app, "/api/automations?scope=project");
expect(res.status).toBe(200);
// All results must be project-scoped - no leakage from global
expect(res.body.every((s: any) => s.scope === "project")).toBe(true);
});
it("cross-project isolation: scope=global never includes project schedules", async () => {
const mockStore = createMockAutomationStore();
const globalSchedule = { ...FAKE_SCHEDULE, id: "sched-global-1", scope: "global" as const };
const projectSchedule = { ...FAKE_SCHEDULE, id: "sched-proj-a-1", scope: "project" as const };
mockStore.listSchedules.mockResolvedValue([globalSchedule, projectSchedule]);
const { app } = buildApp(mockStore);
// Request for global scope (simulating global request)
const res = await GET(app, "/api/automations?scope=global");
expect(res.status).toBe(200);
// All results must be global-scoped - no leakage from project
expect(res.body.every((s: any) => s.scope === "global")).toBe(true);
});
it("no opportunistic lane hopping: scope=project with empty results does not fall back to global", async () => {
const mockStore = createMockAutomationStore();
// Store only has global-scoped schedules
const globalSchedule = { ...FAKE_SCHEDULE, id: "sched-global-1", scope: "global" as const };
mockStore.listSchedules.mockResolvedValue([globalSchedule]);
const { app } = buildApp(mockStore);
// Request for project scope - should return empty, NOT switch to global
const res = await GET(app, "/api/automations?scope=project");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(0);
// Results should NOT contain global schedules
expect(res.body.some((s: any) => s.scope === "global")).toBe(false);
});
it("returns empty array when automation store unavailable (scope=project) - legacy fallback", async () => {
// Build app WITHOUT automationStore option - routes return empty array for backward compatibility
const store = createMockStore();
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
const res = await GET(app, "/api/automations?scope=project");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("returns empty array when automation store unavailable (scope=global) - legacy fallback", async () => {
// Build app WITHOUT automationStore option - routes return empty array for backward compatibility
const store = createMockStore();
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
const res = await GET(app, "/api/automations?scope=global");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("mutations with scope=project do not call global lane", async () => {
const mockStore = createMockAutomationStore();
mockStore.createSchedule.mockResolvedValue({ ...FAKE_SCHEDULE, id: "sched-proj-1", scope: "project" as const });
const { app, automationStore } = buildApp(mockStore);
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
name: "Project Schedule",
command: "echo project",
scheduleType: "hourly",
scope: "project",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
// Verify scope was set to project (not global)
expect(automationStore.createSchedule).toHaveBeenCalledWith(
expect.objectContaining({ scope: "project" }),
);
});
});
});
@@ -11023,6 +11112,138 @@ describe("Routine routes", () => {
expect(res.status).toBe(200);
expect(res.body).toHaveLength(0);
});
// ── Cross-project isolation and fallback path tests ─────────────────
it("cross-project isolation: scope=project never leaks global routines into results", async () => {
const mockStore = createMockRoutineStore();
// Store returns mixed results
const globalRoutine = { ...FAKE_ROUTINE, id: "routine-global-1", scope: "global" as const };
const projectRoutine = { ...FAKE_ROUTINE, id: "routine-proj-a-1", scope: "project" as const };
mockStore.listRoutines.mockResolvedValue([globalRoutine, projectRoutine]);
const { app } = buildRoutineApp(mockStore);
// Request for project scope (simulating proj-a request)
const res = await GET(app, "/api/routines?scope=project");
expect(res.status).toBe(200);
// All results must be project-scoped - no leakage from global
expect(res.body.every((r: any) => r.scope === "project")).toBe(true);
});
it("cross-project isolation: scope=global never includes project routines", async () => {
const mockStore = createMockRoutineStore();
const globalRoutine = { ...FAKE_ROUTINE, id: "routine-global-1", scope: "global" as const };
const projectRoutine = { ...FAKE_ROUTINE, id: "routine-proj-a-1", scope: "project" as const };
mockStore.listRoutines.mockResolvedValue([globalRoutine, projectRoutine]);
const { app } = buildRoutineApp(mockStore);
// Request for global scope (simulating global request)
const res = await GET(app, "/api/routines?scope=global");
expect(res.status).toBe(200);
// All results must be global-scoped - no leakage from project
expect(res.body.every((r: any) => r.scope === "global")).toBe(true);
});
it("no opportunistic lane hopping: scope=project with empty results does not fall back to global", async () => {
const mockStore = createMockRoutineStore();
// Store only has global-scoped routines
const globalRoutine = { ...FAKE_ROUTINE, id: "routine-global-1", scope: "global" as const };
mockStore.listRoutines.mockResolvedValue([globalRoutine]);
const { app } = buildRoutineApp(mockStore);
// Request for project scope - should return empty, NOT switch to global
const res = await GET(app, "/api/routines?scope=project");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(0);
// Results should NOT contain global routines
expect(res.body.some((r: any) => r.scope === "global")).toBe(false);
});
it("returns empty array when routine store unavailable (scope=project) - legacy fallback", async () => {
// Build app WITHOUT routineStore option - routes return empty array for backward compatibility
const store = createMockStore();
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
const res = await GET(app, "/api/routines?scope=project");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("returns empty array when routine store unavailable (scope=global) - legacy fallback", async () => {
// Build app WITHOUT routineStore option - routes return empty array for backward compatibility
const store = createMockStore();
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
const res = await GET(app, "/api/routines?scope=global");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("mutations with scope=project do not call global lane", async () => {
const mockStore = createMockRoutineStore();
mockStore.createRoutine.mockResolvedValue({ ...FAKE_ROUTINE, id: "routine-proj-1", scope: "project" as const });
const { app, routineStore } = buildRoutineApp(mockStore);
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({
name: "Project Routine",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "project",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
// Verify scope was set to project (not global)
expect(routineStore.createRoutine).toHaveBeenCalledWith(
expect.objectContaining({ scope: "project" }),
);
});
it("POST /routines/:id/run with scope mismatch does NOT call RoutineRunner", async () => {
const mockStore = createMockRoutineStore();
const { app, routineRunner } = buildRoutineApp(mockStore);
// Routine is global-scoped
mockStore.getRoutine.mockResolvedValue({ ...FAKE_ROUTINE, scope: "global" as const });
// Request with scope=project but routine is global
const res = await REQUEST(app, "POST", "/api/routines/routine-001/run?scope=project");
expect(res.status).toBe(404);
// RoutineRunner should NOT be called for mismatched scope
expect(routineRunner.triggerManual).not.toHaveBeenCalled();
});
it("POST /routines/:id/run when routine is disabled returns 400", async () => {
const mockStore = createMockRoutineStore();
// Set scope to project to match the default FAKE_ROUTINE scope
mockStore.getRoutine.mockResolvedValue({ ...FAKE_ROUTINE, scope: "project" as const, enabled: false });
const { app, routineRunner } = buildRoutineApp(mockStore);
// Request without scope (defaults to project which matches the routine's scope)
const res = await REQUEST(app, "POST", "/api/routines/routine-001/run");
expect(res.status).toBe(400);
expect(res.body.error).toContain("disabled");
// RoutineRunner should NOT be called when routine is disabled
expect(routineRunner.triggerManual).not.toHaveBeenCalled();
});
it("POST /routines/:id/run when RoutineRunner unavailable returns 503", async () => {
const mockStore = createMockRoutineStore();
mockStore.getRoutine.mockResolvedValue({ ...FAKE_ROUTINE });
// Build app without routineRunner option
const store = createMockStore();
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { routineStore: mockStore as any }));
const res = await REQUEST(app, "POST", "/api/routines/routine-001/run?scope=global");
expect(res.status).toBe(503);
expect(res.body.error).toContain("not available");
});
});
});

View File

@@ -502,3 +502,782 @@ describe("Terminal WebSocket heartbeat", () => {
);
});
});
/**
* Scoped Scheduling Resolver Regression Tests
* ===========================================
*
* These tests verify the scoped scheduling resolver invariants across automation and routine routes.
*
* Scope Resolution Precedence:
* 1. scope=global → Uses the default AutomationStore/RoutineStore from options (process-level)
* 2. scope=project → Uses the same store with scope filtering at query time
* 3. Omitted scope (legacy) → Defaults to project for POST, returns all for GET
*
* Error Contracts:
* - 400: Invalid scope value ("invalid" is not "global" or "project")
* - 503: Store unavailable when scope is specified
* - 200: Empty array when store is unavailable (legacy fallback for backward compatibility)
*
* Cross-Project Isolation:
* - scope=project requests never leak global-scoped items into results
* - scope=global requests never include project-scoped items
* - No opportunistic lane hopping: when scope=project has no results, it returns empty,
* NOT the global lane results
*
* Fallback Behavior:
* - When no AutomationStore/RoutineStore is configured, GET endpoints return []
* (This is a legacy backward-compatible behavior)
* - POST endpoints requiring a store will throw if no store is configured
*/
describe("createServer scoped scheduling resolver regressions", () => {
// ── Mock factory helpers ─────────────────────────────────────────
function createMockAutomationStore(name = "mock-automation-store") {
return {
listSchedules: vi.fn().mockResolvedValue([]),
getSchedule: vi.fn(),
createSchedule: vi.fn(),
updateSchedule: vi.fn(),
deleteSchedule: vi.fn(),
recordRun: vi.fn(),
reorderSteps: vi.fn(),
isValidCron: vi.fn().mockReturnValue(true),
};
}
function createMockRoutineStore(name = "mock-routine-store") {
return {
listRoutines: vi.fn().mockResolvedValue([]),
getRoutine: vi.fn(),
createRoutine: vi.fn(),
updateRoutine: vi.fn(),
deleteRoutine: vi.fn(),
isValidCron: vi.fn().mockReturnValue(true),
};
}
function createMockRoutineRunner() {
return {
triggerManual: vi.fn().mockResolvedValue({ success: true }),
triggerWebhook: vi.fn().mockResolvedValue({ success: true }),
};
}
// ── Mock ProjectEngineManager ───────────────────────────────────
function createMockEngineManager() {
return {
getEngine: vi.fn(),
ensureEngine: vi.fn(),
startReconciliation: vi.fn(),
};
}
// ── Test fixtures ───────────────────────────────────────────────
const FAKE_GLOBAL_SCHEDULE = {
id: "sched-global-1",
name: "Global Schedule",
scope: "global" as const,
scheduleType: "hourly" as const,
command: "echo global",
enabled: true,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const FAKE_PROJECT_SCHEDULE = {
id: "sched-proj-a-1",
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_GLOBAL_ROUTINE = {
id: "routine-global-1",
name: "Global Routine",
scope: "global" 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_PROJECT_ROUTINE = {
id: "routine-proj-a-1",
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",
};
// ── Automation lane selection tests ──────────────────────────────────────
//
// Automation lane selection: The automation store resolves based on scope parameter.
// Precedence: global lane uses process-level store, project lane uses same store
// with scope filtering. No lane switching occurs — scope=project with no project
// schedules returns empty array, NOT global schedules.
describe("Automation lane selection", () => {
it("GET /api/automations?scope=global calls only global automation store", async () => {
const globalStore = createMockAutomationStore("global");
const projectStore = createMockAutomationStore("project");
globalStore.listSchedules.mockResolvedValue([FAKE_GLOBAL_SCHEDULE]);
const engineManager = createMockEngineManager();
// engineManager returns undefined for all projects (no engine available)
engineManager.getEngine.mockReturnValue(undefined);
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
engineManager: engineManager as any,
});
const res = await GET(app, "/api/automations?scope=global");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].scope).toBe("global");
expect(globalStore.listSchedules).toHaveBeenCalledTimes(1);
});
it("GET /api/automations?scope=global returns only global schedules (no project leakage)", async () => {
const globalStore = createMockAutomationStore("global");
const projectStore = createMockAutomationStore("project");
// Global store returns mixed results, but route filters by scope
globalStore.listSchedules.mockResolvedValue([FAKE_GLOBAL_SCHEDULE, FAKE_PROJECT_SCHEDULE]);
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
});
const res = await GET(app, "/api/automations?scope=global");
expect(res.status).toBe(200);
// Route should filter by scope so only global schedules are returned
expect(res.body.every((s: any) => s.scope === "global")).toBe(true);
});
it("GET /api/automations?scope=project returns only project-scoped schedules", async () => {
const globalStore = createMockAutomationStore("global");
globalStore.listSchedules.mockResolvedValue([FAKE_GLOBAL_SCHEDULE, FAKE_PROJECT_SCHEDULE]);
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
});
const res = await GET(app, "/api/automations?scope=project");
expect(res.status).toBe(200);
// Route should filter by scope so only project schedules are returned
expect(res.body.every((s: any) => s.scope === "project")).toBe(true);
});
it("POST /api/automations with scope=global creates in global lane", async () => {
const globalStore = createMockAutomationStore("global");
globalStore.createSchedule.mockResolvedValue({ ...FAKE_GLOBAL_SCHEDULE, scope: "global" });
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
});
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
name: "Test Global Schedule",
command: "echo test",
scheduleType: "hourly",
scope: "global",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(globalStore.createSchedule).toHaveBeenCalledWith(
expect.objectContaining({ scope: "global" }),
);
});
it("POST /api/automations with scope=project creates in project lane", async () => {
const globalStore = createMockAutomationStore("global");
globalStore.createSchedule.mockResolvedValue({ ...FAKE_PROJECT_SCHEDULE, scope: "project" });
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
});
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
name: "Test Project Schedule",
command: "echo test",
scheduleType: "daily",
scope: "project",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(globalStore.createSchedule).toHaveBeenCalledWith(
expect.objectContaining({ scope: "project" }),
);
});
it("GET /api/automations?scope=invalid returns 400 when automation store is configured", async () => {
const globalStore = createMockAutomationStore("global");
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
});
const res = await GET(app, "/api/automations?scope=invalid");
expect(res.status).toBe(400);
expect(res.body.error).toContain('Invalid scope value "invalid"');
});
it("GET /api/automations?scope=invalid returns empty array when no automation store configured (early exit)", async () => {
const store = createMockStore();
// When no automationStore is configured, route returns empty array BEFORE scope validation
const app = createServer(store);
const res = await GET(app, "/api/automations?scope=invalid");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("GET /api/automations without scope returns all (legacy default)", async () => {
const globalStore = createMockAutomationStore("global");
globalStore.listSchedules.mockResolvedValue([FAKE_GLOBAL_SCHEDULE, FAKE_PROJECT_SCHEDULE]);
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
});
const res = await GET(app, "/api/automations");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
});
it("GET /api/automations when no automation store configured returns empty array", async () => {
const store = createMockStore();
const app = createServer(store); // No automationStore option
const res = await GET(app, "/api/automations");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("POST /api/automations/:id/run with scope=global runs global schedule", async () => {
const globalStore = createMockAutomationStore("global");
globalStore.getSchedule.mockResolvedValue({ ...FAKE_GLOBAL_SCHEDULE });
globalStore.recordRun.mockResolvedValue({ ...FAKE_GLOBAL_SCHEDULE });
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
});
const res = await REQUEST(app, "POST", "/api/automations/sched-global-1/run?scope=global");
expect(res.status).toBe(200);
expect(res.body.schedule).toBeDefined();
expect(res.body.result).toBeDefined();
expect(globalStore.getSchedule).toHaveBeenCalledWith("sched-global-1");
});
it("POST /api/automations/:id/run with scope=project for global schedule returns 404", async () => {
const globalStore = createMockAutomationStore("global");
// Schedule is global-scoped
globalStore.getSchedule.mockResolvedValue({ ...FAKE_GLOBAL_SCHEDULE });
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
});
// Request with scope=project but schedule is global
const res = await REQUEST(app, "POST", "/api/automations/sched-global-1/run?scope=project");
expect(res.status).toBe(404);
expect(res.body.error).toContain("Schedule not found");
});
it("POST /api/automations/:id/toggle with scope=global toggles global schedule", async () => {
const globalStore = createMockAutomationStore("global");
globalStore.getSchedule.mockResolvedValue({ ...FAKE_GLOBAL_SCHEDULE, enabled: true });
globalStore.updateSchedule.mockResolvedValue({ ...FAKE_GLOBAL_SCHEDULE, enabled: false });
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
});
const res = await REQUEST(app, "POST", "/api/automations/sched-global-1/toggle?scope=global");
expect(res.status).toBe(200);
expect(globalStore.updateSchedule).toHaveBeenCalled();
});
it("Cross-project isolation: request for proj-a never touches proj-b dependencies", async () => {
const globalStore = createMockAutomationStore("global");
// Returns only project A's schedule
globalStore.listSchedules.mockResolvedValue([FAKE_PROJECT_SCHEDULE]);
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
});
// Request for project scope (would be proj-a in real scenario)
const res = await GET(app, "/api/automations?scope=project");
expect(res.status).toBe(200);
// All returned schedules should be project-scoped
expect(res.body.every((s: any) => s.scope === "project")).toBe(true);
});
});
// ── Routine lane selection tests ────────────────────────────────────────
//
// Routine lane selection: The routine store and routine runner resolve based on scope.
// Precedence: global lane uses process-level store/runner, project lane uses same
// with scope filtering. RoutineRunner is invoked for /run and /trigger endpoints
// when scope matches and routine is enabled.
describe("Routine lane selection", () => {
it("GET /api/routines?scope=global returns only global routines", async () => {
const globalStore = createMockRoutineStore("global");
globalStore.listRoutines.mockResolvedValue([FAKE_GLOBAL_ROUTINE, FAKE_PROJECT_ROUTINE]);
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
});
const res = await GET(app, "/api/routines?scope=global");
expect(res.status).toBe(200);
expect(res.body.every((r: any) => r.scope === "global")).toBe(true);
});
it("GET /api/routines?scope=project returns only project-scoped routines", async () => {
const globalStore = createMockRoutineStore("global");
globalStore.listRoutines.mockResolvedValue([FAKE_GLOBAL_ROUTINE, FAKE_PROJECT_ROUTINE]);
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
});
const res = await GET(app, "/api/routines?scope=project");
expect(res.status).toBe(200);
expect(res.body.every((r: any) => r.scope === "project")).toBe(true);
});
it("POST /api/routines with scope=global creates in global lane", async () => {
const globalStore = createMockRoutineStore("global");
globalStore.createRoutine.mockResolvedValue({ ...FAKE_GLOBAL_ROUTINE });
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
});
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({
name: "Test Global Routine",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "global",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(globalStore.createRoutine).toHaveBeenCalledWith(
expect.objectContaining({ scope: "global" }),
);
});
it("POST /api/routines with scope=project creates in project lane", async () => {
const globalStore = createMockRoutineStore("global");
globalStore.createRoutine.mockResolvedValue({ ...FAKE_PROJECT_ROUTINE });
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
});
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({
name: "Test Project Routine",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "project",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(globalStore.createRoutine).toHaveBeenCalledWith(
expect.objectContaining({ scope: "project" }),
);
});
it("GET /api/routines?scope=invalid returns 400 when routine store is configured", async () => {
const globalStore = createMockRoutineStore("global");
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
});
const res = await GET(app, "/api/routines?scope=invalid");
expect(res.status).toBe(400);
expect(res.body.error).toContain('Invalid scope value "invalid"');
});
it("GET /api/routines?scope=invalid returns empty array when no routine store configured (early exit)", async () => {
const store = createMockStore();
// When no routineStore is configured, route returns empty array BEFORE scope validation
const app = createServer(store);
const res = await GET(app, "/api/routines?scope=invalid");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("GET /api/routines without scope returns all (legacy default)", async () => {
const globalStore = createMockRoutineStore("global");
globalStore.listRoutines.mockResolvedValue([FAKE_GLOBAL_ROUTINE, FAKE_PROJECT_ROUTINE]);
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
});
const res = await GET(app, "/api/routines");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
});
it("GET /api/routines when no routine store configured returns empty array", async () => {
const store = createMockStore();
const app = createServer(store); // No routineStore option
const res = await GET(app, "/api/routines");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("POST /api/routines/:id/run with scope=global runs global routine via RoutineRunner", 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(res.body.routine).toBeDefined();
expect(res.body.result).toBeDefined();
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");
globalStore.getRoutine.mockResolvedValue({ ...FAKE_GLOBAL_ROUTINE });
const routineRunner = createMockRoutineRunner();
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);
expect(res.body.error).toContain("Routine not found");
// RoutineRunner should NOT be called for mismatched scope
expect(routineRunner.triggerManual).not.toHaveBeenCalled();
});
it("POST /api/routines/:id/trigger with scope=global triggers 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/trigger?scope=global");
expect(res.status).toBe(200);
expect(routineRunner.triggerManual).toHaveBeenCalledWith("routine-global-1");
});
it("GET /api/routines/:id/runs with scope=global returns runs for global routine", async () => {
const globalStore = createMockRoutineStore("global");
const runHistory = [
{ routineId: "routine-global-1", startedAt: "2026-01-01T00:00:00.000Z", completedAt: "2026-01-01T00:01:00.000Z", success: true, output: "Done" },
];
globalStore.getRoutine.mockResolvedValue({ ...FAKE_GLOBAL_ROUTINE, runHistory });
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
});
const res = await GET(app, "/api/routines/routine-global-1/runs?scope=global");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
});
it("Cross-project isolation: request for proj-a never touches proj-b dependencies", async () => {
const globalStore = createMockRoutineStore("global");
// Returns only project-scoped routines (would be proj-b in real multi-project scenario)
globalStore.listRoutines.mockResolvedValue([FAKE_PROJECT_ROUTINE]);
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
});
// Request for project scope
const res = await GET(app, "/api/routines?scope=project");
expect(res.status).toBe(200);
// All returned routines should be project-scoped
expect(res.body.every((r: any) => r.scope === "project")).toBe(true);
});
it("POST /api/routines/:id/webhook is scope-independent (uses routine's own scope)", async () => {
const globalStore = createMockRoutineStore("global");
const routineRunner = createMockRoutineRunner();
globalStore.getRoutine.mockResolvedValue({
...FAKE_PROJECT_ROUTINE,
trigger: { type: "webhook" as const, webhookPath: "/trigger/test" },
});
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
routineRunner: routineRunner as any,
});
// Webhook without scope param - should work regardless of scope
const res = await REQUEST(app, "POST", "/api/routines/routine-proj-a-1/webhook", JSON.stringify({}), { "Content-Type": "application/json" });
expect(res.status).toBe(200);
expect(routineRunner.triggerWebhook).toHaveBeenCalled();
});
it("POST /api/routines/:id/run when routine is disabled returns 400", async () => {
const globalStore = createMockRoutineStore("global");
globalStore.getRoutine.mockResolvedValue({ ...FAKE_GLOBAL_ROUTINE, enabled: false });
const routineRunner = createMockRoutineRunner();
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(400);
expect(res.body.error).toContain("disabled");
expect(routineRunner.triggerManual).not.toHaveBeenCalled();
});
it("POST /api/routines/:id/run when no RoutineRunner returns 503", async () => {
const globalStore = createMockRoutineStore("global");
globalStore.getRoutine.mockResolvedValue({ ...FAKE_GLOBAL_ROUTINE });
const store = createMockStore();
// No routineRunner configured
const app = createServer(store, {
routineStore: globalStore as any,
});
const res = await REQUEST(app, "POST", "/api/routines/routine-global-1/run?scope=global");
expect(res.status).toBe(503);
expect(res.body.error).toContain("not available");
});
});
// ── Fallback and error contract tests ────────────────────────────────
//
// Backward-compatible defaults: omitted scope defaults to "project" for mutations.
// This preserves existing behavior while adding explicit scope selection.
// Store unavailability: when no store is configured, GET returns [] for
// backward compatibility (legacy behavior).
describe("Fallback and error contracts", () => {
it("omitted scope defaults to project for backward compatibility (POST automations)", async () => {
const globalStore = createMockAutomationStore("global");
globalStore.createSchedule.mockResolvedValue({ ...FAKE_PROJECT_SCHEDULE, scope: "project" });
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
});
// No scope specified - should default to "project"
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
name: "Test Schedule",
command: "echo test",
scheduleType: "hourly",
// scope omitted
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(globalStore.createSchedule).toHaveBeenCalledWith(
expect.objectContaining({ scope: "project" }),
);
});
it("omitted scope defaults to project for backward compatibility (POST routines)", async () => {
const globalStore = createMockRoutineStore("global");
globalStore.createRoutine.mockResolvedValue({ ...FAKE_PROJECT_ROUTINE, scope: "project" });
const store = createMockStore();
const app = createServer(store, {
routineStore: globalStore as any,
});
// No scope specified - should default to "project"
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({
name: "Test Routine",
trigger: { type: "cron", cronExpression: "0 * * * *" },
// scope omitted
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(globalStore.createRoutine).toHaveBeenCalledWith(
expect.objectContaining({ scope: "project" }),
);
});
it("scope=project with no automation store returns empty array (legacy fallback)", async () => {
const store = createMockStore();
// No automationStore configured - routes return empty array for backward compatibility
const app = createServer(store);
const res = await GET(app, "/api/automations?scope=project");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("scope=global with no automation store returns empty array (legacy fallback)", async () => {
const store = createMockStore();
// No automationStore configured - routes return empty array for backward compatibility
const app = createServer(store);
const res = await GET(app, "/api/automations?scope=global");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("scope=project with no routine store returns empty array (legacy fallback)", async () => {
const store = createMockStore();
// No routineStore configured - routes return empty array for backward compatibility
const app = createServer(store);
const res = await GET(app, "/api/routines?scope=project");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("scope=global with no routine store returns empty array (legacy fallback)", async () => {
const store = createMockStore();
// No routineStore configured - routes return empty array for backward compatibility
const app = createServer(store);
const res = await GET(app, "/api/routines?scope=global");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
});
// ── No-opportunistic-lane-hopping assertions ───────────────────────────
//
// Critical invariant: scope selection is deterministic and deterministic.
// When scope=project returns no results, the resolver must NOT fall back to
// global lane results. This prevents cross-project data leakage and ensures
// automation/routine isolation between global and project contexts.
describe("No opportunistic lane hopping", () => {
it("scope=project request never falls back to global when engine is unavailable", async () => {
const globalStore = createMockAutomationStore("global");
const engineManager = createMockEngineManager();
// No engine available for any project
engineManager.getEngine.mockReturnValue(undefined);
// Only global-scoped schedules exist
globalStore.listSchedules.mockResolvedValue([FAKE_GLOBAL_SCHEDULE]);
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
engineManager: engineManager as any,
});
// Request for project scope - should filter by scope, NOT switch to global
const res = await GET(app, "/api/automations?scope=project");
expect(res.status).toBe(200);
// Should return empty (no project-scoped schedules) not global schedules
expect(res.body).toHaveLength(0);
});
it("scope=global request never switches to project lane", async () => {
const globalStore = createMockAutomationStore("global");
const engineManager = createMockEngineManager();
// Engine IS available for some project
const mockEngine = { getTaskStore: vi.fn() };
engineManager.getEngine.mockReturnValue(mockEngine as any);
// Both global and project schedules exist
globalStore.listSchedules.mockResolvedValue([FAKE_GLOBAL_SCHEDULE, FAKE_PROJECT_SCHEDULE]);
const store = createMockStore();
const app = createServer(store, {
automationStore: globalStore as any,
engineManager: engineManager as any,
});
// Request for global scope - should only return global schedules
const res = await GET(app, "/api/automations?scope=global");
expect(res.status).toBe(200);
// All results should be global-scoped
expect(res.body.every((s: any) => s.scope === "global")).toBe(true);
// Should not have fallen back to project
expect(res.body.some((s: any) => s.scope === "project")).toBe(false);
});
});
});