diff --git a/e2e/test-plan.ts b/e2e/test-plan.ts index 79a4b2a80b..d4bb7674cf 100644 --- a/e2e/test-plan.ts +++ b/e2e/test-plan.ts @@ -2520,9 +2520,7 @@ export const scenarios = plan({ file: "resource-isolation.spec.ts", title: "Group resource grants and account connections cannot cross organizations", targets: { - "self-host": na( - "Self-host permits one organization; real cross-organization grants are a Cloud scenario.", - ), + "self-host": scheduled, cloud: scheduled, local: na("Local has no organization sharing policy."), }, @@ -3233,9 +3231,7 @@ export const scenarios = plan({ title: "Group identities and memberships cannot cross organization boundaries", targets: { cloud: scheduled, - "self-host": na( - "Self-host permits only one organization; foreign organization references are checked in the shared groups scenario.", - ), + "self-host": scheduled, local: na("Local has no organizations or groups."), }, }, @@ -7326,9 +7322,7 @@ export const scenarios = plan({ file: "pat-mcp.spec.ts", title: "PAT MCP calls keep their organization when its slug is renamed mid-request", targets: { - "self-host": na( - "Self-host permits one organization, which every parallel scenario shares; renaming it would move theirs.", - ), + "self-host": scheduled, cloud: scheduled, local: na("Local has no organizations."), }, diff --git a/e2e/tests/dashboard-batch-isolation.spec.ts b/e2e/tests/dashboard-batch-isolation.spec.ts index 40891a4ee3..6c7f14b73b 100644 --- a/e2e/tests/dashboard-batch-isolation.spec.ts +++ b/e2e/tests/dashboard-batch-isolation.spec.ts @@ -4,7 +4,7 @@ import { HttpClient, HttpClientRequest } from "effect/http"; import { randomUUID } from "node:crypto"; import { Actors } from "../support/actors.ts"; import { Api, body, type Session } from "../support/api.ts"; -import { Organization } from "../support/contracts.ts"; +import { Organization, Resource } from "../support/contracts.ts"; import { HostedLive, withHostedCase } from "../support/case.ts"; import { Target } from "../support/platform.ts"; import { batchPath } from "../support/read-batches.ts"; @@ -213,9 +213,8 @@ layer(HostedLive, { excludeTestServices: true })("Dashboard batch isolation", (i expect(yield* roleOf(demoted[1])).toBe("member"); expect(demoted[2]?.status).toBe(403); - if (!cloud) return; - // Self-host has one organization. On Cloud, one batch spans organizations where the caller - // has different roles, one it does not belong to, and aliases of each. + // One batch spans organizations where the caller has different roles, one it does not + // belong to, and aliases of each. const suffix = randomUUID().slice(0, 8); const foreign = yield* body( Organization, @@ -225,22 +224,47 @@ layer(HostedLive, { excludeTestServices: true })("Dashboard batch isolation", (i keepCurrentActiveOrganization: true, }), ); - yield* Effect.addFinalizer(() => - api - .request(actors.owner, "DELETE", `/api/organizations/${foreign.id}`) - .pipe(Effect.orDie), - ); + // Self-host cannot remove organizations; the scenario's own server is discarded instead. + if (cloud) + yield* Effect.addFinalizer(() => + api + .request(actors.owner, "DELETE", `/api/organizations/${foreign.id}`) + .pipe(Effect.orDie), + ); + // On Cloud the member creates its own organization. On self-host only the root's owners + // create, so the owner creates it and invites the member as an administrator. const own = yield* body( Organization, - yield* api.request(actors.member, "POST", "/api/auth/organization/create", { - name: "Batch isolation own", - slug: `batch-own-${suffix}`, - keepCurrentActiveOrganization: true, - }), - ); - yield* Effect.addFinalizer(() => - api.request(actors.member, "DELETE", `/api/organizations/${own.id}`).pipe(Effect.orDie), + yield* api.request( + cloud ? actors.member : actors.owner, + "POST", + "/api/auth/organization/create", + { + name: "Batch isolation own", + slug: `batch-own-${suffix}`, + keepCurrentActiveOrganization: true, + }, + ), ); + if (cloud) + yield* Effect.addFinalizer(() => + api.request(actors.member, "DELETE", `/api/organizations/${own.id}`).pipe(Effect.orDie), + ); + else { + const invitation = yield* body( + Resource, + yield* api.request(actors.owner, "POST", "/api/auth/organization/invite-member", { + organizationId: own.id, + email: "member@example.test", + role: "admin", + }), + ); + expect( + (yield* api.request(actors.member, "POST", "/api/auth/organization/accept-invitation", { + invitationId: invitation.id, + })).status, + ).toBe(200); + } const mixed = [ read("organization", "access", { organization: home.id }), read("oauthClients", "list", { organization: home.id }), @@ -267,11 +291,15 @@ layer(HostedLive, { excludeTestServices: true })("Dashboard batch isolation", (i const of = (index: number) => answers[order.indexOf(index)]; expect(mixed.map((_, index) => of(index)?.status)).toEqual(expected); const roles = yield* Effect.forEach([0, 4, 6, 8], (index) => roleOf(of(index))); - expect(roles).toEqual(["member", "owner", "member", "owner"]); + const own = cloud ? "owner" : "admin"; + expect(roles).toEqual(["member", own, "member", own]); } - // The owner's batch on the same organizations is answered with the owner's roles. + // The owner's batch on the same organizations is answered with the owner's roles. On + // self-host the owner created the member's organization, so it belongs to that one too. const owner = yield* send(actors.owner, mixed); - expect(statuses(owner)).toEqual([200, 200, 200, 200, 403, 403, 200, 200, 403, 200]); + expect(statuses(owner)).toEqual( + cloud ? [200, 200, 200, 200, 403, 403, 200, 200, 403, 200] : mixed.map(() => 200), + ); }), ), ); diff --git a/e2e/tests/groups.spec.ts b/e2e/tests/groups.spec.ts index a61e305b7c..056a1c8069 100644 --- a/e2e/tests/groups.spec.ts +++ b/e2e/tests/groups.spec.ts @@ -9,6 +9,7 @@ import { Evidence } from "../support/evidence.ts"; import { HostedLive, withHostedCase } from "../support/case.ts"; import { holdQuery, refreshVisiblePage } from "../support/query-transition.ts"; import { Organization } from "../support/contracts.ts"; +import { Target } from "../support/platform.ts"; import { scenarios } from "../test-plan.ts"; const Group = Schema.Struct({ @@ -458,12 +459,14 @@ layer(HostedLive, { excludeTestServices: true })("Organization groups", (it) => ); expect(orgResponse.status).toBe(200); const other = yield* body(Organization, orgResponse); - yield* Effect.addFinalizer(() => - api.request(actors.owner, "DELETE", `/api/organizations/${other.id}`).pipe( - Effect.tap((response) => Effect.sync(() => expect(response.status).toBe(200))), - Effect.orDie, - ), - ); + // Self-host cannot remove organizations; the scenario's own server is discarded instead. + if ((yield* Target).metadata.target === "cloud") + yield* Effect.addFinalizer(() => + api.request(actors.owner, "DELETE", `/api/organizations/${other.id}`).pipe( + Effect.tap((response) => Effect.sync(() => expect(response.status).toBe(200))), + Effect.orDie, + ), + ); const prefix = `/api/organizations/${actors.organization.id}/groups`; const created = yield* api.request(actors.owner, "POST", prefix, { name: `Isolation ${randomUUID().slice(0, 8)}`, diff --git a/e2e/tests/pat-mcp.spec.ts b/e2e/tests/pat-mcp.spec.ts index cf4a3ee607..c8fb95de40 100644 --- a/e2e/tests/pat-mcp.spec.ts +++ b/e2e/tests/pat-mcp.spec.ts @@ -9,6 +9,7 @@ import { McpClient } from "../support/mcp-client.ts"; import { Evidence } from "../support/evidence.ts"; import { HostedLive, withHostedCase } from "../support/case.ts"; import { App, Organization } from "../support/contracts.ts"; +import { Target } from "../support/platform.ts"; import { appsManifest } from "../support/apps-release.ts"; const Token = Schema.Struct({ @@ -553,6 +554,7 @@ layer(HostedLive, { excludeTestServices: true })("PAT MCP", (it) => { const api = yield* Api, actors = yield* Actors, mcp = yield* McpClient; + const cloud = (yield* Target).metadata.target === "cloud"; // A temporary organization, so renaming it moves no other scenario's slug. const slug = `pat-rename-${randomUUID().slice(0, 8)}`, renamed = `${slug}-renamed`; @@ -569,7 +571,8 @@ layer(HostedLive, { excludeTestServices: true })("PAT MCP", (it) => { Effect.gen(function* () { for (const keyId of keys) yield* api.request(actors.owner, "POST", "/api/auth/api-key/delete", { keyId }); - yield* api.request(actors.owner, "DELETE", prefix); + // Self-host cannot remove organizations; the scenario's own server is discarded. + if (cloud) yield* api.request(actors.owner, "DELETE", prefix); }).pipe(Effect.orDie), ); const createdKey = yield* api.request(actors.owner, "POST", "/api/auth/api-key/create", { diff --git a/e2e/tests/resource-isolation.spec.ts b/e2e/tests/resource-isolation.spec.ts index 7e9ae3b6f5..025a6d85da 100644 --- a/e2e/tests/resource-isolation.spec.ts +++ b/e2e/tests/resource-isolation.spec.ts @@ -1,10 +1,11 @@ -/** Real Cloud organizations exercise tenant boundaries through the hosted API. */ +/** Real organizations on one host exercise tenant boundaries through the hosted API. */ import { expect, layer } from "@effect/vitest"; import { Effect, Schema } from "effect"; import { randomUUID } from "node:crypto"; import { Api, body } from "../support/api.ts"; import { Actors } from "../support/actors.ts"; import { HostedLive, withHostedCase } from "../support/case.ts"; +import { Target } from "../support/platform.ts"; import { App, Resource, Organization } from "../support/contracts.ts"; import { scenarios } from "../test-plan.ts"; import { appsManifest } from "../support/apps-release.ts"; @@ -32,12 +33,14 @@ layer(HostedLive, { excludeTestServices: true })("Resource isolation", (it) => { keepCurrentActiveOrganization: true, }), ); - yield* Effect.addFinalizer(() => - api.request(actors.owner, "DELETE", `/api/organizations/${other.id}`).pipe( - Effect.tap((response) => Effect.sync(() => expect(response.status).toBe(200))), - Effect.orDie, - ), - ); + // Self-host cannot remove organizations; the scenario's own server is discarded instead. + if ((yield* Target).metadata.target === "cloud") + yield* Effect.addFinalizer(() => + api.request(actors.owner, "DELETE", `/api/organizations/${other.id}`).pipe( + Effect.tap((response) => Effect.sync(() => expect(response.status).toBe(200))), + Effect.orDie, + ), + ); const prefix = `/api/organizations/${actors.organization.id}`, foreign = `/api/organizations/${other.id}`; const app = yield* body(