Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
251 changes: 251 additions & 0 deletions apps/web/src/cloud/forkUnlinkThisPc.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import type { EnvironmentCloudLinkStateResult } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/unstable/reactivity";
import { act, useLayoutEffect } from "react";
import { create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

const testState = vi.hoisted(() => ({
signedInUserId: null as string | null,
linkState: null as EnvironmentCloudLinkStateResult | null,
getToken: vi.fn<() => Promise<string | null>>(),
link: vi.fn(),
unlink: vi.fn(),
preferences: vi.fn(),
refreshDiscovery: vi.fn(),
refreshLink: vi.fn(),
toast: vi.fn(),
}));

vi.mock("@clerk/react", () => ({
useAuth: () => ({
getToken: testState.getToken,
isSignedIn: testState.signedInUserId !== null,
userId: testState.signedInUserId,
}),
}));
vi.mock("../components/ui/toast", () => ({ toastManager: { add: testState.toast } }));
vi.mock("../state/relay", () => ({
relayEnvironmentDiscovery: { refresh: testState.refreshDiscovery },
}));
vi.mock("../state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command }));
vi.mock("./linkEnvironmentAtoms", () => ({
linkPrimaryEnvironment: testState.link,
unlinkPrimaryEnvironment: testState.unlink,
updatePrimaryEnvironmentPreferences: testState.preferences,
}));
vi.mock("./primaryCloudLinkState", () => ({
usePrimaryCloudLinkState: () => ({
target,
data: testState.linkState,
error: null,
isPending: false,
refresh: testState.refreshLink,
}),
}));
vi.mock("./publicConfig", () => ({ resolveRelayClerkTokenOptions: () => ({}) }));

import { useCloudLinkController } from "./useCloudLinkController";

const target = {
environmentId: "primary",
label: "Primary",
httpBaseUrl: "http://localhost:3773",
wsBaseUrl: "ws://localhost:3773/ws",
};

// The state the report describes: a link is stored, but there is no managed
// tunnel and publishing is off, so both switches show OFF.
const linkedWithBothSwitchesOff: EnvironmentCloudLinkStateResult = {
linked: true,
cloudUserId: "account-that-linked",
relayUrl: "https://relay.example.com",
relayIssuer: "https://relay.example.com",
managedTunnelActive: false,
publishAgentActivity: false,
};

const accountConflict = new Error(
"Could not configure environment relay access: This environment is already linked to a different cloud account. Unlink it before switching accounts.",
);

let renderer: ReactTestRenderer | null = null;
let controller: ReturnType<typeof useCloudLinkController> | null = null;

function ControllerProbe() {
const value = useCloudLinkController();
useLayoutEffect(() => {
controller = value;
});
return null;
}

async function mountController() {
await act(() => {
renderer = create(<ControllerProbe />);
});
if (controller === null) throw new Error("Controller is not mounted.");
return controller;
}

function mounted() {
if (controller === null) throw new Error("Controller is not mounted.");
return controller;
}

beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.spyOn(console, "error").mockImplementation(() => undefined);
testState.signedInUserId = "account-signed-in";
testState.linkState = linkedWithBothSwitchesOff;
testState.getToken.mockReset().mockResolvedValue("clerk-token");
for (const command of [
testState.link,
testState.unlink,
testState.preferences,
testState.refreshDiscovery,
]) {
command.mockReset().mockResolvedValue(AsyncResult.success({}));
}
testState.refreshLink.mockReset();
testState.toast.mockReset();
});

