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
52 changes: 1 addition & 51 deletions apps/cloud/src/account/organization-limits.node.test.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,8 @@
import { describe, expect, it } from "@effect/vitest";

import {
FREE_ORGANIZATIONS_PER_USER_LIMIT,
hasNonFreeOrganizationSubscription,
hasPaidOrganizationSubscription,
isOverFreeOrganizationLimit,
shouldApplyFreeOrganizationLimit,
} from "../extensions/billing/plans";
import { hasNonFreeOrganizationSubscription } from "../extensions/billing/plans";

describe("organization limits", () => {
it("treats active and trialing paid org subscriptions as paid", () => {
expect(hasPaidOrganizationSubscription([{ planId: "team", status: "active" }])).toBe(true);
expect(hasPaidOrganizationSubscription([{ planId: "team", status: "trialing" }])).toBe(true);
expect(hasPaidOrganizationSubscription([{ planId: "enterprise", status: "active" }])).toBe(
true,
);
});

it("does not treat inactive paid plans or free plans as paid", () => {
expect(hasPaidOrganizationSubscription([{ planId: "team", status: "canceled" }])).toBe(false);
expect(hasPaidOrganizationSubscription([{ planId: "free", status: "active" }])).toBe(false);
expect(hasPaidOrganizationSubscription([{ planId: null, status: "active" }])).toBe(false);
});

it("exempts any active non-free subscription from the execution rate limit", () => {
expect(hasNonFreeOrganizationSubscription([{ planId: "team", status: "active" }])).toBe(true);
expect(hasNonFreeOrganizationSubscription([{ planId: "enterprise", status: "trialing" }])).toBe(
Expand Down Expand Up @@ -53,34 +33,4 @@ describe("organization limits", () => {
);
expect(hasNonFreeOrganizationSubscription([{ planId: null, status: "active" }])).toBe(false);
});

it("applies the free org limit only when none of the user's active orgs are paid", () => {
const activeMemberships = [
{ organizationId: "org_free_1", status: "active" },
{ organizationId: "org_paid", status: "active" },
];

expect(shouldApplyFreeOrganizationLimit(activeMemberships, new Set())).toBe(true);
expect(shouldApplyFreeOrganizationLimit(activeMemberships, new Set(["org_paid"]))).toBe(false);
});

it("caps free-only users at active org memberships, not pending invitations", () => {
expect(
isOverFreeOrganizationLimit(
Array.from({ length: FREE_ORGANIZATIONS_PER_USER_LIMIT - 1 }, (_, index) => ({
organizationId: `org_${index}`,
status: "active",
})),
),
).toBe(false);

expect(
isOverFreeOrganizationLimit(
Array.from({ length: FREE_ORGANIZATIONS_PER_USER_LIMIT }, (_, index) => ({
organizationId: `org_${index}`,
status: "active",
})),
),
).toBe(true);
});
});
27 changes: 27 additions & 0 deletions apps/cloud/src/edge/marketing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import {
isV2Path,
marketingProxyRequest,
parseV2Edge,
v2AppOrigin,
v2EdgeResponse,
v2PageForSlashedPath,
v2SignUpUrl,
type V2EdgeEnv,
type V2Service,
} from "./marketing";
Expand Down Expand Up @@ -254,6 +256,31 @@ describe("parseV2Edge", () => {
});
});

// Where v1 sends anything that would create an organization: v2's sign-up page
// (and its origin, v2's app host).
describe("v2SignUpUrl", () => {
const service: V2Service = { fetch: () => Promise.resolve(new Response(null)) };
const settings = {
V2: service,
V2_SIGN_UP_URL: "https://app.executor.sh/login?mode=signup",
V2_OAUTH_STATE_PREFIX: "x2.",
V2_ANALYTICS_PROXY_PATH: "/api/0123456789abcdef",
V2_ERROR_TUNNEL_PATH: "/api/fedcba9876543210/submit",
};

it("is v2's sign-up page, query kept, and v2AppOrigin is its origin", () => {
expect(v2SignUpUrl(settings)?.href).toBe("https://app.executor.sh/login?mode=signup");
expect(v2AppOrigin(settings)?.href).toBe("https://app.executor.sh/");
});

it("is null where v1 has no v2: no settings, or broken ones", () => {
expect(v2SignUpUrl({})).toBeNull();
expect(v2SignUpUrl({ ...settings, V2: undefined })).toBeNull();
expect(v2SignUpUrl({ ...settings, V2_SIGN_UP_URL: "/login?mode=signup" })).toBeNull();
expect(v2AppOrigin({})).toBeNull();
});
});

