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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
### New Features

- **GitLab GraphQL emulator** — a new `gitlab` emulator serves GitLab's full GraphQL schema with real graphql-js introspection and validation. It loads the complete published SDL (4000+ types) so generated GraphQL clients see the same surface they would in production, and rejects malformed operations with verbatim graphql-js validation errors. The hosted `gitlab.emulators.dev` host and `createEmulator({ service: "gitlab" })` both expose the schema at `/api/graphql`, with metadata and echo queries resolving and an honest unauthenticated `currentUser`.

### Bug Fixes

- **WorkOS invitation memberships** — sending an organization invitation now also creates a pending organization membership (and the invited user when one does not exist yet), matching real WorkOS. `listOrganizationMemberships` with status `pending` returns invited but not yet joined people, so consumers can list invited members and count seats accurately. Accepting the invitation activates that membership instead of leaving a duplicate.
<!-- release:end -->

## 0.7.5
Expand Down
26 changes: 26 additions & 0 deletions packages/@emulators/workos/src/__tests__/workos.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,32 @@ describe("workos emulator with the real @workos-inc/node SDK", () => {
}
});

it("creates a pending organization membership when an invitation is sent", async () => {
const org = await workos.organizations.createOrganization({ name: "Invite Org" });
const invitation = await workos.userManagement.sendInvitation({
email: "invitee@example.com",
organizationId: org.id,
});
expect(invitation.state).toBe("pending");

// Real WorkOS surfaces the invited user as a PENDING organization
// membership, so consumers can list invited members and count seats.
const memberships = await workos.userManagement.listOrganizationMemberships({
organizationId: org.id,
statuses: ["pending"] as never,
});
expect(memberships.data).toHaveLength(1);
expect(memberships.data[0]!.status).toBe("pending");
const invitedUser = await workos.userManagement.getUser(memberships.data[0]!.userId);
expect(invitedUser.email).toBe("invitee@example.com");

// The invitation itself is also listed, pending.
const invitations = await workos.userManagement.listInvitations({
organizationId: org.id,
});
expect(invitations.data.map((i) => i.email)).toContain("invitee@example.com");
});

it("round-trips vault objects through workos.vault", async () => {
const metadata = await workos.vault.createObject({
name: "executor/secrets/test",
Expand Down
28 changes: 26 additions & 2 deletions packages/@emulators/workos/src/routes/user-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,16 +308,35 @@ export function userManagementRoutes(ctx: RouteContext): void {
if (!ws().organizations.findOneBy("workos_id", organizationId)) {
return workosError(c, 404, "entity_not_found", "Organization not found.");
}
const roleSlug = typeof body.role_slug === "string" ? body.role_slug : null;
const invitation = ws().invitations.insert({
workos_id: workosId("invitation"),
email,
organization_id: organizationId,
inviter_user_id: typeof body.inviter_user_id === "string" ? body.inviter_user_id : null,
role_slug: typeof body.role_slug === "string" ? body.role_slug : null,
role_slug: roleSlug,
state: "pending",
token: randomToken("invite"),
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
});
// Real WorkOS also creates a PENDING organization membership for the invited
// user (creating the user record when one does not exist yet), so
// listOrganizationMemberships with status "pending" returns invited but not
// yet joined people. Mirror that here so consumers can list invited members
// and count seats accurately.
const invitedUser = ensureUserByEmail(ws(), email);
const existingMembership = ws()
.memberships.findBy("user_id", invitedUser.workos_id)
.find((m) => m.organization_id === organizationId);
if (!existingMembership) {
ws().memberships.insert({
workos_id: workosId("om"),
user_id: invitedUser.workos_id,
organization_id: organizationId,
status: "pending",
role_slug: roleSlug ?? "member",
});
}
return c.json(serializeInvitation(invitation), 201);
});

Expand All @@ -333,7 +352,12 @@ export function userManagementRoutes(ctx: RouteContext): void {
const already = ws()
.memberships.findBy("user_id", user.workos_id)
.find((m) => m.organization_id === invitation.organization_id);
if (!already) {
if (already) {
// The invite created a pending membership; accepting activates it.
if (already.status !== "active") {
ws().memberships.update(already.id, { status: "active" });
}
} else {
ws().memberships.insert({
workos_id: workosId("om"),
user_id: user.workos_id,
Expand Down