afterEach(async () => {
await act(() => renderer?.unmount());
renderer = null;
controller = null;
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

describe("unlink this PC", () => {
it("unlinks a PC linked under another account with both switches off, without a relay token", async () => {
const stuck = await mountController();
expect(stuck.linked).toBe(true);
expect(stuck.managedTunnelActive).toBe(false);
expect(stuck.publishAgentActivity).toBe(false);
expect(stuck.linkedAccountDiffers).toBe(true);

let unlinked = false;
await act(async () => {
unlinked = await mounted().unlinkThisPc();
});

expect(unlinked).toBe(true);
expect(testState.unlink).toHaveBeenCalledExactlyOnceWith({ target, clerkToken: null });
expect(testState.getToken).not.toHaveBeenCalled();
expect(testState.link).not.toHaveBeenCalled();
expect(testState.preferences).not.toHaveBeenCalled();
expect(testState.refreshLink).toHaveBeenCalled();
});

it("unlinks while signed out of T3 Connect", async () => {
testState.signedInUserId = null;
const signedOut = await mountController();
expect(signedOut.linkedAccountDiffers).toBe(false);

let unlinked = false;
await act(async () => {
unlinked = await mounted().unlinkThisPc();
});

expect(unlinked).toBe(true);
expect(testState.unlink).toHaveBeenCalledExactlyOnceWith({ target, clerkToken: null });
expect(testState.getToken).not.toHaveBeenCalled();
expect(testState.refreshDiscovery).not.toHaveBeenCalled();
});

it("also revokes at the relay when the signed-in account made the link", async () => {
testState.signedInUserId = "account-that-linked";
const sameAccount = await mountController();
expect(sameAccount.linkedAccountDiffers).toBe(false);

await act(async () => {
await mounted().unlinkThisPc();
});

expect(testState.unlink).toHaveBeenCalledExactlyOnceWith({
target,
clerkToken: "clerk-token",
});
});

it("still unlinks locally when the relay token cannot be read", async () => {
testState.signedInUserId = "account-that-linked";
testState.getToken.mockRejectedValue(new Error("token read failed"));
await mountController();

let unlinked = false;
await act(async () => {
unlinked = await mounted().unlinkThisPc();
});

expect(unlinked).toBe(true);
expect(testState.unlink).toHaveBeenCalledExactlyOnceWith({ target, clerkToken: null });
});

it("reports a failed unlink and leaves the link in place", async () => {
testState.unlink.mockResolvedValue(
AsyncResult.failure(Cause.fail(new Error("Could not unlink the environment from cloud."))),
);
await mountController();

let unlinked = true;
await act(async () => {
unlinked = await mounted().unlinkThisPc();
});

expect(unlinked).toBe(false);
expect(mounted().operationError).toBe("Could not unlink the environment from cloud.");
expect(testState.refreshLink).toHaveBeenCalled();
});

it("points the different-account conflict at the unlink control", async () => {
testState.link.mockResolvedValue(AsyncResult.failure(Cause.fail(accountConflict)));
await mountController();

let linked = true;
await act(async () => {
linked = await mounted().reconcileCloudState({ managedTunnel: true, publish: false });
});

expect(linked).toBe(false);
expect(testState.link).toHaveBeenCalledOnce();
expect(mounted().operationError).toContain('Use "Unlink this PC" in Settings > Connections');
expect(testState.toast).toHaveBeenCalledWith(
expect.objectContaining({
type: "error",
description: expect.stringContaining('Use "Unlink this PC" in Settings > Connections'),
}),
);
});

it("clears a shown conflict once the PC is unlinked", async () => {
testState.link.mockResolvedValue(AsyncResult.failure(Cause.fail(accountConflict)));
await mountController();
await act(async () => {
await mounted().reconcileCloudState({ managedTunnel: true, publish: false });
});
expect(mounted().operationError).not.toBeNull();

await act(async () => {
await mounted().unlinkThisPc();
});

expect(mounted().operationError).toBeNull();
});

it("leaves other link failures worded as they were", async () => {
testState.link.mockResolvedValue(
AsyncResult.failure(Cause.fail(new Error("Could not obtain environment link proof."))),
);
await mountController();

await act(async () => {
await mounted().reconcileCloudState({ managedTunnel: true, publish: false });
});

expect(mounted().operationError).toBe("Could not obtain environment link proof.");
});
});
100 changes: 100 additions & 0 deletions apps/web/src/cloud/forkUnlinkThisPc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { useAuth } from "@clerk/react";
import {
isAtomCommandInterrupted,
settlePromise,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";

import { relayEnvironmentDiscovery } from "../state/relay";
import { useAtomCommand } from "../state/use-atom-command";
import { unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom } from "./linkEnvironmentAtoms";
import type { usePrimaryCloudLinkState } from "./primaryCloudLinkState";
import { resolveRelayClerkTokenOptions } from "./publicConfig";

export const UNLINK_THIS_PC_LABEL = "Unlink this PC";

// The environment server's wording when a relay config names another account
// than the stored link (validateLinkedCloudUser in apps/server/src/cloud).
const LINKED_ACCOUNT_CONFLICT = "linked to a different cloud account";

/**
* Rewrites the server's different-account conflict so it names the control
* that clears it. Every other failure message passes through unchanged.
*/
export function describeCloudLinkFailure(message: string): string {
return message.includes(LINKED_ACCOUNT_CONFLICT)
? `This PC is already linked to a different T3 Connect account. Use "${UNLINK_THIS_PC_LABEL}" in Settings > Connections, then try again.`
: message;
}

/**
* True only when both account ids are known and differ. An unknown id on
* either side is not a mismatch.
*/
function isLinkedToDifferentAccount(input: {
readonly linkedCloudUserId: string | null | undefined;
readonly signedInUserId: string | null | undefined;
}): boolean {
return Boolean(
input.linkedCloudUserId &&
input.signedInUserId &&
input.linkedCloudUserId !== input.signedInUserId,
);
}

/**
* The fork's explicit unlink for the primary environment. Unlike
* `reconcileCloudState`, it does not depend on what the switches show, so it
* is reachable from every linked state, and it never needs a relay token: the
* local unlink always runs, and the relay revoke is attempted only when the
* signed-in account is not known to differ from the one that made the link.
*/
export function useForkUnlinkThisPc(input: {
readonly linkState: ReturnType<typeof usePrimaryCloudLinkState>;
readonly clearFailure: () => void;
readonly reportFailure: (cause: unknown) => void;
}) {
const { getToken, isSignedIn, userId } = useAuth();
const unlinkPrimaryEnvironment = useAtomCommand(unlinkPrimaryEnvironmentAtom, {
reportFailure: false,
});
const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, {
reportFailure: false,
});
const linkedAccountDiffers = isLinkedToDifferentAccount({
linkedCloudUserId: input.linkState.data?.cloudUserId,
signedInUserId: userId,
});

const unlinkThisPc = async (): Promise<boolean> => {
input.clearFailure();
const target = input.linkState.target;
if (!target) {
input.reportFailure(new Error("Local environment is not ready yet."));
return false;
}
const canRevokeAtRelay = Boolean(isSignedIn) && !linkedAccountDiffers;
const tokenResult = canRevokeAtRelay
? await settlePromise(() => getToken(resolveRelayClerkTokenOptions()))
: null;
const unlinkResult = await unlinkPrimaryEnvironment({
target,
clerkToken: tokenResult?._tag === "Success" ? (tokenResult.value ?? null) : null,
});
input.linkState.refresh();
if (unlinkResult._tag === "Failure") {
if (!isAtomCommandInterrupted(unlinkResult)) {
input.reportFailure(squashAtomCommandFailure(unlinkResult));
}
return false;
}
// The local link is gone at this point. The relay list only exists for a
// signed-in account, and a failed refresh of it does not undo the unlink.
if (isSignedIn) {
await refreshRelayEnvironments();
}
return true;
};

return { linkedAccountDiffers, unlinkThisPc };
}
13 changes: 12 additions & 1 deletion apps/web/src/cloud/useCloudLinkController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useState } from "react";
import { toastManager } from "../components/ui/toast";
import { relayEnvironmentDiscovery } from "../state/relay";
import { useAtomCommand } from "../state/use-atom-command";
import { describeCloudLinkFailure, useForkUnlinkThisPc } from "./forkUnlinkThisPc";
import {
linkPrimaryEnvironment as linkPrimaryEnvironmentAtom,
unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom,
Expand Down Expand Up @@ -51,7 +52,9 @@ export function useCloudLinkController() {
const [operationError, setOperationError] = useState<string | null>(null);

const reportUpdateFailure = (cause: unknown) => {
const message = cause instanceof Error ? cause.message : "Could not update T3 Connect access.";
const message = describeCloudLinkFailure(
cause instanceof Error ? cause.message : "Could not update T3 Connect access.",
);
const traceId = findErrorTraceId(cause);
console.error("[t3-connect] Could not update T3 Connect", { message, traceId, cause });
setOperationError(traceId ? `${message} Trace ID: ${traceId}` : message);
Expand All @@ -76,6 +79,12 @@ export function useCloudLinkController() {
primaryCloudLinkState.data?.managedTunnelActive ?? primaryCloudLinkState.data?.linked ?? false;
const publishAgentActivity = primaryCloudLinkState.data?.publishAgentActivity ?? false;
const linked = primaryCloudLinkState.data?.linked ?? false;
// Fork: an unlink that does not depend on what the switches show.
const { linkedAccountDiffers, unlinkThisPc } = useForkUnlinkThisPc({
linkState: primaryCloudLinkState,
clearFailure: () => setOperationError(null),
reportFailure: reportUpdateFailure,
});

const reconcileCloudState = async (desired: CloudLinkDesiredState): Promise<boolean> => {
setOperationError(null);
Expand Down Expand Up @@ -155,9 +164,11 @@ export function useCloudLinkController() {
isSignedIn,
linkState: primaryCloudLinkState,
linked,
linkedAccountDiffers,
managedTunnelActive,
publishAgentActivity,
operationError,
reconcileCloudState,
unlinkThisPc,
};
}
Loading
Loading