From 5ebdd8383f33058e535794d5ae7464122f7108d6 Mon Sep 17 00:00:00 2001 From: Adaptura Date: Fri, 11 Sep 2026 00:25:19 +0200 Subject: [PATCH 1/5] fix(users): match duplicate email creation contract --- bun.lock | 3 ++ package.json | 1 + src/core/middleware/error-handler.ts | 2 +- src/workos/routes/users.spec.ts | 77 ++++++++++++++++++++++++---- src/workos/routes/users.ts | 7 ++- 5 files changed, 78 insertions(+), 12 deletions(-) diff --git a/bun.lock b/bun.lock index 07ace0c..67b8f62 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "@types/bun": "^1.3.14", "@types/node": "~22.19.7", "@types/semver": "^7.7.1", + "@workos-inc/node": "10.8.0", "@workos/openapi-spec": "^0.80.0", "husky": "^9.1.7", "oxfmt": "^0.62.0", @@ -148,6 +149,8 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + "@workos-inc/node": ["@workos-inc/node@10.8.0", "", {}, "sha512-BmKwxaouV4hYhoJ9K0UPLC9LTpVfvnayvAy3lG9ELbZBoEWeaKkXoYn4W+d3vK7SZQ4qj/8PE7ve7sUKXnpRDA=="], + "@workos/openapi-spec": ["@workos/openapi-spec@0.80.0", "", {}, "sha512-jMAJVVvnWEdT3cq8G8H7U7gWJ3gIC19gRmkxB7DEA7YU2NXbhfGOt2Z4GDtspgljRt/GAp/pPT2+B5oaLIUyjw=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], diff --git a/package.json b/package.json index 3f53360..fdf0bf8 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@types/bun": "^1.3.14", "@types/node": "~22.19.7", "@types/semver": "^7.7.1", + "@workos-inc/node": "10.8.0", "@workos/openapi-spec": "^0.80.0", "husky": "^9.1.7", "oxfmt": "^0.62.0", diff --git a/src/core/middleware/error-handler.ts b/src/core/middleware/error-handler.ts index 8043026..de15222 100644 --- a/src/core/middleware/error-handler.ts +++ b/src/core/middleware/error-handler.ts @@ -6,7 +6,7 @@ export class WorkOSApiError extends Error { public status: number, message: string, public code: string, - public errors?: Array<{ field: string; code: string; message?: string }>, + public errors?: Array<{ field?: string; code: string; message?: string }>, ) { super(message); this.name = 'WorkOSApiError'; diff --git a/src/workos/routes/users.spec.ts b/src/workos/routes/users.spec.ts index 7997c6f..03293aa 100644 --- a/src/workos/routes/users.spec.ts +++ b/src/workos/routes/users.spec.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach } from 'bun:test'; +import { BadRequestException, WorkOS } from '@workos-inc/node'; import { createServer, type ApiKeyMap } from '../../core/index.js'; import { workosPlugin } from '../index.js'; @@ -83,8 +84,11 @@ describe('User routes', () => { method: 'POST', body: JSON.stringify({ email: 'user@x.test' }), }); - expect(second.status).toBe(409); - expect((await json(second)).code).toBe('user_already_exists'); + expect(second.status).toBe(400); + expect(await json(second)).toMatchObject({ + code: 'user_creation_error', + errors: [{ code: 'email_not_available' }], + }); }); // This is the lookup an SDK's listUsers({ email }) maps to, so it is how a caller finds the @@ -106,17 +110,70 @@ describe('User routes', () => { expect(miss.data).toHaveLength(0); }); - it('rejects duplicate email', async () => { - await req('/user_management/users', { - method: 'POST', - body: JSON.stringify({ email: 'dup@test.com' }), - }); + it('matches the user-creation contract for a duplicate email without changing the existing user', async () => { + const created = await json( + await req('/user_management/users', { + method: 'POST', + body: JSON.stringify({ + email: 'dup@test.com', + first_name: 'Original', + last_name: 'User', + password: 'pass123', + }), + }), + ); const res = await req('/user_management/users', { method: 'POST', - body: JSON.stringify({ email: 'dup@test.com' }), + body: JSON.stringify({ + email: 'dup@test.com', + first_name: 'Changed', + last_name: 'User', + email_verified: true, + external_id: 'dup@test.com', + }), + }); + expect(res.status).toBe(400); + expect(await json(res)).toEqual({ + message: 'Could not create user.', + code: 'user_creation_error', + errors: [{ code: 'email_not_available', message: 'This email is not available.' }], + }); + + const unchanged = await json(await req(`/user_management/users/${created.id}`)); + expect(unchanged).toMatchObject({ + id: created.id, + email: 'dup@test.com', + first_name: 'Original', + last_name: 'User', + email_verified: false, + external_id: null, + }); + }); + + it('is decoded as BadRequestException by @workos-inc/node', async () => { + const workos = new WorkOS({ + apiKey: 'sk_test_users', + apiHostname: 'emulate.test', + https: false, + maxRetries: 0, + fetchFn: (async () => + new Response( + JSON.stringify({ + message: 'Could not create user.', + code: 'user_creation_error', + errors: [{ code: 'email_not_available', message: 'This email is not available.' }], + }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + )) as unknown as typeof fetch, + }); + + await expect(workos.userManagement.createUser({ email: 'dup@test.com' })).rejects.toMatchObject({ + name: BadRequestException.name, + status: 400, + message: 'Could not create user.', + code: 'user_creation_error', + errors: [{ code: 'email_not_available', message: 'This email is not available.' }], }); - expect(res.status).toBe(409); - expect((await json(res)).code).toBe('user_already_exists'); }); it('gets user by id', async () => { diff --git a/src/workos/routes/users.ts b/src/workos/routes/users.ts index c707ce9..58f8cf6 100644 --- a/src/workos/routes/users.ts +++ b/src/workos/routes/users.ts @@ -38,7 +38,12 @@ export function userRoutes(ctx: RouteContext): void { // the ambiguity by insertion order. const existing = findUserByEmail(ws, email); if (existing) { - throw new WorkOSApiError(409, 'A user with this email already exists', 'user_already_exists'); + // Production treats a taken email on this creation endpoint as a request failure, not a + // generic conflict. Keep this specific to user creation: other 409 contracts remain + // independently meaningful to their SDK callers. + throw new WorkOSApiError(400, 'Could not create user.', 'user_creation_error', [ + { code: 'email_not_available', message: 'This email is not available.' }, + ]); } if (body.name !== undefined && body.name !== null && typeof body.name !== 'string') { From 65c68151402b5dca7b775bc4ceece969e5ce3250 Mon Sep 17 00:00:00 2001 From: Adaptura Date: Fri, 11 Sep 2026 00:37:54 +0200 Subject: [PATCH 2/5] test(users): exercise duplicate email contract through SDK --- src/workos/routes/users.spec.ts | 58 ++++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/src/workos/routes/users.spec.ts b/src/workos/routes/users.spec.ts index 03293aa..1030001 100644 --- a/src/workos/routes/users.spec.ts +++ b/src/workos/routes/users.spec.ts @@ -151,28 +151,56 @@ describe('User routes', () => { }); it('is decoded as BadRequestException by @workos-inc/node', async () => { + const fetchFn: typeof fetch = Object.assign( + async (...args: Parameters) => { + const [input, init] = args; + const request = input instanceof Request ? new Request(input, init) : new Request(input.toString(), init); + return await app.request(request); + }, + { preconnect: fetch.preconnect }, + ); const workos = new WorkOS({ apiKey: 'sk_test_users', apiHostname: 'emulate.test', https: false, maxRetries: 0, - fetchFn: (async () => - new Response( - JSON.stringify({ - message: 'Could not create user.', - code: 'user_creation_error', - errors: [{ code: 'email_not_available', message: 'This email is not available.' }], - }), - { status: 400, headers: { 'Content-Type': 'application/json' } }, - )) as unknown as typeof fetch, + fetchFn, }); - await expect(workos.userManagement.createUser({ email: 'dup@test.com' })).rejects.toMatchObject({ - name: BadRequestException.name, - status: 400, - message: 'Could not create user.', - code: 'user_creation_error', - errors: [{ code: 'email_not_available', message: 'This email is not available.' }], + const created = await workos.userManagement.createUser({ + email: 'sdk-dup@test.com', + firstName: 'Original', + lastName: 'User', + password: 'pass123', + }); + + try { + await workos.userManagement.createUser({ + email: 'sdk-dup@test.com', + firstName: 'Changed', + lastName: 'User', + emailVerified: true, + externalId: 'sdk-dup@test.com', + }); + throw new Error('Expected duplicate user creation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(BadRequestException); + expect(error).toMatchObject({ + status: 400, + message: 'Could not create user.', + code: 'user_creation_error', + errors: [{ code: 'email_not_available', message: 'This email is not available.' }], + }); + } + + const unchanged = await workos.userManagement.getUser(created.id); + expect(unchanged).toMatchObject({ + id: created.id, + email: 'sdk-dup@test.com', + firstName: 'Original', + lastName: 'User', + emailVerified: false, + externalId: null, }); }); From ed1d66214b0f2b12fba4f0c11a2a1381313d0045 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 15 Sep 2026 09:58:07 -0400 Subject: [PATCH 3/5] chore(deps): range-pin @workos-inc/node Every other dependency uses a range; the exact pin was the one outlier and carried no note explaining why. The resolved version is unchanged. --- bun.lock | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index 67b8f62..f72f8e6 100644 --- a/bun.lock +++ b/bun.lock @@ -15,7 +15,7 @@ "@types/bun": "^1.3.14", "@types/node": "~22.19.7", "@types/semver": "^7.7.1", - "@workos-inc/node": "10.8.0", + "@workos-inc/node": "^10.8.0", "@workos/openapi-spec": "^0.80.0", "husky": "^9.1.7", "oxfmt": "^0.62.0", diff --git a/package.json b/package.json index fdf0bf8..3a92d84 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "@types/bun": "^1.3.14", "@types/node": "~22.19.7", "@types/semver": "^7.7.1", - "@workos-inc/node": "10.8.0", + "@workos-inc/node": "^10.8.0", "@workos/openapi-spec": "^0.80.0", "husky": "^9.1.7", "oxfmt": "^0.62.0", From 464d9ce14527b29600e34d1f6e275b8e94bcc9b1 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 15 Sep 2026 09:58:07 -0400 Subject: [PATCH 4/5] docs(users): cite the spec for the duplicate-email 400 Point at UserlandUsersController_create in @workos/openapi-spec, which documents the 400 user_creation_error envelope and no 409 for this endpoint, so the contract can be verified without a live account. Note which part is spec (the envelope) and which is observed (the email_not_available detail). --- src/workos/routes/users.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/workos/routes/users.ts b/src/workos/routes/users.ts index 58f8cf6..f5cc6e0 100644 --- a/src/workos/routes/users.ts +++ b/src/workos/routes/users.ts @@ -38,9 +38,10 @@ export function userRoutes(ctx: RouteContext): void { // the ambiguity by insertion order. const existing = findUserByEmail(ws, email); if (existing) { - // Production treats a taken email on this creation endpoint as a request failure, not a - // generic conflict. Keep this specific to user creation: other 409 contracts remain - // independently meaningful to their SDK callers. + // The spec (UserlandUsersController_create in @workos/openapi-spec) documents no 409 for this + // endpoint: a taken email is a 400 `user_creation_error` carrying the reason in `errors`, and + // `email_not_available` is the detail production sends. Keep this specific to user creation; + // other 409 contracts remain independently meaningful to their SDK callers. throw new WorkOSApiError(400, 'Could not create user.', 'user_creation_error', [ { code: 'email_not_available', message: 'This email is not available.' }, ]); From 602f54afa437709e12aaf1b7b1b7100eefb32c81 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 15 Sep 2026 09:58:08 -0400 Subject: [PATCH 5/5] test(users): hoist the SDK client shim into sdk.test-utils The WorkOS client factory and fetchFn shim are reusable by any spec that wants the real SDK's request/exception mapping in the loop, so they move to a shared *.test-utils.ts (build-excluded, test-typechecked). Hono's app.request already normalizes Request-vs-string input, so the shim is one call. The SDK test now asserts only what is unique to it, the exception class and status; the response body and unchanged-user checks already live in the HTTP contract test. --- src/workos/routes/users.spec.ts | 60 +++++---------------------------- src/workos/sdk.test-utils.ts | 14 ++++++++ 2 files changed, 22 insertions(+), 52 deletions(-) create mode 100644 src/workos/sdk.test-utils.ts diff --git a/src/workos/routes/users.spec.ts b/src/workos/routes/users.spec.ts index 1030001..afbdd32 100644 --- a/src/workos/routes/users.spec.ts +++ b/src/workos/routes/users.spec.ts @@ -1,7 +1,8 @@ import { describe, it, expect, beforeEach } from 'bun:test'; -import { BadRequestException, WorkOS } from '@workos-inc/node'; +import { BadRequestException } from '@workos-inc/node'; import { createServer, type ApiKeyMap } from '../../core/index.js'; import { workosPlugin } from '../index.js'; +import { sdkClient } from '../sdk.test-utils.js'; const apiKeys: ApiKeyMap = { sk_test_users: { environment: 'test' } }; const headers = { Authorization: 'Bearer sk_test_users', 'Content-Type': 'application/json' }; @@ -150,58 +151,13 @@ describe('User routes', () => { }); }); + // The body is asserted above; this checks only what the SDK makes of it. it('is decoded as BadRequestException by @workos-inc/node', async () => { - const fetchFn: typeof fetch = Object.assign( - async (...args: Parameters) => { - const [input, init] = args; - const request = input instanceof Request ? new Request(input, init) : new Request(input.toString(), init); - return await app.request(request); - }, - { preconnect: fetch.preconnect }, - ); - const workos = new WorkOS({ - apiKey: 'sk_test_users', - apiHostname: 'emulate.test', - https: false, - maxRetries: 0, - fetchFn, - }); - - const created = await workos.userManagement.createUser({ - email: 'sdk-dup@test.com', - firstName: 'Original', - lastName: 'User', - password: 'pass123', - }); - - try { - await workos.userManagement.createUser({ - email: 'sdk-dup@test.com', - firstName: 'Changed', - lastName: 'User', - emailVerified: true, - externalId: 'sdk-dup@test.com', - }); - throw new Error('Expected duplicate user creation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(BadRequestException); - expect(error).toMatchObject({ - status: 400, - message: 'Could not create user.', - code: 'user_creation_error', - errors: [{ code: 'email_not_available', message: 'This email is not available.' }], - }); - } - - const unchanged = await workos.userManagement.getUser(created.id); - expect(unchanged).toMatchObject({ - id: created.id, - email: 'sdk-dup@test.com', - firstName: 'Original', - lastName: 'User', - emailVerified: false, - externalId: null, - }); + const workos = sdkClient(app, 'sk_test_users'); + await workos.userManagement.createUser({ email: 'sdk-dup@test.com' }); + const dup = workos.userManagement.createUser({ email: 'sdk-dup@test.com' }); + await expect(dup).rejects.toBeInstanceOf(BadRequestException); + await expect(dup).rejects.toMatchObject({ status: 400 }); }); it('gets user by id', async () => { diff --git a/src/workos/sdk.test-utils.ts b/src/workos/sdk.test-utils.ts new file mode 100644 index 0000000..d55b1de --- /dev/null +++ b/src/workos/sdk.test-utils.ts @@ -0,0 +1,14 @@ +import { WorkOS } from '@workos-inc/node'; +import type { createServer } from '../core/index.js'; + +// An @workos-inc/node client whose requests are served in-process by `app` — no port, no +// listener — so a test can assert what the real SDK makes of an emulator response (which +// exception class, which fields). Bun's `typeof fetch` carries `preconnect`, hence the +// Object.assign rather than a bare arrow. +export function sdkClient(app: ReturnType['app'], apiKey: string): WorkOS { + const fetchFn: typeof fetch = Object.assign( + async (input: Request | string | URL, init?: RequestInit) => app.request(input, init), + { preconnect: fetch.preconnect }, + ); + return new WorkOS({ apiKey, apiHostname: 'emulate.test', https: false, maxRetries: 0, fetchFn }); +}