From ef2fc6e0bc5ced5026417da849a8380a4dd6aacb Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 7 Aug 2026 17:25:52 -0400 Subject: [PATCH 1/3] feat(groups): add AuthKit Groups support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #57: the emulator had no `/organizations/{org}/groups` routes, so SDK consumers testing AuthKit Groups had nothing to hit, and the five `group.*` events were declared in the generated catalog but never fired. This implements all nine spec endpoints, wires the events to collection hooks (so seeded and API-created groups emit identically), and makes groups seedable nested under `organizations` — mirroring `memberships` — so a test suite can pre-populate groups without setup calls. --- README.md | 6 + SUPPORTED.md | 7 +- scripts/gen-supported-lib.spec.ts | 5 + scripts/gen-supported-lib.ts | 25 ++- src/core/id.ts | 2 + src/workos/config-validator.ts | 69 +++++++ src/workos/entities.ts | 14 ++ src/workos/helpers.ts | 27 +++ src/workos/index.ts | 72 +++++++ src/workos/routes/groups.spec.ts | 322 ++++++++++++++++++++++++++++++ src/workos/routes/groups.ts | 185 +++++++++++++++++ src/workos/seed-groups.spec.ts | 269 +++++++++++++++++++++++++ src/workos/store.ts | 10 + 13 files changed, 1007 insertions(+), 6 deletions(-) create mode 100644 src/workos/routes/groups.spec.ts create mode 100644 src/workos/routes/groups.ts create mode 100644 src/workos/seed-groups.spec.ts diff --git a/README.md b/README.md index fd5f695..8f2d12f 100644 --- a/README.md +++ b/README.md @@ -973,6 +973,12 @@ organizations: - email: employee@acme.com role: member status: active + groups: + # Groups belong to this org; members reference a membership by the same + # email join key `memberships` use above. + - name: Engineering + description: The engineering team + members: [employee@acme.com] roles: - slug: admin diff --git a/SUPPORTED.md b/SUPPORTED.md index 04575a2..0b37470 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -2,7 +2,7 @@ # Supported Features -The emulator implements **131 of 212** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.59.0`) (**61.8%**). +The emulator implements **140 of 212** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.59.0`) (**66.0%**). Endpoint coverage says whether a route exists, not whether a feature is usable; for example, Directory Sync implements every endpoint the spec defines for it and is @@ -22,12 +22,13 @@ answers "can I actually emulate this?". | Organizations | ✅ 5/5 | ✅ 6/6 | ✅ seed `organizations` | | | User Management | ✅ 8/8 | ⚠️ 7/9 | ✅ seed `users` | Email-change confirm/send endpoints are not implemented. | | Authentication | ⚠️ 3/4 | ⚠️ 4/5 | ⚠️ API only | All grant types are hand-written rather than generated from the spec. Refresh tokens always rotate, which is stricter than production. | -| Organization Memberships | ⚠️ 2/3 | ✅ 5/5 | ⚠️ API only | Seeded via `memberships` nested under an organization. Membership groups are not implemented. | +| Organization Memberships | ✅ 3/3 | ✅ 5/5 | ✅ seed `memberships` | Seeded via `memberships` nested under an organization. | +| Groups | ✅ 3/3 | ✅ 5/5 | ✅ seed `groups` | Seeded via `groups` nested under an organization. Members reference a seeded membership by email. | | Invitations | ✅ 3/3 | ✅ 4/4 | ✅ seed `invitations` | | | SSO | ✅ 5/5 | ✅ 3/3 | ✅ seed `connections` | Seeded connections carry `profiles`, which drive the SSO login flow. | | Directory Sync | ✅ 6/6 | ✅ 1/1 | ❌ none | Read-only. Every spec endpoint is implemented and all `dsync.*` events are wired, but nothing can create a directory: there is no POST route and no seed key. Node callers can insert directly via `getWorkOSStore(emulator.store)`, which does emit the events. `dsync.group.user_added` / `user_removed` are never emitted — there is no group membership mutation surface. | | Multi-Factor Auth | ✅ 2/2 | ✅ 5/5 | ⚠️ API only | TOTP codes are accepted without verifying the shared secret. | -| FGA / Authorization | ⚠️ 13/22 | ⚠️ 13/31 | ✅ seed `roles`, `permissions` | Warrant/check semantics are partial; group endpoints are not implemented. | +| FGA / Authorization | ⚠️ 13/19 | ⚠️ 13/26 | ✅ seed `roles`, `permissions` | Warrant/check semantics are partial; group role assignments are not implemented. | | Audit Logs | ⚠️ 3/4 | ⚠️ 3/4 | ⚠️ API only | Events are stored and queryable. Export generation is not implemented. | | Vault | ❌ 0/5 | ❌ 0/6 | ❌ none | Not implemented. | | Feature Flags | ✅ 4/4 | ⚠️ 1/4 | ⚠️ API only | Enable/disable and targeting exist, but under different verbs than the spec (`POST /feature-flags/:slug/enable` where the spec says `PUT`), so they do not count toward coverage. | diff --git a/scripts/gen-supported-lib.spec.ts b/scripts/gen-supported-lib.spec.ts index 5f456c0..95395b3 100644 --- a/scripts/gen-supported-lib.spec.ts +++ b/scripts/gen-supported-lib.spec.ts @@ -218,6 +218,11 @@ describe('deriveSetup', () => { expect(cell).toEqual({ level: 'none', label: 'none' }); }); + it('reports seeding via a nested config section that is not a top-level key', () => { + const cell = deriveSetup({ name: 'Groups', tags: [], seedVia: 'groups' }, seedKeys, 1); + expect(cell).toEqual({ level: 'full', label: 'seed `groups`' }); + }); + it('reports automatic for features whose data is a side effect', () => { const cell = deriveSetup({ name: 'Events', tags: [], automatic: true }, seedKeys, 0); expect(cell).toEqual({ level: 'full', label: 'automatic' }); diff --git a/scripts/gen-supported-lib.ts b/scripts/gen-supported-lib.ts index 136c2ea..c181bf7 100644 --- a/scripts/gen-supported-lib.ts +++ b/scripts/gen-supported-lib.ts @@ -67,6 +67,15 @@ export interface FeatureDef { * silently promising seeding that does not exist. */ seedKeys?: string[]; + /** + * The feature is seedable through a config section that is not a top-level + * `EmulatorSeedConfig` key — e.g. `memberships` or `groups` nested under + * `organizations` — so it cannot be verified the way `seedKeys` can. When set, + * `Set up` reports seeding honestly instead of falling back to "API only"; + * the note explains where the nested key lives. A top-level `seedKeys` entry, + * if present, takes priority. + */ + seedVia?: string; /** * Path prefixes for emulator-specific creation routes that have no spec * equivalent, e.g. `POST /feature-flags/:slug/enable`. These are excluded @@ -110,7 +119,14 @@ export const FEATURES: FeatureDef[] = [ { name: 'Organization Memberships', tags: ['user-management.organization-membership', 'user-management.organization-membership.groups'], - notes: 'Seeded via `memberships` nested under an organization. Membership groups are not implemented.', + seedVia: 'memberships', + notes: 'Seeded via `memberships` nested under an organization.', + }, + { + name: 'Groups', + tags: ['groups'], + seedVia: 'groups', + notes: 'Seeded via `groups` nested under an organization. Members reference a seeded membership by email.', }, { name: 'Invitations', @@ -136,9 +152,9 @@ export const FEATURES: FeatureDef[] = [ }, { name: 'FGA / Authorization', - tags: ['authorization', 'permissions', 'groups'], + tags: ['authorization', 'permissions'], seedKeys: ['roles', 'permissions'], - notes: 'Warrant/check semantics are partial; group endpoints are not implemented.', + notes: 'Warrant/check semantics are partial; group role assignments are not implemented.', }, { name: 'Audit Logs', @@ -474,6 +490,9 @@ export function deriveSetup(feature: FeatureDef, seedConfigKeys: string[], imple if (seedKeys.length > 0) { return { level: 'full', label: `seed \`${seedKeys.join('`, `')}\`` }; } + if (feature.seedVia) { + return { level: 'full', label: `seed \`${feature.seedVia}\`` }; + } if (feature.automatic) { return { level: 'full', label: 'automatic' }; } diff --git a/src/core/id.ts b/src/core/id.ts index 882be93..f2019ab 100644 --- a/src/core/id.ts +++ b/src/core/id.ts @@ -44,6 +44,8 @@ export const ID_PREFIXES = { organization: 'org', organization_membership: 'om', organization_domain: 'org_domain', + group: 'group', + group_membership: 'gm', connection: 'conn', connection_domain: 'conn_domain', directory: 'directory', diff --git a/src/workos/config-validator.ts b/src/workos/config-validator.ts index e1d1f23..99f4c7a 100644 --- a/src/workos/config-validator.ts +++ b/src/workos/config-validator.ts @@ -250,6 +250,75 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe }); } } + if (org.groups) { + if (!Array.isArray(org.groups)) { + errors.push({ + path: `organizations[${index}].groups`, + message: 'groups must be an array if provided', + value: org.groups, + }); + } else { + // Group members reference an org membership by the user's email, and that + // membership must be one declared in this org's `memberships` — the only seed + // path that creates org memberships. Collect those emails to cross-reference, + // the way `userEmails` cross-references membership emails against users. + const orgMembershipEmails = new Set( + (org.memberships ?? []) + .map((m) => seedEmail(m.email)) + .filter((r): r is { ok: true; email: string } => r.ok) + .map((r) => r.email.toLowerCase()), + ); + org.groups.forEach((group, gIndex) => { + if (!group.name || typeof group.name !== 'string') { + errors.push({ + path: `organizations[${index}].groups[${gIndex}].name`, + message: 'name is required and must be a string', + value: group.name, + }); + } + if ( + group.description !== undefined && + group.description !== null && + typeof group.description !== 'string' + ) { + errors.push({ + path: `organizations[${index}].groups[${gIndex}].description`, + message: 'description must be a string or null if provided', + value: group.description, + }); + } + if (group.members) { + if (!Array.isArray(group.members)) { + errors.push({ + path: `organizations[${index}].groups[${gIndex}].members`, + message: 'members must be an array of emails if provided', + value: group.members, + }); + } else { + group.members.forEach((email, mIndex) => { + const memberEmail = seedEmail(email); + if (!memberEmail.ok) { + errors.push({ + path: `organizations[${index}].groups[${gIndex}].members[${mIndex}]`, + message: + memberEmail.problem === 'malformed' + ? 'must be a valid email address' + : 'each member must be the email of a user', + value: email, + }); + } else if (!orgMembershipEmails.has(memberEmail.email.toLowerCase())) { + errors.push({ + path: `organizations[${index}].groups[${gIndex}].members[${mIndex}]`, + message: "member email must match a membership defined in this organization's `memberships`", + value: email, + }); + } + }); + } + } + }); + } + } }); // Organization name is the lookup key for connections, connectApplications, and diff --git a/src/workos/entities.ts b/src/workos/entities.ts index 68429a3..a652da4 100644 --- a/src/workos/entities.ts +++ b/src/workos/entities.ts @@ -46,6 +46,20 @@ export interface WorkOSOrganizationMembership extends Entity { metadata: Record; } +/** An AuthKit group within an organization (`/organizations/{org}/groups`). */ +export interface WorkOSGroup extends Entity { + object: 'group'; + organization_id: string; + name: string; + description: string | null; +} + +/** Join between a group and an organization membership. Internal — never serialized by id. */ +export interface WorkOSGroupMembership extends Entity { + group_id: string; + organization_membership_id: string; +} + export interface WorkOSUser extends Entity { object: 'user'; email: string; diff --git a/src/workos/helpers.ts b/src/workos/helpers.ts index e3fae22..47de718 100644 --- a/src/workos/helpers.ts +++ b/src/workos/helpers.ts @@ -16,6 +16,7 @@ import type { WorkOSOrganization, WorkOSOrganizationDomain, WorkOSOrganizationMembership, + WorkOSGroup, WorkOSUser, WorkOSSession, WorkOSEmailVerification, @@ -148,6 +149,32 @@ export function formatMembershipEvent(m: WorkOSOrganizationMembership): Record { + return { + object: 'organization_membership', + id: m.id, + user_id: m.user_id, + organization_id: m.organization_id, + status: m.status, + directory_managed: false, + custom_attributes: {}, + created_at: m.created_at, + updated_at: m.updated_at, + }; +} + +/** An AuthKit group (`group` object). `formatEntity` yields exactly the spec's `Group` shape. */ +export function formatGroup(g: WorkOSGroup): Record { + return formatEntity(g); +} + const USER_EXCLUDE = new Set([...INTERNAL_FIELDS, 'impersonator', 'oauth_provider']); export function formatUser(user: WorkOSUser): Record { diff --git a/src/workos/index.ts b/src/workos/index.ts index 3547645..a352539 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -5,6 +5,7 @@ import { getWorkOSStore } from './store.js'; import { organizationRoutes } from './routes/organizations.js'; import { organizationDomainRoutes } from './routes/organization-domains.js'; import { membershipRoutes } from './routes/memberships.js'; +import { groupRoutes } from './routes/groups.js'; import { userRoutes } from './routes/users.js'; import { emailVerificationRoutes } from './routes/email-verification.js'; import { passwordResetRoutes } from './routes/password-reset.js'; @@ -48,6 +49,7 @@ import { formatUser, formatOrganization, formatMembershipEvent, + formatGroup, formatConnection, formatSession, formatInvitation, @@ -99,6 +101,17 @@ export interface WorkOSSeedOrganization { role?: string; status?: 'active' | 'inactive' | 'pending'; }>; + /** + * AuthKit groups within this organization. Members reference a membership of this + * org by the user's email — the same join key `memberships` use — because org + * membership ids are generated at startup, so an id literal could never resolve. + */ + groups?: Array<{ + name: string; + description?: string | null; + /** Emails of users that have a membership in this organization. */ + members?: string[]; + }>; } export interface WorkOSSeedUser { @@ -345,6 +358,44 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee }); } } + + if (orgConfig.groups) { + for (const gg of orgConfig.groups) { + const group = ws.groups.insert({ + object: 'group', + organization_id: org.id, + name: gg.name, + description: gg.description ?? null, + }); + + if (gg.members) { + for (const memberEmail of gg.members) { + // Group members reference an org membership by the user's email — the + // same join key memberships use. validateSeedConfig guarantees the email + // matches a membership of this org; resolving the user then the membership + // here is the lookup that turns that guarantee into a join row. + const memberUser = findUserByEmail(ws, memberEmail); + if (!memberUser) { + throw new Error( + `Seed group '${gg.name}' references unknown user '${memberEmail}' (organization '${orgConfig.name}')`, + ); + } + const membership = ws.organizationMemberships + .findBy('organization_id', org.id) + .find((m) => m.user_id === memberUser.id); + if (!membership) { + throw new Error( + `Seed group '${gg.name}' member '${memberEmail}' has no membership in organization '${orgConfig.name}'`, + ); + } + ws.groupMemberships.insert({ + group_id: group.id, + organization_membership_id: membership.id, + }); + } + } + } + } } } @@ -569,6 +620,7 @@ export const workosPlugin: ServicePlugin = { organizationRoutes(ctx); organizationDomainRoutes(ctx); membershipRoutes(ctx); + groupRoutes(ctx); userRoutes(ctx); emailVerificationRoutes(ctx); passwordResetRoutes(ctx); @@ -639,6 +691,26 @@ export const workosPlugin: ServicePlugin = { onUpdate: (m) => eventBus.emit({ event: EVENTS.organizationMembershipUpdated, data: formatMembershipEvent(m) }), onDelete: (m) => eventBus.emit({ event: EVENTS.organizationMembershipDeleted, data: formatMembershipEvent(m) }), }); + // AuthKit groups. `group.created`/`updated`/`deleted` carry the full Group object the + // spec's event data requires; `group.member_added`/`member_removed` carry only the two + // ids. Hook-driven (not inline in the routes) so seeded groups fire the same events. + ws.groups.setHooks({ + onInsert: (g) => eventBus.emit({ event: EVENTS.groupCreated, data: formatGroup(g) }), + onUpdate: (g) => eventBus.emit({ event: EVENTS.groupUpdated, data: formatGroup(g) }), + onDelete: (g) => eventBus.emit({ event: EVENTS.groupDeleted, data: formatGroup(g) }), + }); + ws.groupMemberships.setHooks({ + onInsert: (gm) => + eventBus.emit({ + event: EVENTS.groupMemberAdded, + data: { group_id: gm.group_id, organization_membership_id: gm.organization_membership_id }, + }), + onDelete: (gm) => + eventBus.emit({ + event: EVENTS.groupMemberRemoved, + data: { group_id: gm.group_id, organization_membership_id: gm.organization_membership_id }, + }), + }); ws.connections.setHooks({ // The spec has no connection.created/updated — only activation state transitions onInsert: (c) => { diff --git a/src/workos/routes/groups.spec.ts b/src/workos/routes/groups.spec.ts new file mode 100644 index 0000000..d723003 --- /dev/null +++ b/src/workos/routes/groups.spec.ts @@ -0,0 +1,322 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { createServer, type ApiKeyMap } from '../../core/index.js'; +import { workosPlugin } from '../index.js'; + +const apiKeys: ApiKeyMap = { sk_test_grp: { environment: 'test' } }; +const headers = { Authorization: 'Bearer sk_test_grp', 'Content-Type': 'application/json' }; + +function createTestApp() { + return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); +} + +describe('Group routes', () => { + let app: ReturnType['app']; + + beforeEach(() => { + app = createTestApp().app; + }); + + const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); + const json = (res: Response) => res.json() as Promise; + + async function createOrg(name: string) { + return json(await req('/organizations', { method: 'POST', body: JSON.stringify({ name }) })); + } + + async function createUser(email: string) { + return json(await req('/user_management/users', { method: 'POST', body: JSON.stringify({ email }) })); + } + + async function createMembership(orgId: string, userId: string, role = 'member') { + return json( + await req('/user_management/organization_memberships', { + method: 'POST', + body: JSON.stringify({ organization_id: orgId, user_id: userId, role_slug: role }), + }), + ); + } + + async function createGroup(orgId: string, name: string, description?: string) { + return json( + await req(`/organizations/${orgId}/groups`, { + method: 'POST', + body: JSON.stringify({ name, description }), + }), + ); + } + + async function events(...types: string[]) { + const qs = types.map((t) => `events[]=${t}`).join('&'); + return json(await req(`/events?${qs}`)); + } + + it('creates a group', async () => { + const org = await createOrg('Group Org'); + const res = await req(`/organizations/${org.id}/groups`, { + method: 'POST', + body: JSON.stringify({ name: 'Engineering', description: 'The engineering team' }), + }); + expect(res.status).toBe(201); + const g = await json(res); + expect(g.object).toBe('group'); + expect(g.id).toMatch(/^group_/); + expect(g.organization_id).toBe(org.id); + expect(g.name).toBe('Engineering'); + expect(g.description).toBe('The engineering team'); + expect(g.created_at).toBeTruthy(); + expect(g.updated_at).toBeTruthy(); + }); + + it('defaults description to null and emits group.created', async () => { + const org = await createOrg('Desc Org'); + const g = await createGroup(org.id, 'Sales'); + expect(g.description).toBeNull(); + + const evts = await events('group.created'); + expect(evts.data.some((e: any) => e.data.id === g.id)).toBe(true); + }); + + it('404s creating a group in an unknown organization', async () => { + const res = await req('/organizations/org_does_not_exist/groups', { + method: 'POST', + body: JSON.stringify({ name: 'X' }), + }); + expect(res.status).toBe(404); + }); + + it('422s creating a group without a name', async () => { + const org = await createOrg('No Name Org'); + const res = await req(`/organizations/${org.id}/groups`, { + method: 'POST', + body: JSON.stringify({ description: 'no name' }), + }); + expect(res.status).toBe(422); + }); + + it('lists groups within an organization', async () => { + const org = await createOrg('List Org'); + await createGroup(org.id, 'A'); + await createGroup(org.id, 'B'); + + const list = await json(await req(`/organizations/${org.id}/groups`)); + expect(list.object).toBe('list'); + expect(list.data).toHaveLength(2); + }); + + it('gets, updates, and deletes a group', async () => { + const org = await createOrg('CRUD Org'); + const g = await createGroup(org.id, 'Old'); + + const got = await json(await req(`/organizations/${org.id}/groups/${g.id}`)); + expect(got.name).toBe('Old'); + + const updated = await json( + await req(`/organizations/${org.id}/groups/${g.id}`, { + method: 'PATCH', + body: JSON.stringify({ name: 'New', description: 'updated' }), + }), + ); + expect(updated.name).toBe('New'); + expect(updated.description).toBe('updated'); + + const evts = await events('group.updated'); + expect(evts.data.some((e: any) => e.data.id === g.id)).toBe(true); + + const del = await req(`/organizations/${org.id}/groups/${g.id}`, { method: 'DELETE' }); + expect(del.status).toBe(204); + + const after = await req(`/organizations/${org.id}/groups/${g.id}`); + expect(after.status).toBe(404); + }); + + it('404s a group from a different organization', async () => { + const orgA = await createOrg('Org A'); + const orgB = await createOrg('Org B'); + const g = await createGroup(orgA.id, 'A-only'); + + expect((await req(`/organizations/${orgB.id}/groups/${g.id}`)).status).toBe(404); + expect((await req(`/organizations/${orgB.id}/groups/${g.id}`, { method: 'DELETE' })).status).toBe(404); + }); + + it('422s updating a group with an empty name', async () => { + const org = await createOrg('Empty Update Org'); + const g = await createGroup(org.id, 'Keep'); + const res = await req(`/organizations/${org.id}/groups/${g.id}`, { + method: 'PATCH', + body: JSON.stringify({ name: '' }), + }); + expect(res.status).toBe(422); + }); + + it('emits group.deleted and drops members', async () => { + const org = await createOrg('Delete Drop Org'); + const user = await createUser('drop@test.com'); + const m = await createMembership(org.id, user.id); + const g = await createGroup(org.id, 'Doomed'); + + await req(`/organizations/${org.id}/groups/${g.id}/organization-memberships`, { + method: 'POST', + body: JSON.stringify({ organization_membership_id: m.id }), + }); + + await req(`/organizations/${org.id}/groups/${g.id}`, { method: 'DELETE' }); + + const evts = await events('group.deleted'); + expect(evts.data.some((e: any) => e.data.id === g.id)).toBe(true); + + // A group deletion is one event — not a `member_removed` per member. + const removedEvts = await events('group.member_removed'); + expect(removedEvts.data.filter((e: any) => e.data.group_id === g.id)).toHaveLength(0); + + // The membership still exists; only the group is gone. + const stillMember = await req(`/user_management/organization_memberships/${m.id}`); + expect(stillMember.status).toBe(200); + + // And the membership is no longer listed as belonging to any group. + const groups = await json(await req(`/user_management/organization_memberships/${m.id}/groups`)); + expect(groups.data).toHaveLength(0); + }); + + it('adds a member, lists members, and removes a member', async () => { + const org = await createOrg('Member Org'); + const user = await createUser('member@test.com'); + const m = await createMembership(org.id, user.id); + const g = await createGroup(org.id, 'Eng'); + + const added = await json( + await req(`/organizations/${org.id}/groups/${g.id}/organization-memberships`, { + method: 'POST', + body: JSON.stringify({ organization_membership_id: m.id }), + }), + ); + expect(added.object).toBe('group'); + + const memberEvts = await events('group.member_added'); + expect(memberEvts.data.some((e: any) => e.data.group_id === g.id)).toBe(true); + + const list = await json(await req(`/organizations/${org.id}/groups/${g.id}/organization-memberships`)); + expect(list.data).toHaveLength(1); + // The base membership shape: identifying fields, no embedded user or roles. + const base = list.data[0]; + expect(base.object).toBe('organization_membership'); + expect(base.id).toBe(m.id); + expect(base.user_id).toBe(user.id); + expect(base.organization_id).toBe(org.id); + expect(base.status).toBe('active'); + expect(base.directory_managed).toBe(false); + expect(base).not.toHaveProperty('user'); + expect(base).not.toHaveProperty('roles'); + + const removed = await req(`/organizations/${org.id}/groups/${g.id}/organization-memberships/${m.id}`, { + method: 'DELETE', + }); + expect(removed.status).toBe(204); + + const removedEvts = await events('group.member_removed'); + expect(removedEvts.data.some((e: any) => e.data.group_id === g.id)).toBe(true); + + const after = await json(await req(`/organizations/${org.id}/groups/${g.id}/organization-memberships`)); + expect(after.data).toHaveLength(0); + }); + + it('is idempotent when adding an existing member', async () => { + const org = await createOrg('Idempotent Org'); + const user = await createUser('idem@test.com'); + const m = await createMembership(org.id, user.id); + const g = await createGroup(org.id, 'Idem'); + + await req(`/organizations/${org.id}/groups/${g.id}/organization-memberships`, { + method: 'POST', + body: JSON.stringify({ organization_membership_id: m.id }), + }); + const second = await req(`/organizations/${org.id}/groups/${g.id}/organization-memberships`, { + method: 'POST', + body: JSON.stringify({ organization_membership_id: m.id }), + }); + expect(second.status).toBe(200); + + const list = await json(await req(`/organizations/${org.id}/groups/${g.id}/organization-memberships`)); + expect(list.data).toHaveLength(1); + }); + + it('404s adding an unknown membership', async () => { + const org = await createOrg('Unknown Om Org'); + const g = await createGroup(org.id, 'X'); + const res = await req(`/organizations/${org.id}/groups/${g.id}/organization-memberships`, { + method: 'POST', + body: JSON.stringify({ organization_membership_id: 'om_does_not_exist' }), + }); + expect(res.status).toBe(404); + }); + + it('422s adding a membership from a different organization', async () => { + const orgA = await createOrg('Cross A'); + const orgB = await createOrg('Cross B'); + const user = await createUser('cross@test.com'); + const mB = await createMembership(orgB.id, user.id); + const gA = await createGroup(orgA.id, 'A group'); + + const res = await req(`/organizations/${orgA.id}/groups/${gA.id}/organization-memberships`, { + method: 'POST', + body: JSON.stringify({ organization_membership_id: mB.id }), + }); + expect(res.status).toBe(422); + }); + + it('422s adding a member without an organization_membership_id', async () => { + const org = await createOrg('Missing Om Org'); + const g = await createGroup(org.id, 'X'); + const res = await req(`/organizations/${org.id}/groups/${g.id}/organization-memberships`, { + method: 'POST', + body: JSON.stringify({}), + }); + expect(res.status).toBe(422); + }); + + it('404s removing a membership that is not in the group', async () => { + const org = await createOrg('Remove NotMember Org'); + const user = await createUser('notin@test.com'); + const m = await createMembership(org.id, user.id); + const g = await createGroup(org.id, 'X'); + + const res = await req(`/organizations/${org.id}/groups/${g.id}/organization-memberships/${m.id}`, { + method: 'DELETE', + }); + expect(res.status).toBe(404); + }); + + it('lists the groups an organization membership belongs to', async () => { + const org = await createOrg('ListForOm Org'); + const user = await createUser('listfor@test.com'); + const m = await createMembership(org.id, user.id); + const g1 = await createGroup(org.id, 'One'); + const g2 = await createGroup(org.id, 'Two'); + + await req(`/organizations/${org.id}/groups/${g1.id}/organization-memberships`, { + method: 'POST', + body: JSON.stringify({ organization_membership_id: m.id }), + }); + await req(`/organizations/${org.id}/groups/${g2.id}/organization-memberships`, { + method: 'POST', + body: JSON.stringify({ organization_membership_id: m.id }), + }); + + const list = await json(await req(`/user_management/organization_memberships/${m.id}/groups`)); + expect(list.data).toHaveLength(2); + expect(list.data.every((g: any) => g.object === 'group')).toBe(true); + }); + + it('404s listing groups for an unknown membership', async () => { + const res = await req('/user_management/organization_memberships/om_none/groups'); + expect(res.status).toBe(404); + }); + + it('keeps groups isolated between organizations', async () => { + const orgA = await createOrg('Iso A'); + const orgB = await createOrg('Iso B'); + await createGroup(orgA.id, 'Only A'); + + const listB = await json(await req(`/organizations/${orgB.id}/groups`)); + expect(listB.data).toHaveLength(0); + }); +}); diff --git a/src/workos/routes/groups.ts b/src/workos/routes/groups.ts new file mode 100644 index 0000000..601645b --- /dev/null +++ b/src/workos/routes/groups.ts @@ -0,0 +1,185 @@ +import { + type RouteContext, + notFound, + validationError, + parseJsonBody, + parseListParams, + cursorPaginate, +} from '../../core/index.js'; +import { getWorkOSStore } from '../store.js'; +import { formatGroup, formatMembershipBase, formatListResponse } from '../helpers.js'; + +/** + * AuthKit Groups (`/organizations/{organizationId}/groups`) — the org-scoped groups product + * described at https://workos.com/docs/authkit/groups. A group belongs to one organization; + * its members are organization memberships of that org, joined through `groupMemberships`. + * + * Events (`group.created` / `updated` / `deleted`, `group.member_added` / `member_removed`) + * are emitted by collection hooks registered in `workosPlugin.register`, not inline here, so + * seeded groups and API-created ones fire the same events. + */ +export function groupRoutes(ctx: RouteContext): void { + const { app, store } = ctx; + const ws = getWorkOSStore(store); + + // Create a group + app.post('/organizations/:organizationId/groups', async (c) => { + const organizationId = c.req.param('organizationId'); + if (!ws.organizations.get(organizationId)) throw notFound('Organization'); + + const body = await parseJsonBody(c); + const name = body.name; + if (typeof name !== 'string' || name.length === 0) { + throw validationError('name is required', [{ field: 'name', code: 'required' }]); + } + + const group = ws.groups.insert({ + object: 'group', + organization_id: organizationId, + name, + description: typeof body.description === 'string' ? body.description : null, + }); + + return c.json(formatGroup(group), 201); + }); + + // List groups in an organization + app.get('/organizations/:organizationId/groups', (c) => { + const organizationId = c.req.param('organizationId'); + if (!ws.organizations.get(organizationId)) throw notFound('Organization'); + + const url = new URL(c.req.url); + const params = parseListParams(url); + const result = ws.groups.list({ + ...params, + filter: (g) => g.organization_id === organizationId, + }); + + return c.json(formatListResponse(result, formatGroup)); + }); + + // Get a group + app.get('/organizations/:organizationId/groups/:groupId', (c) => { + const group = ws.groups.get(c.req.param('groupId')); + if (!group || group.organization_id !== c.req.param('organizationId')) throw notFound('Group'); + return c.json(formatGroup(group)); + }); + + // Update a group + app.patch('/organizations/:organizationId/groups/:groupId', async (c) => { + const group = ws.groups.get(c.req.param('groupId')); + if (!group || group.organization_id !== c.req.param('organizationId')) throw notFound('Group'); + + const body = await parseJsonBody(c); + const updates: Record = {}; + + if ('name' in body) { + if (typeof body.name !== 'string' || body.name.length === 0) { + throw validationError('name must be a non-empty string', [{ field: 'name', code: 'invalid' }]); + } + updates.name = body.name; + } + if ('description' in body) { + updates.description = typeof body.description === 'string' ? body.description : null; + } + + const updated = ws.groups.update(group.id, updates); + return c.json(formatGroup(updated!)); + }); + + // Delete a group + app.delete('/organizations/:organizationId/groups/:groupId', (c) => { + const group = ws.groups.get(c.req.param('groupId')); + if (!group || group.organization_id !== c.req.param('organizationId')) throw notFound('Group'); + + // Join rows are left in place rather than deleted here: deleting them would fire a + // `group.member_removed` event per member, but a group deletion is one event + // (`group.deleted`). The dangling rows are harmless — every read resolves the + // group or membership and skips a miss (see the list endpoints below). + ws.groups.delete(group.id); + return c.body(null, 204); + }); + + // Add an organization membership to a group + app.post('/organizations/:organizationId/groups/:groupId/organization-memberships', async (c) => { + const group = ws.groups.get(c.req.param('groupId')); + if (!group || group.organization_id !== c.req.param('organizationId')) throw notFound('Group'); + + const body = await parseJsonBody(c); + const omId = body.organization_membership_id; + if (typeof omId !== 'string' || omId.length === 0) { + throw validationError('organization_membership_id is required', [ + { field: 'organization_membership_id', code: 'required' }, + ]); + } + + const membership = ws.organizationMemberships.get(omId); + if (!membership) throw notFound('Organization Membership'); + + if (membership.organization_id !== group.organization_id) { + throw validationError('Organization Membership does not belong to this organization', [ + { field: 'organization_membership_id', code: 'invalid' }, + ]); + } + + // Idempotent: adding an existing member returns the group rather than erroring, matching + // production's tolerant re-add. + const existing = ws.groupMemberships + .findBy('group_id', group.id) + .find((gm) => gm.organization_membership_id === omId); + if (!existing) { + ws.groupMemberships.insert({ group_id: group.id, organization_membership_id: omId }); + } + + return c.json(formatGroup(group)); + }); + + // List the organization memberships in a group + app.get('/organizations/:organizationId/groups/:groupId/organization-memberships', (c) => { + const group = ws.groups.get(c.req.param('groupId')); + if (!group || group.organization_id !== c.req.param('organizationId')) throw notFound('Group'); + + const url = new URL(c.req.url); + const params = parseListParams(url); + + // Resolve join rows to memberships, skipping any whose membership was deleted (user + // deletion cascades memberships but not join rows) so a dangling id never surfaces. + const memberships = ws.groupMemberships + .findBy('group_id', group.id) + .map((gm) => ws.organizationMemberships.get(gm.organization_membership_id)) + .filter((m): m is NonNullable => m !== undefined); + + const result = cursorPaginate(memberships, params); + return c.json(formatListResponse(result, formatMembershipBase)); + }); + + // Remove an organization membership from a group + app.delete('/organizations/:organizationId/groups/:groupId/organization-memberships/:omId', (c) => { + const group = ws.groups.get(c.req.param('groupId')); + if (!group || group.organization_id !== c.req.param('organizationId')) throw notFound('Group'); + + const omId = c.req.param('omId'); + const gm = ws.groupMemberships.findBy('group_id', group.id).find((row) => row.organization_membership_id === omId); + if (!gm) throw notFound('Organization Membership'); + + ws.groupMemberships.delete(gm.id); + return c.body(null, 204); + }); + + // List the groups an organization membership belongs to + app.get('/user_management/organization_memberships/:omId/groups', (c) => { + const omId = c.req.param('omId'); + if (!ws.organizationMemberships.get(omId)) throw notFound('Organization Membership'); + + const url = new URL(c.req.url); + const params = parseListParams(url); + + const groups = ws.groupMemberships + .findBy('organization_membership_id', omId) + .map((gm) => ws.groups.get(gm.group_id)) + .filter((g): g is NonNullable => g !== undefined); + + const result = cursorPaginate(groups, params); + return c.json(formatListResponse(result, formatGroup)); + }); +} diff --git a/src/workos/seed-groups.spec.ts b/src/workos/seed-groups.spec.ts new file mode 100644 index 0000000..325575b --- /dev/null +++ b/src/workos/seed-groups.spec.ts @@ -0,0 +1,269 @@ +/** + * Seeding AuthKit groups. Groups are nested under an organization, and their members + * reference an organization membership by the user's email — the same join key + * `memberships` use, since org membership ids are generated at startup. validateSeedConfig + * rejects a member email that does not match a membership declared in the same org. + */ +import { describe, it, expect, afterEach } from 'bun:test'; +import { createEmulator, type Emulator } from '../index.js'; +import { validateSeedConfig } from './config-validator.js'; + +describe('Seeding groups', () => { + let emulator: Emulator | undefined; + + afterEach(async () => { + await emulator?.close(); + emulator = undefined; + }); + + const auth = (apiKey: string) => ({ Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }); + + it('seeds a group and joins its members to memberships by email', async () => { + emulator = await createEmulator({ + port: 0, + seed: { + users: [{ email: 'admin@acme.com' }, { email: 'dev@acme.com' }], + organizations: [ + { + name: 'Acme Corp', + memberships: [{ email: 'admin@acme.com', role: 'admin' }, { email: 'dev@acme.com' }], + groups: [{ name: 'Engineering', description: 'The engineering team', members: ['dev@acme.com'] }], + }, + ], + }, + }); + + // Resolve the org id through the organizations list, since it is generated at startup. + const orgs = (await ( + await fetch(`${emulator.url}/organizations`, { headers: auth(emulator.apiKey) }) + ).json()) as any; + const org = orgs.data[0]; + + const groupList = (await ( + await fetch(`${emulator.url}/organizations/${org.id}/groups`, { + headers: auth(emulator.apiKey), + }) + ).json()) as any; + expect(groupList.data).toHaveLength(1); + const g = groupList.data[0]; + expect(g.name).toBe('Engineering'); + expect(g.description).toBe('The engineering team'); + + const members = (await ( + await fetch(`${emulator.url}/organizations/${org.id}/groups/${g.id}/organization-memberships`, { + headers: auth(emulator.apiKey), + }) + ).json()) as any; + expect(members.data).toHaveLength(1); + expect(members.data[0].user_id).toMatch(/^user_/); + }); + + it('emits group.created and group.member_added for seeded groups', async () => { + emulator = await createEmulator({ + port: 0, + seed: { + users: [{ email: 'admin@acme.com' }], + organizations: [ + { + name: 'Acme Corp', + memberships: [{ email: 'admin@acme.com' }], + groups: [{ name: 'Engineering', members: ['admin@acme.com'] }], + }, + ], + }, + }); + + const evts = (await ( + await fetch(`${emulator.url}/events?events[]=group.created&events[]=group.member_added`, { + headers: auth(emulator.apiKey), + }) + ).json()) as any; + const types = new Set(evts.data.map((e: any) => e.event)); + expect(types.has('group.created')).toBe(true); + expect(types.has('group.member_added')).toBe(true); + }); + + it('joins a group member to its membership case-insensitively', async () => { + emulator = await createEmulator({ + port: 0, + seed: { + users: [{ email: 'Admin@Acme.com' }], + organizations: [ + { + name: 'Acme Corp', + memberships: [{ email: 'admin@acme.com' }], + groups: [{ name: 'Eng', members: ['ADMIN@ACME.com'] }], + }, + ], + }, + }); + + const orgs = (await ( + await fetch(`${emulator.url}/organizations`, { headers: auth(emulator.apiKey) }) + ).json()) as any; + const groups = (await ( + await fetch(`${emulator.url}/organizations/${orgs.data[0].id}/groups`, { + headers: auth(emulator.apiKey), + }) + ).json()) as any; + const members = (await ( + await fetch( + `${emulator.url}/organizations/${orgs.data[0].id}/groups/${groups.data[0].id}/organization-memberships`, + { headers: auth(emulator.apiKey) }, + ) + ).json()) as any; + expect(members.data).toHaveLength(1); + }); + + it('rejects startup when a group member has no membership in the organization', async () => { + await expect( + createEmulator({ + port: 0, + seed: { + users: [{ email: 'admin@acme.com' }], + organizations: [ + { + name: 'Acme Corp', + memberships: [{ email: 'admin@acme.com' }], + // A user who exists but has no membership in this org. + groups: [{ name: 'Eng', members: ['admin@acme.com', 'lonely@acme.com'] }], + }, + ], + }, + }), + ).rejects.toThrow(/must match a membership defined in this organization/); + }); + + describe('seed config validation', () => { + const findError = (config: Parameters[0], pathFragment: string) => { + const { valid, errors } = validateSeedConfig(config); + expect(valid).toBe(false); + const error = errors.find((e) => e.path.includes(pathFragment)); + expect(error, `expected an error at ${pathFragment}, got: ${JSON.stringify(errors)}`).toBeDefined(); + return error!; + }; + + const baseConfig = { + users: [{ email: 'admin@acme.com' }], + organizations: [ + { + name: 'Acme', + memberships: [{ email: 'admin@acme.com' }], + groups: [{ name: 'Eng', members: ['admin@acme.com'] }], + }, + ], + }; + + it('rejects a group without a name', () => { + const error = findError( + { + ...baseConfig, + organizations: [ + { name: 'Acme', memberships: [{ email: 'admin@acme.com' }], groups: [{ description: 'no name' } as never] }, + ], + }, + 'organizations[0].groups[0].name', + ); + expect(error.message).toContain('name is required'); + }); + + it('rejects a non-string description', () => { + const error = findError( + { + ...baseConfig, + organizations: [ + { + name: 'Acme', + memberships: [{ email: 'admin@acme.com' }], + groups: [{ name: 'Eng', description: 5 as never }], + }, + ], + }, + 'organizations[0].groups[0].description', + ); + expect(error.message).toContain('description must be a string or null'); + }); + + it('rejects groups that is not an array', () => { + const error = findError( + { + ...baseConfig, + organizations: [{ name: 'Acme', memberships: [{ email: 'admin@acme.com' }], groups: 'nope' as never }], + }, + 'organizations[0].groups', + ); + expect(error.message).toContain('groups must be an array'); + }); + + it('rejects members that is not an array', () => { + const error = findError( + { + ...baseConfig, + organizations: [ + { + name: 'Acme', + memberships: [{ email: 'admin@acme.com' }], + groups: [{ name: 'Eng', members: 'nope' as never }], + }, + ], + }, + 'organizations[0].groups[0].members', + ); + expect(error.message).toContain('members must be an array'); + }); + + it('rejects a member email that could only be a typo', () => { + const error = findError( + { + ...baseConfig, + organizations: [ + { name: 'Acme', memberships: [{ email: 'admin@acme.com' }], groups: [{ name: 'Eng', members: ['nope'] }] }, + ], + }, + 'organizations[0].groups[0].members[0]', + ); + expect(error.message).toContain('valid email address'); + }); + + it('rejects a member email that matches no membership in the organization', () => { + const error = findError( + { + users: [{ email: 'admin@acme.com' }, { email: 'other@acme.com' }], + organizations: [ + { + name: 'Acme', + memberships: [{ email: 'admin@acme.com' }], + groups: [{ name: 'Eng', members: ['other@acme.com'] }], + }, + ], + }, + 'organizations[0].groups[0].members[0]', + ); + expect(error.message).toContain('must match a membership defined in this organization'); + }); + + it('accepts a member email differing from its membership only in case', () => { + const { valid, errors } = validateSeedConfig({ + users: [{ email: 'Admin@Acme.com' }], + organizations: [ + { + name: 'Acme', + memberships: [{ email: 'admin@acme.com' }], + groups: [{ name: 'Eng', members: ['ADMIN@ACME.com'] }], + }, + ], + }); + expect(valid).toBe(true); + expect(errors).toEqual([]); + }); + + it('accepts a group with no members', () => { + const { valid, errors } = validateSeedConfig({ + users: [{ email: 'admin@acme.com' }], + organizations: [{ name: 'Acme', memberships: [{ email: 'admin@acme.com' }], groups: [{ name: 'Eng' }] }], + }); + expect(valid).toBe(true); + expect(errors).toEqual([]); + }); + }); +}); diff --git a/src/workos/store.ts b/src/workos/store.ts index 3ff1227..8621f8c 100644 --- a/src/workos/store.ts +++ b/src/workos/store.ts @@ -4,6 +4,8 @@ import type { WorkOSOrganization, WorkOSOrganizationDomain, WorkOSOrganizationMembership, + WorkOSGroup, + WorkOSGroupMembership, WorkOSUser, WorkOSSession, WorkOSEmailVerification, @@ -50,6 +52,8 @@ export interface WorkOSStore { organizations: Collection; organizationDomains: Collection; organizationMemberships: Collection; + groups: Collection; + groupMemberships: Collection; users: Collection; sessions: Collection; emailVerifications: Collection; @@ -111,6 +115,12 @@ export function getWorkOSStore(store: Store): WorkOSStore { ID_PREFIXES.organization_membership, ['organization_id', 'user_id'], ), + groups: store.collection('workos.groups', ID_PREFIXES.group, ['organization_id']), + groupMemberships: store.collection( + 'workos.group_memberships', + ID_PREFIXES.group_membership, + ['group_id', 'organization_membership_id'], + ), users: store.collection('workos.users', ID_PREFIXES.user, ['email', 'external_id']), sessions: store.collection('workos.sessions', ID_PREFIXES.session, ['user_id']), emailVerifications: store.collection( From cf90ad040151b1d489f3b00cc72c4ad031010c91 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 7 Aug 2026 18:36:04 -0400 Subject: [PATCH 2/3] fix(groups): guard seed validation against non-array memberships When `groups` were configured alongside a truthy non-array `memberships` value, the groups validation block called `.map()` on that invalid value and threw a `TypeError`, crashing startup and `--validate-config` instead of returning the structured memberships error the validator had already recorded. Guard the cross-reference with `Array.isArray`, mirroring the memberships block's own check. --- src/workos/config-validator.ts | 6 +++++- src/workos/seed-groups.spec.ts | 13 +++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/workos/config-validator.ts b/src/workos/config-validator.ts index 99f4c7a..0f1e1ec 100644 --- a/src/workos/config-validator.ts +++ b/src/workos/config-validator.ts @@ -262,8 +262,12 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe // membership must be one declared in this org's `memberships` — the only seed // path that creates org memberships. Collect those emails to cross-reference, // the way `userEmails` cross-references membership emails against users. + // Guard against a truthy non-array `memberships`: the memberships block above + // already recorded a structured error for that, and falling through to here + // would call `.map()` on the invalid value and crash startup instead of + // returning that error. const orgMembershipEmails = new Set( - (org.memberships ?? []) + (Array.isArray(org.memberships) ? org.memberships : []) .map((m) => seedEmail(m.email)) .filter((r): r is { ok: true; email: string } => r.ok) .map((r) => r.email.toLowerCase()), diff --git a/src/workos/seed-groups.spec.ts b/src/workos/seed-groups.spec.ts index 325575b..e3577ec 100644 --- a/src/workos/seed-groups.spec.ts +++ b/src/workos/seed-groups.spec.ts @@ -154,6 +154,19 @@ describe('Seeding groups', () => { ], }; + it('rejects groups without crashing when memberships is a truthy non-array', () => { + // The memberships block records a structured error for this; the groups block must + // not then call `.map()` on the invalid value and throw. Returns the memberships + // error rather than crashing startup or `--validate-config`. + const { valid, errors } = validateSeedConfig({ + organizations: [{ name: 'Acme', memberships: 'not-an-array' as never, groups: [{ name: 'Eng' }] }], + }); + expect(valid).toBe(false); + expect( + errors.some((e) => e.path === 'organizations[0].memberships' && e.message.includes('must be an array')), + ).toBe(true); + }); + it('rejects a group without a name', () => { const error = findError( { From cbd403607431a33d64a47de96a9d079c60b2fcfe Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 7 Aug 2026 18:40:34 -0400 Subject: [PATCH 3/3] fix(groups): guard seed validation against non-object group entries A non-object group entry such as `groups: [null]` dereferenced `group.name` and threw a `TypeError`, crashing startup and `--validate-config` instead of returning a structured error. Record an "each group must be an object" error and skip the property checks, matching the validator's structured-error approach. --- src/workos/config-validator.ts | 11 +++++++++++ src/workos/seed-groups.spec.ts | 14 ++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/workos/config-validator.ts b/src/workos/config-validator.ts index 0f1e1ec..45285cd 100644 --- a/src/workos/config-validator.ts +++ b/src/workos/config-validator.ts @@ -273,6 +273,17 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe .map((r) => r.email.toLowerCase()), ); org.groups.forEach((group, gIndex) => { + // A non-object entry (e.g. `groups: [null]` from a YAML/JSON typo) would + // throw on `group.name` below; record a structured error and skip the + // property checks rather than crashing startup or `--validate-config`. + if (group === null || typeof group !== 'object') { + errors.push({ + path: `organizations[${index}].groups[${gIndex}]`, + message: 'each group must be an object', + value: group, + }); + return; + } if (!group.name || typeof group.name !== 'string') { errors.push({ path: `organizations[${index}].groups[${gIndex}].name`, diff --git a/src/workos/seed-groups.spec.ts b/src/workos/seed-groups.spec.ts index e3577ec..b90b0df 100644 --- a/src/workos/seed-groups.spec.ts +++ b/src/workos/seed-groups.spec.ts @@ -167,6 +167,20 @@ describe('Seeding groups', () => { ).toBe(true); }); + it('rejects a non-object group entry without crashing', () => { + // `groups: [null]` (or any non-object entry) must record a structured error + // rather than throw on `group.name` during startup or `--validate-config`. + const { valid, errors } = validateSeedConfig({ + organizations: [{ name: 'Acme', groups: [null as never] }], + }); + expect(valid).toBe(false); + expect( + errors.some( + (e) => e.path === 'organizations[0].groups[0]' && e.message.includes('each group must be an object'), + ), + ).toBe(true); + }); + it('rejects a group without a name', () => { const error = findError( {