mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-29 06:28:23 +02:00
* fix(auth): route invitees to their workspace instead of forcing /onboarding Workspace presence now wins over `onboarded_at` across every post-auth entry point, so a user invited into an existing workspace lands inside that workspace instead of being trapped in the new-workspace wizard. The redesigned onboarding flow (#1411) intentionally flipped the priority during frontend development so every login re-entered /onboarding; the backend `onboarded_at` field shipped but the flipped priority was never restored. Closes #1837. - packages/core/paths/resolve.ts: has-workspace beats !hasOnboarded. Onboarding is reachable only when the user has zero workspaces. - apps/web/app/auth/callback/page.tsx: drop the early-return on !onboarded so a `next=/invite/<id>` survives Google OAuth round-trips. - apps/web/app/(auth)/login/page.tsx: same removal in both the already-authenticated effect and the post-login handler. - packages/views/layout/use-dashboard-guard.ts: stop bouncing in-workspace users to /onboarding; rely on the resolver for zero-workspace cases. - apps/desktop/src/renderer/src/App.tsx: window-overlay now opens onboarding only when wsCount === 0 AND !hasOnboarded. - apps/web/app/(auth)/onboarding/page.tsx: defense-in-depth — bounce away if the visitor already has a workspace, even on direct URL access. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(auth): fix URLSearchParams leaking state across callback tests The previous cleanup `mockSearchParams.forEach((_v, k) => mockSearchParams.delete(k))` silently skipped entries because forEach advances its index while the underlying URLSearchParams shrinks, so a `state=next:/invite/...` set in one test bled into the next. Snapshot keys via Array.from before deleting. Also rewrites the assertions to match the new policy: an unonboarded user with a safe `next=` honors it, with a workspace lands in that workspace, and only with zero workspaces falls back to /onboarding. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
139 lines
4.1 KiB
TypeScript
139 lines
4.1 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, waitFor } from "@testing-library/react";
|
|
import { paths } from "@multica/core/paths";
|
|
|
|
const { mockPush, mockSearchParams, mockLoginWithGoogle, mockListWorkspaces } =
|
|
vi.hoisted(() => ({
|
|
mockPush: vi.fn(),
|
|
mockSearchParams: new URLSearchParams(),
|
|
mockLoginWithGoogle: vi.fn(),
|
|
mockListWorkspaces: vi.fn(),
|
|
}));
|
|
|
|
const makeUser = (overrides: Partial<{ onboarded_at: string | null }> = {}) => ({
|
|
id: "user-1",
|
|
name: "Test",
|
|
email: "test@multica.ai",
|
|
avatar_url: null,
|
|
onboarded_at: null,
|
|
onboarding_questionnaire: {},
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
updated_at: "2026-01-01T00:00:00Z",
|
|
...overrides,
|
|
});
|
|
|
|
vi.mock("next/navigation", () => ({
|
|
useRouter: () => ({ push: mockPush }),
|
|
useSearchParams: () => mockSearchParams,
|
|
}));
|
|
|
|
vi.mock("@tanstack/react-query", () => ({
|
|
useQueryClient: () => ({ setQueryData: vi.fn() }),
|
|
}));
|
|
|
|
// Preserve the real sanitizeNextUrl so the "drop unsafe ?next=" behavior is
|
|
// exercised rather than silently diverging from the source of truth.
|
|
vi.mock("@multica/core/auth", async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("@multica/core/auth")>(
|
|
"@multica/core/auth",
|
|
);
|
|
return {
|
|
...actual,
|
|
useAuthStore: (selector: (s: unknown) => unknown) =>
|
|
selector({ loginWithGoogle: mockLoginWithGoogle }),
|
|
};
|
|
});
|
|
|
|
vi.mock("@multica/core/workspace/queries", () => ({
|
|
workspaceKeys: { list: () => ["workspaces"] },
|
|
}));
|
|
|
|
vi.mock("@multica/core/api", () => ({
|
|
api: {
|
|
listWorkspaces: mockListWorkspaces,
|
|
googleLogin: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
import CallbackPage from "./page";
|
|
|
|
describe("CallbackPage", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Snapshot keys before deleting — forEach + delete skips entries because
|
|
// the iteration index advances while the underlying list shrinks.
|
|
Array.from(mockSearchParams.keys()).forEach((k) =>
|
|
mockSearchParams.delete(k),
|
|
);
|
|
mockSearchParams.set("code", "test-code");
|
|
mockLoginWithGoogle.mockResolvedValue(makeUser());
|
|
mockListWorkspaces.mockResolvedValue([]);
|
|
});
|
|
|
|
it("unonboarded user honors a safe next= (e.g. /invite/{id}) so invitees aren't trapped", async () => {
|
|
mockSearchParams.set("state", "next:/invite/abc123");
|
|
render(<CallbackPage />);
|
|
await waitFor(() => {
|
|
expect(mockPush).toHaveBeenCalledWith("/invite/abc123");
|
|
});
|
|
expect(mockPush).not.toHaveBeenCalledWith(paths.onboarding());
|
|
});
|
|
|
|
it("unonboarded user with no next= and zero workspaces lands on /onboarding", async () => {
|
|
render(<CallbackPage />);
|
|
await waitFor(() => {
|
|
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
|
|
});
|
|
});
|
|
|
|
it("unonboarded user with existing workspace lands in that workspace, not /onboarding", async () => {
|
|
mockListWorkspaces.mockResolvedValue([
|
|
{
|
|
id: "ws-1",
|
|
name: "Acme",
|
|
slug: "acme",
|
|
description: null,
|
|
context: null,
|
|
settings: {},
|
|
repos: [],
|
|
issue_prefix: "ACME",
|
|
created_at: "",
|
|
updated_at: "",
|
|
},
|
|
]);
|
|
render(<CallbackPage />);
|
|
await waitFor(() => {
|
|
expect(mockPush).toHaveBeenCalledWith(paths.workspace("acme").issues());
|
|
});
|
|
expect(mockPush).not.toHaveBeenCalledWith(paths.onboarding());
|
|
});
|
|
|
|
it("onboarded user ignores unsafe next= targets and lands on the default destination", async () => {
|
|
mockLoginWithGoogle.mockResolvedValue(
|
|
makeUser({ onboarded_at: "2026-01-01T00:00:00Z" }),
|
|
);
|
|
mockSearchParams.set("state", "next:https://evil.example");
|
|
|
|
render(<CallbackPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(mockPush).toHaveBeenCalled();
|
|
});
|
|
expect(mockPush).not.toHaveBeenCalledWith("https://evil.example");
|
|
});
|
|
|
|
it("onboarded user honors a safe next= target (e.g. /invite/{id})", async () => {
|
|
mockLoginWithGoogle.mockResolvedValue(
|
|
makeUser({ onboarded_at: "2026-01-01T00:00:00Z" }),
|
|
);
|
|
mockSearchParams.set("state", "next:/invite/abc123");
|
|
|
|
render(<CallbackPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(mockPush).toHaveBeenCalledWith("/invite/abc123");
|
|
});
|
|
});
|
|
});
|