describe("v2EdgeResponse", () => {
const SIGN_UP_URL = "https://v2.executor.sh/login?mode=signup";
const STATE_PREFIX = "x2.";
Expand Down
2 changes: 1 addition & 1 deletion e2e/cloud/account-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// React shell + api-keys page consume. Happy paths go through the typed
// AccountHttpApi client (the same contract the UI's AccountApiClient uses);
// refusal edges and the bearer cross-check use raw fetch with the identity's
// real sealed-session cookie (the org-limit cross-check pattern).
// real sealed-session cookie.
import { expect } from "@effect/vitest";
import { Effect } from "effect";
import { AccountHttpApi } from "@executor-js/api";
Expand Down
9 changes: 5 additions & 4 deletions e2e/cloud/admin-users-console.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@
// most here — that a plain member who reaches the URL is told they don't have
// access rather than shown an empty workspace.
//
// Two members are built through the REAL flows (login → create-organization →
// invite → accept-invitation, in `./support/session`). Each connects a Personal
// credential; the plain member's Workspace attempt is refused, so the directory
// and connection UI prove the owner-aware permission boundary together.
// Two members are built through the REAL flows (login into the admin's
// organization → invite → accept-invitation, in `./support/session`). Each
// connects a Personal credential; the plain member's Workspace attempt is
// refused, so the directory and connection UI prove the owner-aware permission
// boundary together.
import { randomBytes } from "node:crypto";

import { expect } from "@effect/vitest";
Expand Down
8 changes: 4 additions & 4 deletions e2e/cloud/admin-users.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
// "who uses my workspace, and what have they connected", reading across every
// member of the tenant instead of binding to one.
//
// Two members are built through the REAL flows (login → create-organization →
// invite → accept-invitation), each connects their own Personal credential, and
// the admin then reads the joined view — the exact shape a customer dashboard's
// icon grid consumes. The guarantees pinned here:
// Two members are built through the REAL flows (login into the admin's
// organization → invite → accept-invitation), each connects their own Personal
// credential, and the admin then reads the joined view — the exact shape a
// customer dashboard's icon grid consumes. The guarantees pinned here:
//
// 1. the joined view reports BOTH members and each one's own connections,
// even though no single product-view caller can see another member's;
Expand Down
118 changes: 80 additions & 38 deletions e2e/cloud/auth-routing-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
// through the real dev server (the e2e cloud target boots `vite dev` with the
// SSR gate active, so this is the same routing that runs in prod):
//
// signed out → /login
// click Sign in → WorkOS (emulator) callback → org-less → /create-org
// create the first org → canonical dashboard at /<slug>
// member: signed out → /login → click Sign in → WorkOS (emulator) callback
// → canonical dashboard at /<slug>
// no org: the same sign-in → Executor v2's sign-up page (v1 creates no
// organizations; they are created in v2)
//
// The individual hops are also covered piecewise (cloud/unauthenticated-skeleton,
// cloud/session-gate, scenarios/org-slug-routing) — those pin edge cases this
Expand All @@ -20,60 +21,53 @@ import { randomUUID } from "node:crypto";

import { expect } from "@effect/vitest";
import { Effect } from "effect";
import type { Page } from "playwright";

import { scenario } from "../src/scenario";
import { Browser, Target } from "../src/services";
import { settle } from "../src/surfaces/browser";
import { settle, type BrowserSession } from "../src/surfaces/browser";
import { seedOrganization } from "../targets/cloud";

/** Signed out at the root, then through the hosted sign-in form as `email`. */
const signInThroughTheUi = async (page: Page, step: BrowserSession["step"], email: string) => {
await step("Signed out, the root redirects to /login", async () => {
await page.goto("/", { waitUntil: "commit" });
await page.getByText("Sign in to manage your tools and integrations").waitFor();
});
expect(new URL(page.url()).pathname, "the signed-out root lands on /login").toBe("/login");

await step("Sign in through the UI", async () => {
await page.getByRole("link", { name: "Sign in" }).click();
// The emulator's hosted AuthKit form.
await page.getByPlaceholder("new-user@example.com").fill(email);
await page.getByRole("button", { name: /Continue/ }).click();
});
};

scenario(
"Auth routing · signed out → login → onboarding → dashboard, one browser session",
"Auth routing · signed out → login → dashboard, one browser session",
{},
Effect.gen(function* () {
const browser = yield* Browser;
yield* Target;

// A brand-new user the emulator provisions on first login_hint — no org, so
// the post-callback document is gated to onboarding.
// A member of an existing organization (it lives in WorkOS, as every v1
// organization does), signing in for the first time in this browser.
const email = `flow-${randomUUID().slice(0, 8)}@e2e.test`;
yield* Effect.promise(() => seedOrganization(email, "Flow Test Org"));

// No cookies → a clean, signed-out browser.
const anonymous = { label: "anonymous" };

yield* browser.session(anonymous, async ({ page, step }) => {
await step("Signed out, the root redirects to /login", async () => {
await page.goto("/", { waitUntil: "commit" });
await page.getByText("Sign in to manage your tools and integrations").waitFor();
});
expect(new URL(page.url()).pathname, "the signed-out root lands on /login").toBe("/login");

await step("Sign in through the UI → org-less session lands on onboarding", async () => {
await page.getByRole("link", { name: "Sign in" }).click();
// The emulator's hosted AuthKit form: provision a brand-new user.
await page.getByPlaceholder("new-user@example.com").fill(email);
await page.getByRole("button", { name: /Continue/ }).click();
// callback mints the session → SSR gate sees no org → /create-org.
await page.waitForURL((url) => url.pathname === "/create-org", { timeout: 30_000 });
await page.getByPlaceholder("Northwind Labs").waitFor({ timeout: 30_000 });
});
await signInThroughTheUi(page, step, email);

await step("Create the first org → canonical dashboard at /<slug>", async () => {
const orgName = "Flow Test Org";
const orgNameInput = page.getByPlaceholder("Northwind Labs");
await settle(page);
await orgNameInput.fill(orgName);
await page.waitForTimeout(250);
if ((await orgNameInput.inputValue()) !== orgName) {
await orgNameInput.fill(orgName);
}
expect(await orgNameInput.inputValue(), "org name survives create-org hydration").toBe(
orgName,
);
await page.getByRole("button", { name: "Create organization" }).click();
await page.getByText("Connect your MCP client").waitFor({ timeout: 30_000 });
await page.getByRole("button", { name: "Continue to app" }).click();
// The bare landing canonicalizes onto the new org's slug.
await step("The callback lands on the canonical dashboard at /<slug>", async () => {
// The bare landing canonicalizes onto the org's slug.
await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { timeout: 30_000 });
await page.getByText("Integrations").first().waitFor({ timeout: 30_000 });
await settle(page);
await page.getByRole("button", { name: /Flow Test Org/ }).waitFor({ timeout: 30_000 });
});

expect(
Expand All @@ -83,3 +77,51 @@ scenario(
});
}),
);

scenario(
"Auth routing · a sign-in with no organization is sent to Executor v2's sign-up page",
{},
Effect.gen(function* () {
const browser = yield* Browser;
const target = yield* Target;

const email = `flow-orgless-${randomUUID().slice(0, 8)}@e2e.test`;
const anonymous = { label: "anonymous" };

yield* browser.session(anonymous, async ({ page, step }) => {
// The callback's redirect chain ends on v2's real host, and Playwright
// cannot hold a document at a redirect hop. So the hosted form's answer
// is held here (as login-csrf does), and the browser's own request
// context — its cookie jar — follows the chain one hop at a time.
let callback: string | undefined;
await page.route("**/user_management/authorize/submit", async (route) => {
const response = await route.fetch({ maxRedirects: 0 });
callback = response.headers().location;
await route.fulfill({ status: 200, contentType: "text/plain", body: "Signed in" });
});

await signInThroughTheUi(page, step, email);

await step("The callback signs the browser in and returns it to the app", async () => {
await expect.poll(() => callback).toBeDefined();
const response = await page.request.get(callback!, { maxRedirects: 0 });
expect(response.status(), "the callback completes the sign-in").toBe(302);
expect(response.headers().location, "…back to the app's root").toBe("/");
});

await step("The app sends the org-less session on to v2's sign-up page", async () => {
const response = await page.request.get(new URL("/", target.baseUrl).toString(), {
maxRedirects: 0,
headers: { accept: "text/html" },
});
expect(response.status(), "no app shell for a session with no organization").toBe(302);
const location = new URL(response.headers().location ?? "", target.baseUrl);
expect(location.origin, "the session leaves v1 for v2's host").not.toBe(
new URL(target.baseUrl).origin,
);
expect(location.pathname, "v2's sign-in page…").toBe("/login");
expect(location.searchParams.get("mode"), "…opened to sign up").toBe("signup");
});
});
}),
);
37 changes: 7 additions & 30 deletions e2e/cloud/billing-customer-provisioning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,18 @@
// executions and NONE of them reach the meter. The product looks perfectly
// healthy while every execution is unbilled and unmetered.
//
// Three guarantees are pinned here, all read from Autumn's own state rather than
// Two guarantees are pinned here, all read from Autumn's own state rather than
// from the execution's response (which can never show any of them):
//
// 1. creating an organization provisions its billing customer up front,
// 2. if a customer is missing anyway, the billing seam heals it in place and
// the execution still lands on the meter, and
// 3. any OTHER billing failure is left alone — the repair is scoped to the
// 1. if an organization's customer is missing, the billing seam heals it in
// place and the execution still lands on the meter, and
// 2. any OTHER billing failure is left alone — the repair is scoped to the
// "no such customer" answer, so a real provider failure is still reported
// rather than quietly retried away.
//
// (v1 no longer creates organizations — they are created in Executor v2 — so
// there is no creation step left to provision a customer up front.)
//
// The missing customer is produced with the emulator's fault injector — one
// 404 `customer_not_found` per billing endpoint — because the emulator, like
// real Autumn's SDK flow, otherwise auto-creates customers on contact.
Expand Down Expand Up @@ -54,31 +56,6 @@ const UNRELATED_NOT_FOUND = {
body: { message: "Not Found", code: "not_found" },
} as const;

scenario(
"Billing · creating an organization provisions it as a billing customer",
{ timeout: 180_000 },
Effect.gen(function* () {
yield* Billing;
const autumn = yield* Autumn;
const target = yield* Target;
const mcp = yield* Mcp;

// A brand-new user creating their first organization: none of the
// opportunistic billing lookups (the over-the-free-limit check, the members
// page's seat count, the rate limiter's paid-plan exemption) are reached on
// this journey, so org creation is the only chance to provision.
const identity = yield* target.newIdentity();
const bearer = yield* mcp.mintBearer(emailOf(identity));
const organizationId = orgIdOf(bearer);

const customerIds = yield* autumn.customerIds();
expect(
customerIds,
"the new organization exists as a customer at the billing provider",
).toContain(organizationId);
}),
);

scenario(
"Billing · a missing billing customer is healed in place and the execution is still metered",
{ timeout: 180_000 },
Expand Down
Loading
Loading