Skip to content
Open
235 changes: 235 additions & 0 deletions src/audit-logs/audit-logs.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import fetch from 'jest-fetch-mock';
import { UnauthorizedException } from '../common/exceptions';
import { BadRequestException } from '../common/exceptions/bad-request.exception';
import { ListResponse } from '../common/interfaces';
import { mockWorkOsResponse } from '../common/utils/workos-mock-response';
import { WorkOS } from '../workos';
import {
AuditLogExport,
AuditLogExportOptions,
AuditLogExportResponse,
AuditLogSchema,
AuditLogSchemaResponse,
CreateAuditLogEventOptions,
CreateAuditLogSchemaOptions,
CreateAuditLogSchemaResponse,
Expand Down Expand Up @@ -844,4 +846,237 @@ describe('AuditLogs', () => {
});
});
});

describe('listSchemas', () => {
describe('when the api responds with a 200', () => {
it('returns a paginated list of schemas', async () => {
const workosSpy = jest.spyOn(WorkOS.prototype, 'get');

const time = new Date().toISOString();

const schemaResponse: AuditLogSchemaResponse = {
object: 'audit_log_schema',
version: 1,
targets: [
{
type: 'user',
metadata: {
type: 'object',
properties: {
user_id: { type: 'string' },
},
},
},
],
actor: {
metadata: {
type: 'object',
properties: {
actor_id: { type: 'string' },
},
},
},
metadata: {
type: 'object',
properties: {
foo: { type: 'number' },
},
},
created_at: time,
};

const listResponse: ListResponse<AuditLogSchemaResponse> = {
object: 'list',
data: [schemaResponse],
list_metadata: {
before: undefined,
after: undefined,
},
};

workosSpy.mockResolvedValueOnce(mockWorkOsResponse(200, listResponse));

const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU');

const result = await workos.auditLogs.listSchemas({
action: 'user.logged_in',
});

expect(result.data).toHaveLength(1);
// Metadata is deserialized to simplified format (same as createSchema)
expect(result.data[0]).toEqual({
object: 'audit_log_schema',
version: 1,
targets: [
{
type: 'user',
metadata: { user_id: 'string' },
},
],
actor: {
metadata: { actor_id: 'string' },
},
metadata: { foo: 'number' },
createdAt: time,
});

expect(workosSpy).toHaveBeenCalledWith(
'/audit_logs/actions/user.logged_in/schemas',
{ query: { order: 'desc' } },
);
});
});

describe('with pagination options', () => {
it('passes pagination parameters to the API', async () => {
const workosSpy = jest.spyOn(WorkOS.prototype, 'get');

const listResponse: ListResponse<AuditLogSchemaResponse> = {
object: 'list',
data: [],
list_metadata: {
before: undefined,
after: undefined,
},
};

workosSpy.mockResolvedValueOnce(mockWorkOsResponse(200, listResponse));

const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU');

await workos.auditLogs.listSchemas({
action: 'user.logged_in',
limit: 10,
after: 'cursor_123',
order: 'asc',
});

expect(workosSpy).toHaveBeenCalledWith(
'/audit_logs/actions/user.logged_in/schemas',
{ query: { limit: 10, after: 'cursor_123', order: 'asc' } },
);
});
});

describe('when the api responds with a 401', () => {
it('throws an UnauthorizedException', async () => {
const workosSpy = jest.spyOn(WorkOS.prototype, 'get');

workosSpy.mockImplementationOnce(() => {
throw new UnauthorizedException('a-request-id');
});

const workos = new WorkOS('invalid apikey');

await expect(
workos.auditLogs.listSchemas({ action: 'user.logged_in' }),
).rejects.toThrow(UnauthorizedException);
});
});

describe('with schema without optional fields', () => {
it('returns schema with undefined actor and metadata', async () => {
const workosSpy = jest.spyOn(WorkOS.prototype, 'get');

const time = new Date().toISOString();

const schemaResponse: AuditLogSchemaResponse = {
object: 'audit_log_schema',
version: 1,
targets: [
{
type: 'document',
},
],
created_at: time,
};

const listResponse: ListResponse<AuditLogSchemaResponse> = {
object: 'list',
data: [schemaResponse],
list_metadata: {
before: undefined,
after: undefined,
},
};

workosSpy.mockResolvedValueOnce(mockWorkOsResponse(200, listResponse));

const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU');

const result = await workos.auditLogs.listSchemas({
action: 'document.created',
});

expect(result.data).toHaveLength(1);
expect(result.data[0]).toEqual({
object: 'audit_log_schema',
version: 1,
targets: [
{
type: 'document',
metadata: undefined,
},
],
actor: undefined,
metadata: undefined,
createdAt: time,
});
});
});

describe('with multiple schemas', () => {
it('returns all schemas in the response', async () => {
const workosSpy = jest.spyOn(WorkOS.prototype, 'get');

const time1 = new Date().toISOString();
const time2 = new Date(Date.now() - 1000).toISOString();

const schemaResponse1: AuditLogSchemaResponse = {
object: 'audit_log_schema',
version: 2,
targets: [{ type: 'user' }],
created_at: time1,
};

const schemaResponse2: AuditLogSchemaResponse = {
object: 'audit_log_schema',
version: 1,
targets: [{ type: 'user' }],
metadata: {
type: 'object',
properties: {
ip_address: { type: 'string' },
},
},
created_at: time2,
};

const listResponse: ListResponse<AuditLogSchemaResponse> = {
object: 'list',
data: [schemaResponse1, schemaResponse2],
list_metadata: {
before: 'cursor_before',
after: 'cursor_after',
},
};

workosSpy.mockResolvedValueOnce(mockWorkOsResponse(200, listResponse));

const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU');

const result = await workos.auditLogs.listSchemas({
action: 'user.logged_in',
});

expect(result.data).toHaveLength(2);
expect(result.data[0].version).toBe(2);
expect(result.data[1].version).toBe(1);
// Metadata is deserialized to simplified format
expect(result.data[1].metadata).toEqual({ ip_address: 'string' });
expect(result.listMetadata.before).toBe('cursor_before');
expect(result.listMetadata.after).toBe('cursor_after');
});
});
});
});
37 changes: 34 additions & 3 deletions src/audit-logs/audit-logs.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { PaginationOptions } from '../common/interfaces';
import { fetchAndDeserialize } from '../common/utils/fetch-and-deserialize';
import { AutoPaginatable } from '../common/utils/pagination';
import { WorkOS } from '../workos';
import {
CreateAuditLogEventOptions,
CreateAuditLogEventRequestOptions,
ListSchemasOptions,
} from './interfaces';
import { AuditLogExportOptions } from './interfaces/audit-log-export-options.interface';
import {
Expand All @@ -10,16 +14,19 @@ import {
} from './interfaces/audit-log-export.interface';
import {
AuditLogSchema,
CreateAuditLogSchemaOptions,
CreateAuditLogSchemaRequestOptions,
AuditLogSchemaResponse,
} from './interfaces/audit-log-schema.interface';
import {
CreateAuditLogSchemaResponse,
CreateAuditLogSchemaRequestOptions,
CreateAuditLogSchemaOptions,
} from './interfaces/create-audit-log-schema-options.interface';
import {
deserializeAuditLogExport,
deserializeAuditLogSchema,
serializeAuditLogExportOptions,
serializeCreateAuditLogEventOptions,
serializeCreateAuditLogSchemaOptions,
deserializeAuditLogSchema,
} from './serializers';

export class AuditLogs {
Expand Down Expand Up @@ -77,4 +84,28 @@ export class AuditLogs {

return deserializeAuditLogSchema(data);
}

async listSchemas(
options: ListSchemasOptions,
): Promise<AutoPaginatable<AuditLogSchema, ListSchemasOptions>> {
const { action, ...paginationOptions } = options;
const endpoint = `/audit_logs/actions/${action}/schemas`;
Comment on lines +88 to +92
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] listSchemas should accept optional options like other list methods

listSchemas(options: ListSchemasOptions) forces callers to pass an object, even ListSchemasOptions is mostly pagination + required action. Most other list-style SDK methods take options?: ... and allow a simpler call-site / future optional expansion. Consider listSchemas(action: string, options?: PaginationOptions) or listSchemas(options: ListSchemasOptions) but make options optional with a runtime check.

(If this is intentional API design, please ignore—just flagging because it differs from the rest of the SDK pattern.)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/audit-logs/audit-logs.ts
Line: 88:92

Comment:
[P2] `listSchemas` should accept optional options like other list methods

`listSchemas(options: ListSchemasOptions)` forces callers to pass an object, even `ListSchemasOptions` is mostly pagination + required `action`. Most other list-style SDK methods take `options?: ...` and allow a simpler call-site / future optional expansion. Consider `listSchemas(action: string, options?: PaginationOptions)` or `listSchemas(options: ListSchemasOptions)` but make `options` optional with a runtime check.

(If this is intentional API design, please ignore—just flagging because it differs from the rest of the SDK pattern.)

<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@swaroopAkkineniWorkos This isn't a bad suggestion, since it might be a bit easier for devs to use listSchemas(action) if they're using the auto-pagination.


return new AutoPaginatable(
await fetchAndDeserialize<AuditLogSchemaResponse, AuditLogSchema>(
this.workos,
endpoint,
deserializeAuditLogSchema,
paginationOptions,
),
(params: PaginationOptions) =>
fetchAndDeserialize<AuditLogSchemaResponse, AuditLogSchema>(
this.workos,
endpoint,
deserializeAuditLogSchema,
params,
),
options,
);
}
}
46 changes: 46 additions & 0 deletions src/audit-logs/interfaces/audit-log-schema.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
export type AuditLogSchemaMetadata =
| Record<string, { type: 'string' | 'boolean' | 'number' }>
| undefined;

