Skip to content
Open
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
12 changes: 3 additions & 9 deletions e2e/test-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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."),
},
Expand Down Expand Up @@ -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."),
},
},
Expand Down Expand Up @@ -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."),
},
Expand Down
68 changes: 48 additions & 20 deletions e2e/tests/dashboard-batch-isolation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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 }),
Expand All @@ -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),
);
}),
),
);
Expand Down
15 changes: 9 additions & 6 deletions e2e/tests/groups.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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)}`,
Expand Down
5 changes: 4 additions & 1 deletion e2e/tests/pat-mcp.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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`;
Expand All @@ -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", {
Expand Down
17 changes: 10 additions & 7 deletions e2e/tests/resource-isolation.spec.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(
Expand Down