Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
36 changes: 28 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
```
Expand All @@ -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);
```
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ export class HttpClient {
}

async request<T>(path: string, options: RequestOptions = {}): Promise<T> {
return (await this.requestWithStatus<T>(path, options)).data;
}

/** Request parsed data together with its HTTP status, using the same error handling. */
async requestWithStatus<T>(path: string, options: RequestOptions = {}): Promise<{ status: number; data: T }> {
const url = this.buildUrl(path, options.params);
const headers: Record<string, string> = {
'X-User': this.username,
Expand All @@ -57,7 +62,7 @@ export class HttpClient {
body,
});

return this.handleResponse<T>(response);
return { status: response.status, data: await this.handleResponse<T>(response) };
}

private buildUrl(path: string, params?: QueryParams): string {
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export type {
Organization,
Domain,
ProofpointUser,
ProofpointUserInput,
DomainInput,
EndpointDiscovery,
Features,
Licensing,
Expand Down
21 changes: 21 additions & 0 deletions src/resources/batch.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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[];
}
25 changes: 16 additions & 9 deletions src/resources/domains.ts
Original file line number Diff line number Diff line change
@@ -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`.
Expand All @@ -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<BatchResult> {
return this.httpClient.request<BatchResult>(`/orgs/${encodeURIComponent(orgDomain)}/domains`, {
async create(orgDomain: string, domains: Array<string | DomainInput>): Promise<BatchItemResult[]> {
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<unknown>(`/orgs/${encodeURIComponent(orgDomain)}/domains`, {
method: 'POST',
body: { domains },
body: items,
});
return normalizeCreateResponse(response.status, response.data);
}

/** PUT /orgs/{orgDomain}/domains/{domain} */
Expand Down
29 changes: 20 additions & 9 deletions src/resources/users.ts
Original file line number Diff line number Diff line change
@@ -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`.
Expand All @@ -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<string, unknown>[]): Promise<BatchResult> {
return this.httpClient.request<BatchResult>(`/orgs/${encodeURIComponent(orgDomain)}/users`, {
async create(orgDomain: string, users: ProofpointUserInput | ProofpointUserInput[]): Promise<BatchItemResult[]> {
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<unknown>(`/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} */
Expand Down
39 changes: 35 additions & 4 deletions src/types/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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
Expand All @@ -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;
Expand Down Expand Up @@ -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 };
103 changes: 103 additions & 0 deletions tests/create.test.ts
Original file line number Diff line number Diff line change
@@ -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<string | DomainInput>);
await expect(promise).rejects.toBeInstanceOf(ValidationError);
await expect(promise).rejects.toThrow(/domains\[1\].name/);
expect(requests).toBe(0);
});
2 changes: 1 addition & 1 deletion tests/domains.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading
Loading