diff --git a/CHANGELOG.md b/CHANGELOG.md index 03499f1..806c5b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Preserve explicit per-item outcomes in 201 create responses and strip response-only fields and update-only passwords from user create requests. + ### Changed - **Release workflow no longer persists a write-scoped git credential across `npm ci`.** The release job declares `contents: write`, which overrides this repo's read-only default workflow permission, so `actions/checkout`'s default persisted credential was write-scoped and lived in `.git/config` through dependency install, build and test — readable by any compromised dependency lifecycle script. `persist-credentials: false` is semantic-release's own documented GitHub Actions recipe; it authenticates its pushes from `GITHUB_TOKEN` directly and never needed the persisted credential. (CWE-250) diff --git a/README.md b/README.md index 336c775..789019d 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,9 @@ await client.orgs.delete(domain); ```ts await client.domains.list(orgDomain); -await client.domains.create(orgDomain, ['new.example.com']); // batch; see 207 handling below +await client.domains.create(orgDomain, ['new.example.com', { name: 'relay.example.com', is_relay: true }]); +// POST body: [{"name":"new.example.com"},{"name":"relay.example.com","is_relay":true}] +// Returns a normalized array; see 207 handling below. await client.domains.update(orgDomain, domain, data); await client.domains.delete(orgDomain, domain); ``` @@ -104,7 +106,10 @@ await client.domains.delete(orgDomain, domain); ```ts await client.users.list(orgDomain); await client.users.get(orgDomain, email); // GET ?email= filter -await client.users.create(orgDomain, [{ email: 'new@example.com' }]); // batch; see 207 handling below +await client.users.create(orgDomain, { primary_email: 'new@example.com', firstname: 'New', surname: 'User' }); +// Single POST body: {"primary_email":"new@example.com","firstname":"New","surname":"User"} +await client.users.create(orgDomain, [{ primary_email: 'batch@example.com', firstname: 'Batch', surname: 'User' }]); +// Batch POST body: [{"primary_email":"batch@example.com","firstname":"Batch","surname":"User"}] await client.users.update(orgDomain, email, data); await client.users.delete(orgDomain, email); ``` @@ -149,14 +154,29 @@ await client.token.create(); // mints an Odin-based SSO token ## Batch endpoints and 207 Multi-Status -`domains.create` and `users.create` accept arrays and hit Proofpoint's batch-create endpoints. -A `207 Multi-Status` response means some items succeeded and others failed. This client does -**not** throw on 207 -- it returns the raw parsed response body so you can inspect per-item -results yourself: +`users.create` accepts one User object or an array; `domains.create` accepts an array of +strings and/or Domain objects and converts strings to `{ name }` objects. Bodies are sent +bare, without `users` or `domains` wrappers (OpenAPI operations `postUser` / `postDomain`). +Users require a non-empty `primary_email`; `email` is not the Proofpoint field name. +`users.create` drops response-only fields (`uid`, `creation_date`, `last_update`, `last_login`) +and `password` (update-only) from the POST body without modifying the input. +Domain names must be non-empty. Invalid items raise `ValidationError` before any request. + +Both methods return `BatchItemResult[]`. A 201 created object becomes +a one-item array, preserving its fields and any explicit boolean `success` or numeric `status`. +These fields are only synthesized when absent or invalid: `status` defaults to 201; +`success` is inferred from a numeric `status` (or `code`) being 2xx, and defaults to true +when neither is numeric. +A 207 Multi-Status never throws: an array is returned directly, a `{ results: [...] }` +envelope is unwrapped, and any other body becomes a one-item array. Missing `success` +is set to true only for items with a numeric 2xx `status` or `code`; otherwise it stays absent. ```ts -const result = await client.domains.create('example.com', ['good.example.com', 'bad domain']); -// result is the raw batch response -- inspect each item for its own success/failure +const results = await client.users.create('example.com', [ + { primary_email: 'alice@example.com', firstname: 'Alice', surname: 'Smith' }, + { primary_email: 'bob@example.com', firstname: 'Bob', surname: 'Jones' }, +]); +for (const item of results) console.log(item.status, item.success); ``` ## Error handling diff --git a/src/http.ts b/src/http.ts index 06f4da9..0f18fb1 100644 --- a/src/http.ts +++ b/src/http.ts @@ -37,6 +37,11 @@ export class HttpClient { } async request(path: string, options: RequestOptions = {}): Promise { + return (await this.requestWithStatus(path, options)).data; + } + + /** Request parsed data together with its HTTP status, using the same error handling. */ + async requestWithStatus(path: string, options: RequestOptions = {}): Promise<{ status: number; data: T }> { const url = this.buildUrl(path, options.params); const headers: Record = { 'X-User': this.username, @@ -57,7 +62,7 @@ export class HttpClient { body, }); - return this.handleResponse(response); + return { status: response.status, data: await this.handleResponse(response) }; } private buildUrl(path: string, params?: QueryParams): string { diff --git a/src/index.ts b/src/index.ts index 0a4ea54..3fa4388 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,8 @@ export type { Organization, Domain, ProofpointUser, + ProofpointUserInput, + DomainInput, EndpointDiscovery, Features, Licensing, diff --git a/src/resources/batch.ts b/src/resources/batch.ts new file mode 100644 index 0000000..579bb11 --- /dev/null +++ b/src/resources/batch.ts @@ -0,0 +1,21 @@ +import type { BatchItemResult } from '../types/index.js'; + +/** Preserve raw per-item fields and only infer success from explicit 2xx statuses. */ +export function normalizeCreateResponse(status: number, data: unknown): BatchItemResult[] { + if (status === 201 && data && typeof data === 'object' && !Array.isArray(data)) { + const item = data as Record; + const code = typeof item.status === 'number' ? item.status : item.code; + const success = typeof item.success === 'boolean' ? item.success + : typeof code === 'number' ? code >= 200 && code < 300 : true; + return [{ ...item, success, status: typeof item.status === 'number' ? item.status : 201 }]; + } + const items = Array.isArray(data) ? data + : data && typeof data === 'object' && 'results' in data && Array.isArray(data.results) + ? data.results : [data]; + for (const item of items) { + if (!item || typeof item !== 'object' || item.success !== undefined) continue; + const code = [item.status, item.code].find((value) => typeof value === 'number' && value >= 200 && value < 300); + if (code !== undefined) item.success = true; + } + return items as BatchItemResult[]; +} diff --git a/src/resources/domains.ts b/src/resources/domains.ts index ab0fc6d..8e0b640 100644 --- a/src/resources/domains.ts +++ b/src/resources/domains.ts @@ -1,5 +1,7 @@ +import { ValidationError } from '../errors.js'; +import { normalizeCreateResponse } from './batch.js'; import type { HttpClient } from '../http.js'; -import type { BatchResult, Domain } from '../types/index.js'; +import type { BatchItemResult, DomainInput, Domain } from '../types/index.js'; /** * Domains resource -- `/orgs/{orgDomain}/domains`. @@ -13,17 +15,22 @@ export class DomainsResource { } /** - * POST /orgs/{orgDomain}/domains -- batch-create domains. - * - * A 207 Multi-Status response means partial success/failure; this method - * never throws on 207, it returns the raw parsed body so callers can - * inspect per-item results themselves. + * POST /orgs/{orgDomain}/domains (postDomain in Proofpoint's OpenAPI). + * Sends a bare array of Domain objects. Returns normalized item results; never throws on 207. */ - async create(orgDomain: string, domains: string[]): Promise { - return this.httpClient.request(`/orgs/${encodeURIComponent(orgDomain)}/domains`, { + async create(orgDomain: string, domains: Array): Promise { + const items = domains.map((domain) => typeof domain === 'string' ? { name: domain } : domain); + for (const [index, item] of items.entries()) { + if (!item || typeof item !== 'object' || Array.isArray(item) || typeof item.name !== 'string' || !item.name.trim()) { + const message = `domains[${index}].name must be a non-empty string`; + throw new ValidationError(message, [{ field: `domains[${index}].name`, message }], item); + } + } + const response = await this.httpClient.requestWithStatus(`/orgs/${encodeURIComponent(orgDomain)}/domains`, { method: 'POST', - body: { domains }, + body: items, }); + return normalizeCreateResponse(response.status, response.data); } /** PUT /orgs/{orgDomain}/domains/{domain} */ diff --git a/src/resources/users.ts b/src/resources/users.ts index c4954d7..dee9e3c 100644 --- a/src/resources/users.ts +++ b/src/resources/users.ts @@ -1,5 +1,7 @@ +import { ValidationError } from '../errors.js'; +import { normalizeCreateResponse } from './batch.js'; import type { HttpClient } from '../http.js'; -import type { BatchResult, ProofpointUser } from '../types/index.js'; +import type { BatchItemResult, ProofpointUserInput, ProofpointUser } from '../types/index.js'; /** * Users resource -- `/orgs/{orgDomain}/users`. @@ -26,17 +28,26 @@ export class UsersResource { } /** - * POST /orgs/{orgDomain}/users -- batch-create users. - * - * A 207 Multi-Status response means partial success/failure; this method - * never throws on 207, it returns the raw parsed body so callers can - * inspect per-item results themselves. + * POST /orgs/{orgDomain}/users (postUser in Proofpoint's OpenAPI). + * Sends a bare User object or array of User objects. Returns normalized item results; never throws on 207. */ - async create(orgDomain: string, users: Record[]): Promise { - return this.httpClient.request(`/orgs/${encodeURIComponent(orgDomain)}/users`, { + async create(orgDomain: string, users: ProofpointUserInput | ProofpointUserInput[]): Promise { + const items = Array.isArray(users) ? users : [users]; + for (const [index, item] of items.entries()) { + if (!item || typeof item !== 'object' || Array.isArray(item) || typeof item.primary_email !== 'string' || !item.primary_email.trim()) { + const message = `users[${index}].primary_email must be a non-empty string${item && typeof item === 'object' && 'email' in item && !('primary_email' in item) ? '; email is not the Proofpoint field name; use primary_email' : ''}`; + throw new ValidationError(message, [{ field: `users[${index}].primary_email`, message }], item); + } + } + const sanitized = items.map((item) => { + const { uid, creation_date, last_update, last_login, password, ...input } = item; + return input; + }); + const response = await this.httpClient.requestWithStatus(`/orgs/${encodeURIComponent(orgDomain)}/users`, { method: 'POST', - body: { users }, + body: Array.isArray(users) ? sanitized : sanitized[0], }); + return normalizeCreateResponse(response.status, response.data); } /** PUT /orgs/{orgDomain}/users/{email} */ diff --git a/src/types/index.ts b/src/types/index.ts index 477f374..59e78b1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,7 +1,36 @@ +/** Create input fields documented by Proofpoint's User schema. */ +export interface ProofpointUserInput { + primary_email: string; + is_active?: boolean; + type?: string; + send_welcome_email?: boolean; + firstname?: string; + surname?: string; + alias_emails?: string[]; + safe_list_senders?: string[]; + block_list_senders?: string[]; + white_list_senders?: string[]; + black_list_senders?: string[]; + is_billable?: boolean; + odin_settings?: Record; + read_only_user?: boolean; + [key: string]: unknown; +} + +/** Input fields documented by Proofpoint's Domain schema. */ +export interface DomainInput { + name: string; + is_active?: boolean; + is_relay?: boolean; + destination?: string; + failovers?: string[]; + [key: string]: unknown; +} + /** * Domain types for the Proofpoint Essentials API. * - * Proofpoint's public API docs do not publish a formal JSON schema, so these + * Based on Proofpoint's OpenAPI schemas and observed responses, these * interfaces model the documented/observed shape of each resource while * remaining permissive (index signatures) for fields the docs don't * enumerate. Callers that need a specific field not modeled here can still @@ -27,8 +56,12 @@ export interface Domain { export interface ProofpointUser { id?: number | string; + primary_email?: string; + surname?: string; + /** @deprecated Not a Proofpoint field name; use primary_email. */ email?: string; firstname?: string; + /** @deprecated Not a Proofpoint field name; use surname. */ lastname?: string; is_active?: boolean; [key: string]: unknown; @@ -82,8 +115,6 @@ export interface BatchItemResult { } /** - * Raw batch-create response. Batch create methods return this verbatim - * (never throwing on 207) so callers can inspect partial failures - * themselves. + * Legacy batch response union. Create methods return normalized BatchItemResult arrays. */ export type BatchResult = BatchItemResult[] | { results?: BatchItemResult[]; [key: string]: unknown }; diff --git a/tests/create.test.ts b/tests/create.test.ts new file mode 100644 index 0000000..f24cba3 --- /dev/null +++ b/tests/create.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { ProofpointEssentialsClient, ValidationError, type ProofpointUserInput, type DomainInput } from '../src/index.js'; +import { server } from './mocks/server.js'; +import { BASE_URL } from './mocks/handlers.js'; + +const client = new ProofpointEssentialsClient({ username: 'admin@example.com', password: 'secret' }); + +for (const resource of ['users', 'domains'] as const) { + describe(`${resource} create wire contract`, () => { + const input = resource === 'users' ? { primary_email: 'alice@example.com', firstname: 'Alice', surname: 'Smith' } : { name: 'a.com', is_relay: true }; + const create = () => resource === 'users' + ? client.users.create('example.com', input as ProofpointUserInput) + : client.domains.create('example.com', [input as DomainInput]); + + it('sends bare data and normalizes a 201 object, preserving fields', async () => { + let captured: unknown; + server.use(http.post(`${BASE_URL}/orgs/example.com/${resource}`, async ({ request }) => { + captured = await request.json(); + if (captured && typeof captured === 'object' && resource in captured) { + return HttpResponse.json({ message: 'Wrapped body is invalid' }, { status: 400 }); + } + return HttpResponse.json({ ...input, uid: 123 }, { status: 201 }); + })); + expect(await create()).toEqual([{ ...input, uid: 123, success: true, status: 201 }]); + expect(captured).toEqual(resource === 'users' ? input : [input]); + const rejected = await fetch(`${BASE_URL}/orgs/example.com/${resource}`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ [resource]: [input] }), + }); + expect(rejected.status).toBe(400); + expect(await rejected.json()).toEqual({ message: 'Wrapped body is invalid' }); + }); + + it.each([ + [{ success: false, status: 422 }, { success: false, status: 422 }], + [{ code: 409 }, { code: 409, success: false, status: 201 }], + [{ status: 204 }, { status: 204, success: true }], + [{ code: 201 }, { code: 201, success: true, status: 201 }], + [{ success: true, status: 422 }, { success: true, status: 422 }], + [{ success: 'invalid', status: 'invalid' }, { success: true, status: 201 }], + ])('preserves or infers the outcome of a 201 object: %j', async (body, expected) => { + server.use(http.post(`${BASE_URL}/orgs/example.com/${resource}`, () => HttpResponse.json(body, { status: 201 }))); + expect(await create()).toEqual([expected]); + }); + + it.each(['array', 'results', 'object'] as const)('normalizes 207 %s without inventing success', async (shape) => { + const items = [{ status: 201, extra: 'kept' }, { code: 204 }, { status: 422 }, { message: 'unknown' }, { success: false, status: 201 }]; + const body = shape === 'array' ? items : shape === 'results' ? { results: items } : items[3]; + server.use(http.post(`${BASE_URL}/orgs/example.com/${resource}`, () => HttpResponse.json(body, { status: 207 }))); + expect(await create()).toEqual(shape === 'object' ? [items[3]] : [ + { ...items[0], success: true }, { ...items[1], success: true }, ...items.slice(2), + ]); + }); + + it('rejects wrapped bodies in the default mock', async () => { + const response = await fetch(`${BASE_URL}/orgs/example.com/${resource}`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ [resource]: [input] }), + }); + expect(response.status).toBe(400); + }); + }); +} + +it('sends a bare users array and passes through explicit 207 results', async () => { + const users = [{ primary_email: 'a@example.com' }]; + const results = [{ status: 201, success: true }, { status: 422, success: false }]; + let captured: unknown; + server.use(http.post(`${BASE_URL}/orgs/example.com/users`, async ({ request }) => { + captured = await request.json(); + return HttpResponse.json(results, { status: 207 }); + })); + expect(await client.users.create('example.com', users)).toEqual(results); + expect(captured).toEqual(users); +}); + +it('converts domain strings and preserves Domain object options', async () => { + let captured: unknown; + server.use(http.post(`${BASE_URL}/orgs/example.com/domains`, async ({ request }) => { + captured = await request.json(); + return HttpResponse.json([], { status: 207 }); + })); + await client.domains.create('example.com', ['a.com', { name: 'b.com', destination: '10.0.0.1', failovers: ['10.0.0.2'] }]); + expect(captured).toEqual([{ name: 'a.com' }, { name: 'b.com', destination: '10.0.0.1', failovers: ['10.0.0.2'] }]); +}); + +it.each([{}, { email: 'a@example.com' }, { primary_email: ' ' }, null, [], 'a@example.com'])('validates user input before sending: %j', async (bad) => { + let requests = 0; + server.use(http.post(`${BASE_URL}/orgs/example.com/users`, () => { requests++; return HttpResponse.json({}); })); + const promise = client.users.create('example.com', [{ primary_email: 'valid@example.com' }, bad] as ProofpointUserInput[]); + await expect(promise).rejects.toBeInstanceOf(ValidationError); + await expect(promise).rejects.toThrow(/users\[1\].primary_email/); + if (bad && typeof bad === 'object' && 'email' in bad) await expect(promise).rejects.toThrow(/email is not the Proofpoint field name/); + expect(requests).toBe(0); +}); + +it.each(['', ' ', { name: '' }, {}, null])('validates domain name before sending: %j', async (bad) => { + let requests = 0; + server.use(http.post(`${BASE_URL}/orgs/example.com/domains`, () => { requests++; return HttpResponse.json({}); })); + const promise = client.domains.create('example.com', ['valid.com', bad] as Array); + await expect(promise).rejects.toBeInstanceOf(ValidationError); + await expect(promise).rejects.toThrow(/domains\[1\].name/); + expect(requests).toBe(0); +}); diff --git a/tests/domains.test.ts b/tests/domains.test.ts index a55bd7e..3ce7be0 100644 --- a/tests/domains.test.ts +++ b/tests/domains.test.ts @@ -20,7 +20,7 @@ describe('DomainsResource', () => { it('creates domains in a batch (all succeed, 201)', async () => { const client = makeClient(); const result = await client.domains.create('example.com', ['new.example.com']); - expect(result).toEqual(fixtures.domains.domainsCreateSuccess); + expect(result).toEqual([{ name: 'new.example.com', success: true, status: 201 }]); }); it('does NOT throw on 207 Multi-Status and returns per-item results', async () => { diff --git a/tests/fixtures/users.ts b/tests/fixtures/users.ts index 4ef5f45..66a2bbd 100644 --- a/tests/fixtures/users.ts +++ b/tests/fixtures/users.ts @@ -1,25 +1,25 @@ export const usersList = [ - { id: 1, email: 'alice@example.com', firstname: 'Alice', lastname: 'Anderson', is_active: true }, - { id: 2, email: 'bob@example.com', firstname: 'Bob', lastname: 'Baker', is_active: true }, + { id: 1, primary_email: 'alice@example.com', firstname: 'Alice', surname: 'Anderson', is_active: true }, + { id: 2, primary_email: 'bob@example.com', firstname: 'Bob', surname: 'Baker', is_active: true }, ]; export const userSingle = { id: 1, - email: 'alice@example.com', + primary_email: 'alice@example.com', firstname: 'Alice', - lastname: 'Anderson', + surname: 'Anderson', is_active: true, }; export const usersCreatePartial = [ - { success: true, status: 201, email: 'carol@example.com' }, - { success: false, status: 422, email: 'not-an-email', message: 'Invalid email address' }, + { success: true, status: 201, primary_email: 'carol@example.com' }, + { success: false, status: 422, primary_email: 'not-an-email', message: 'Invalid email address' }, ]; export const userUpdated = { id: 1, - email: 'alice@example.com', + primary_email: 'alice@example.com', firstname: 'Alice', - lastname: 'Anderson-Smith', + surname: 'Anderson-Smith', is_active: true, }; diff --git a/tests/mocks/handlers.ts b/tests/mocks/handlers.ts index f5e0b10..75e2c5c 100644 --- a/tests/mocks/handlers.ts +++ b/tests/mocks/handlers.ts @@ -16,9 +16,13 @@ export const handlers = [ // Domains http.get(`${BASE_URL}/orgs/example.com/domains`, () => HttpResponse.json(fixtures.domains.domainsList)), - http.post(`${BASE_URL}/orgs/example.com/domains`, () => - HttpResponse.json(fixtures.domains.domainsCreateSuccess, { status: 201 }) - ), + http.post(`${BASE_URL}/orgs/example.com/domains`, async ({ request }) => { + const body = await request.json(); + if (body && typeof body === 'object' && 'domains' in body) { + return HttpResponse.json({ message: 'Wrapped domains body is invalid' }, { status: 400 }); + } + return HttpResponse.json({ name: 'new.example.com' }, { status: 201 }); + }), http.put(`${BASE_URL}/orgs/example.com/domains/example.com`, () => new HttpResponse(null, { status: 204 })), http.delete(`${BASE_URL}/orgs/example.com/domains/example.com`, () => new HttpResponse(null, { status: 204 })), @@ -31,9 +35,13 @@ export const handlers = [ } return HttpResponse.json(fixtures.users.usersList); }), - http.post(`${BASE_URL}/orgs/example.com/users`, () => - HttpResponse.json(fixtures.users.usersCreatePartial, { status: 207 }) - ), + http.post(`${BASE_URL}/orgs/example.com/users`, async ({ request }) => { + const body = await request.json(); + if (body && typeof body === 'object' && 'users' in body) { + return HttpResponse.json({ message: 'Wrapped users body is invalid' }, { status: 400 }); + } + return HttpResponse.json(fixtures.users.usersCreatePartial, { status: 207 }); + }), http.put(`${BASE_URL}/orgs/example.com/users/alice%40example.com`, () => new HttpResponse(null, { status: 204 })), http.delete(`${BASE_URL}/orgs/example.com/users/alice%40example.com`, () => new HttpResponse(null, { status: 204 })), diff --git a/tests/users.test.ts b/tests/users.test.ts index 52182bf..eabdbcf 100644 --- a/tests/users.test.ts +++ b/tests/users.test.ts @@ -35,15 +35,37 @@ describe('UsersResource', () => { it('creates users in a batch and returns 207 partial results without throwing', async () => { const client = makeClient(); const result = await client.users.create('example.com', [ - { email: 'carol@example.com' }, - { email: 'not-an-email' }, + { primary_email: 'carol@example.com' }, + { primary_email: 'not-an-email' }, ]); expect(result).toEqual(fixtures.users.usersCreatePartial); }); + it.each(['single', 'array'] as const)('strips non-create fields from a %s input without mutation', async (shape) => { + const user = Object.freeze({ + primary_email: 'alice@example.com', firstname: 'Alice', custom_field: 'kept', + uid: 123, creation_date: '2026-01-01', last_update: '2026-02-01', + last_login: '2026-03-01', password: 'test-only', + }); + const original = { ...user }; + const input = shape === 'single' ? user : [user, { ...user, primary_email: 'bob@example.com' }]; + const snapshot = structuredClone(input); + let captured: unknown; + server.use(http.post(`${BASE_URL}/orgs/example.com/users`, async ({ request }) => { + captured = await request.json(); + return HttpResponse.json([], { status: 207 }); + })); + + await makeClient().users.create('example.com', input); + const expected = { primary_email: 'alice@example.com', firstname: 'Alice', custom_field: 'kept' }; + expect(captured).toEqual(shape === 'single' ? expected : [expected, { ...expected, primary_email: 'bob@example.com' }]); + expect(input).toEqual(snapshot); + expect(user).toEqual(original); + }); + it('updates a user', async () => { const client = makeClient(); - const result = await client.users.update('example.com', 'alice@example.com', { lastname: 'Anderson-Smith' }); + const result = await client.users.update('example.com', 'alice@example.com', { surname: 'Anderson-Smith' }); expect(result).toEqual({}); });