Skip to content

Commit 08be63a

Browse files
committed
Test the freeze of organizations moving to v2
1 parent f84e954 commit 08be63a

5 files changed

Lines changed: 1183 additions & 0 deletions

File tree

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
// ---------------------------------------------------------------------------
2+
// The account plane's freeze over HTTP: every non-GET `/account/*` route
3+
// changes the organization the selector names, and the account middleware
4+
// refuses those for an organization moving to (or moved to) Executor v2 before
5+
// the WorkOS account service runs. The move state is read from the real PGlite
6+
// test database; WorkOS, the user store and the membership mirror are stubs,
7+
// and WorkOS dies on anything but session authentication and the one update
8+
// the open organization's request is allowed to make.
9+
// ---------------------------------------------------------------------------
10+
11+
import { verifiedSettingsCookie } from "../../test-stubs/verified-settings";
12+
import { afterAll, describe, expect, it } from "@effect/vitest";
13+
import { Effect, Layer } from "effect";
14+
import { HttpRouter, HttpServer } from "effect/unstable/http";
15+
16+
import { MemberDirectory } from "@executor-js/api/server";
17+
18+
import { UserStoreService } from "../auth/context";
19+
import { ORG_SELECTOR_HEADER } from "../auth/organization";
20+
import { WorkOSClient, type WorkOSClientService } from "../auth/workos";
21+
import { WorkOsMirror, type WorkOsMirrorShape } from "../auth/workos-mirror";
22+
import { DbService, makeDbLayer } from "../db/db";
23+
import { organizations, orgMovedToV2 } from "../db/schema";
24+
import {
25+
ORG_MOVED_CODE,
26+
ORG_MOVED_MESSAGE,
27+
ORG_MOVING_CODE,
28+
ORG_MOVING_MESSAGE,
29+
} from "../org/move-to-v2";
30+
import { makeAccountApiLive } from "./account-api";
31+
32+
const CALLER = "user_synthetic_admin";
33+
const createdAt = new Date("2026-01-01T00:00:00.000Z");
34+
35+
const organizationRow = (id: string) => ({
36+
id,
37+
name: `Org ${id}`,
38+
slug: id,
39+
backfilledAt: createdAt,
40+
deletedAt: null,
41+
workosUpdatedAt: null,
42+
createdAt,
43+
});
44+
45+
const unreadStore = (why: string) => () => Effect.runPromise(Effect.die(why));
46+
const stubUsers = Layer.succeed(UserStoreService)({
47+
use: (_op, fn) =>
48+
Effect.promise(() =>
49+
fn({
50+
ensureAccount: unreadStore("accounts are not minted here"),
51+
getAccount: unreadStore("accounts are not read here"),
52+
upsertOrganization: async (org: { id: string; name: string }) => ({
53+
...organizationRow(org.id),
54+
name: org.name,
55+
}),
56+
getOrganization: async (id: string) => organizationRow(id),
57+
getOrganizationBySlug: unreadStore("selectors here are ids"),
58+
markOrganizationDeleted: unreadStore("nothing is deleted here"),
59+
deleteOrganizationCascade: unreadStore("nothing is deleted here"),
60+
}),
61+
),
62+
});
63+
64+
// The caller is an active admin of every organization a test names.
65+
const stubDirectory = Layer.succeed(MemberDirectory)({
66+
membership: (accountId, organizationId) =>
67+
Effect.succeed(
68+
accountId === CALLER
69+
? {
70+
accountId,
71+
membershipId: `om_${organizationId}`,
72+
organizationId,
73+
email: null,
74+
name: null,
75+
avatarUrl: null,
76+
role: "admin",
77+
status: "active" as const,
78+
lastActiveAt: null,
79+
}
80+
: null,
81+
),
82+
membershipById: () => Effect.die("not read here"),
83+
membershipsOf: () => Effect.die("not read here"),
84+
members: () => Effect.die("not read here"),
85+
membersById: () => Effect.die("not read here"),
86+
findByEmail: () => Effect.die("not read here"),
87+
});
88+
89+
const stubMirror = Layer.succeed(
90+
WorkOsMirror,
91+
new Proxy({} as WorkOsMirrorShape, {
92+
get: (_target, prop) => () => Effect.die(`unexpected WorkOsMirror.${String(prop)} call`),
93+
}),
94+
);
95+
96+
/** WorkOS as the account plane sees it; every other call it gets is recorded. */
97+
const stubWorkOS = (calls: string[]) =>
98+
Layer.succeed(
99+
WorkOSClient,
100+
new Proxy({} as WorkOSClientService, {
101+
get: (_target, prop) => {
102+
const method = String(prop);
103+
if (method === "authenticateSealedSession") {
104+
return () =>
105+
Effect.succeed({
106+
userId: CALLER,
107+
sessionId: "test-settings-session",
108+
email: "admin@placeholder.test",
109+
organizationId: null,
110+
});
111+
}
112+
if (method === "updateOrganization") {
113+
return (id: string, name: string) =>
114+
Effect.sync(() => {
115+
calls.push(method);
116+
return { id, name, updatedAt: createdAt.toISOString() };
117+
});
118+
}
119+
return () =>
120+
Effect.sync(() => {
121+
calls.push(method);
122+
}).pipe(Effect.andThen(Effect.die(`unexpected WorkOSClient.${method} call`)));
123+
},
124+
}),
125+
);
126+
127+
const apps: { dispose: () => Promise<void> }[] = [];
128+
afterAll(async () => {
129+
await Promise.all(apps.map((app) => app.dispose()));
130+
});
131+
132+
const send = async (organizationId: string, method: string, path: string, body?: unknown) => {
133+
const calls: string[] = [];
134+
const rsLive = Layer.mergeAll(makeDbLayer(), stubUsers, stubDirectory, stubMirror);
135+
const app = HttpRouter.toWebHandler(
136+
makeAccountApiLive(rsLive).pipe(
137+
Layer.provide(stubWorkOS(calls)),
138+
Layer.provide(HttpServer.layerServices),
139+
),
140+
{ disableLogger: true },
141+
);
142+
apps.push(app);
143+
const response = await app.handler(
144+
new Request(`https://executor.test${path}`, {
145+
method,
146+
headers: {
147+
cookie: `wos-session=sealed; ${await verifiedSettingsCookie(CALLER)}`,
148+
[ORG_SELECTOR_HEADER]: organizationId,
149+
...(body === undefined ? {} : { "content-type": "application/json" }),
150+
},
151+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
152+
}),
153+
undefined as never,
154+
);
155+
return {
156+
status: response.status,
157+
retryAfter: response.headers.get("retry-after"),
158+
body: (await response.json()) as unknown,
159+
calls,
160+
};
161+
};
162+
163+
const seedOrg = (state?: "moving" | "moved" | "rolled_back") =>
164+
Effect.runPromise(
165+
Effect.gen(function* () {
166+
const { db } = yield* DbService;
167+
const id = `org_${crypto.randomUUID().replaceAll("-", "")}`;
168+
yield* Effect.promise(() =>
169+
db.insert(organizations).values({ id, name: "Synthetic Org", slug: id.slice(4, 20) }),
170+
);
171+
if (state) yield* Effect.promise(() => db.insert(orgMovedToV2).values({ orgId: id, state }));
172+
return id;
173+
}).pipe(Effect.provide(makeDbLayer())),
174+
);
175+
176+
describe("account plane freeze", () => {
177+
it("refuses member, key and settings changes while the organization moves", async () => {
178+
const org = await seedOrg("moving");
179+
for (const [method, path, body] of [
180+
["POST", "/account/members/invite", { email: "new@placeholder.test" }],
181+
["PATCH", "/account/members/om_1/role", { roleSlug: "member" }],
182+
["DELETE", "/account/members/om_1", undefined],
183+
["POST", "/account/api-keys", { name: "ci" }],
184+
["DELETE", "/account/api-keys/key_1", undefined],
185+
["POST", "/account/org-api-keys", { name: "ci" }],
186+
["PATCH", "/account/name", { name: "Renamed" }],
187+
] as const) {
188+
const response = await send(org, method, path, body);
189+
expect(response).toEqual({
190+
status: 423,
191+
retryAfter: "60",
192+
body: { error: ORG_MOVING_MESSAGE, code: ORG_MOVING_CODE },
193+
calls: [],
194+
});
195+
}
196+
});
197+
198+
it("refuses them for good once the organization has moved", async () => {
199+
const org = await seedOrg("moved");
200+
const response = await send(org, "PATCH", "/account/name", { name: "Renamed" });
201+
expect(response).toEqual({
202+
status: 423,
203+
retryAfter: null,
204+
body: { error: ORG_MOVED_MESSAGE, code: ORG_MOVED_CODE },
205+
calls: [],
206+
});
207+
});
208+
209+
it("serves the change when the organization is not moving, or was given back", async () => {
210+
for (const org of [await seedOrg(), await seedOrg("rolled_back")]) {
211+
const response = await send(org, "PATCH", "/account/name", { name: "Renamed" });
212+
expect(response).toEqual({
213+
status: 200,
214+
retryAfter: null,
215+
body: { name: "Renamed" },
216+
calls: ["updateOrganization"],
217+
});
218+
}
219+
});
220+
});

0 commit comments

Comments
 (0)