export interface AuditLogActorSchema {
metadata: Record<string, string | boolean | number>;
}

export interface AuditLogTargetSchema {
type: string;
metadata?: Record<string, string | boolean | number>;
}

export interface AuditLogSchema {
object: 'audit_log_schema';
version: number;
targets: AuditLogTargetSchema[];
actor: AuditLogActorSchema | undefined;
metadata: Record<string, string | boolean | number> | undefined;
createdAt: string;
}

interface SerializedAuditLogTargetSchema {
type: string;
metadata?: {
type: 'object';
properties: AuditLogSchemaMetadata;
};
}

export interface AuditLogSchemaResponse {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is exported publicly, this would be a breaking change. Perhaps we can alias CreateAuditLogSchemaResponse to AuditLogSchemaResponse and mark CreateAuditLogSchemaResponse as deprecated?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mattgd updated. Added the alias and also leaving all the exports in CreateAuditLogSchemaResponse as is and just exporting the types from the common, new audit log interface i made.

object: 'audit_log_schema';
version: number;
targets: SerializedAuditLogTargetSchema[];
actor?: {
metadata: {
type: 'object';
properties: AuditLogSchemaMetadata;
};
};
metadata?: {
type: 'object';
properties: AuditLogSchemaMetadata;
};
created_at: string;
}
Loading