feat(FN-1768): merge fusion/fn-1768
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user