diff --git a/src/audit_logs.ts b/src/audit_logs.ts new file mode 100644 index 00000000..5e33ab95 --- /dev/null +++ b/src/audit_logs.ts @@ -0,0 +1,48 @@ +import { StreamClient, UR, DefaultGenerics } from './client'; + +export interface AuditLog { + action: string; + created_at: string; + custom: Record; + entity_id: string; + entity_type: string; + user_id: string; +} + +export interface AuditLogFilterAPIResponse { + audit_logs: AuditLog[]; + duration: string; + next?: string; + prev?: string; +} + +export interface AuditLogFilterOptions extends UR { + entity_id?: string; + entity_type?: string; + limit?: number; + next?: string; + prev?: string; + user_id?: string; +} + +export class StreamAuditLogs { + token: string; + client: StreamClient; + + constructor(client: StreamClient, token: string) { + this.client = client; + this.token = token; + } + + buildURL(...args: string[]): string { + return `${['audit_logs', ...args].join('/')}/`; + } + + async filter(options?: AuditLogFilterOptions): Promise { + return this.client.get({ + url: this.buildURL(), + qs: options, + token: this.token, + }); + } +} diff --git a/src/client.ts b/src/client.ts index 1482835d..38f4572d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -13,6 +13,7 @@ import { StreamFileStore } from './files'; import { StreamImageStore } from './images'; import { StreamReaction } from './reaction'; import { StreamUser } from './user'; +import { StreamAuditLogs } from './audit_logs'; import { JWTScopeToken, JWTUserSessionToken } from './signing'; import { FeedError, StreamApiError, SiteError } from './errors'; import utils from './utils'; @@ -183,6 +184,7 @@ export class StreamClient; + auditLogs: StreamAuditLogs; private _personalizationToken?: string; private _collectionsToken?: string; @@ -293,6 +295,7 @@ export class StreamClient(this, this.getOrCreateToken()); + this.auditLogs = new StreamAuditLogs(this, this.getOrCreateToken()); // If we are in a node environment and batchOperations/createRedirectUrl is available add the methods to the prototype of StreamClient if (BatchOperations && !!createRedirectUrl && DataPrivacy) { diff --git a/src/index.ts b/src/index.ts index d91f5f03..a084ffa3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,3 +20,4 @@ export * from './user'; export * from './batch_operations'; export * from './errors'; export * from './signing'; +export * from './audit_logs'; diff --git a/test/integration/node/client_test.js b/test/integration/node/client_test.js index 4cc2b0f5..f38ac857 100644 --- a/test/integration/node/client_test.js +++ b/test/integration/node/client_test.js @@ -746,4 +746,109 @@ describe('[INTEGRATION] Stream client (Node)', function () { expect(resp.export.activity_ids).to.eql([activityRes.id]); expect(resp.export.reaction_ids).to.eql([reaction1.id, reaction3.id]); }); + + describe('Audit Logs', function () { + it('filter audit logs by entity type and ID', async function () { + // First create an activity to generate an audit log + const activity = { + actor: 'user:1', + verb: 'tweet', + object: '1', + foreign_id: `audit-test-${Date.now()}`, + }; + + const activityRes = await this.user1.addActivity(activity); + + // Filter audit logs for this activity + const response = await this.client.auditLogs.filter({ + entity_type: 'activity', + entity_id: activityRes.id, + limit: 5, + }); + + // Verify response structure + expect(response).to.have.property('duration'); + expect(response).to.have.property('audit_logs'); + expect(Array.isArray(response.audit_logs)).to.be(true); + + // There should be at least one audit log for this activity + expect(response.audit_logs.length).to.be.greaterThan(0); + + // Check log structure + const log = response.audit_logs[0]; + expect(log).to.have.property('entity_type'); + expect(log).to.have.property('entity_id'); + expect(log).to.have.property('action'); + expect(log).to.have.property('user_id'); + expect(log).to.have.property('created_at'); + }); + + it('filter audit logs with pagination', async function () { + // First create an activity to generate an audit log + const activity = { + actor: 'user:1', + verb: 'tweet', + object: '1', + foreign_id: `audit-pagination-test-${Date.now()}`, + }; + + const activityRes = await this.user1.addActivity(activity); + + // Filter with a small limit to ensure pagination + const response = await this.client.auditLogs.filter({ + entity_type: 'activity', + entity_id: activityRes.id, + limit: 1, + }); + + // Verify response structure includes pagination + expect(response).to.have.property('next'); + + // If there's more than one result, test pagination + if (response.next) { + const nextPage = await this.client.auditLogs.filter({ + entity_type: 'activity', + entity_id: activityRes.id, + limit: 1, + next: response.next, + }); + + expect(nextPage).to.have.property('audit_logs'); + expect(Array.isArray(nextPage.audit_logs)).to.be(true); + + // Next page should have results + expect(nextPage.audit_logs.length).to.be(1); + + // Next page should have different results + if (response.audit_logs.length > 0 && nextPage.audit_logs.length > 0) { + expect(response.audit_logs[0].id).to.not.eql(nextPage.audit_logs[0].id); + } + } + }); + + it('filter audit logs by user ID', async function () { + // Create an activity with a specific user ID + const userId = randUserId('audit'); + const activity = { + actor: userId, + verb: 'tweet', + object: '1', + foreign_id: `audit-user-test-${Date.now()}`, + }; + + await this.user1.addActivity(activity); + + // Filter audit logs for this user + const response = await this.client.auditLogs.filter({ + user_id: userId, + limit: 5, + }); + + // Check that we get logs for this user + if (response.audit_logs.length > 0) { + const log = response.audit_logs[0]; + expect(log.user_id).to.be(userId); + } + }); + }); }); diff --git a/test/unit/node/audit_logs_test.js b/test/unit/node/audit_logs_test.js new file mode 100644 index 00000000..f50ce0c5 --- /dev/null +++ b/test/unit/node/audit_logs_test.js @@ -0,0 +1,108 @@ +import expect from 'expect.js'; +import * as td from 'testdouble'; + +import { beforeEachFn } from '../utils/hooks'; + +describe('[UNIT] Stream Audit Logs (node)', function () { + let get; + + beforeEach(beforeEachFn); + beforeEach(function () { + get = td.function(); + td.replace(this.client, 'get', get); + }); + + afterEach(function () { + td.reset(); + }); + + describe('filter', function () { + it('should send get request with no conditions', function () { + const fakedJWT = 'Faked JWT'; + this.client.auditLogs.token = fakedJWT; + + this.client.auditLogs.filter(); + + td.verify( + get({ + url: 'audit_logs/', + qs: undefined, + token: fakedJWT, + }), + ); + }); + + it('should send get request with all filter conditions', function () { + const fakedJWT = 'Faked JWT'; + const conditions = { + entity_type: 'activity', + entity_id: '123', + user_id: 'user1', + limit: 10, + next: 'next_cursor', + prev: 'prev_cursor', + }; + this.client.auditLogs.token = fakedJWT; + + this.client.auditLogs.filter(conditions); + + td.verify( + get({ + url: 'audit_logs/', + qs: conditions, + token: fakedJWT, + }), + ); + }); + + it('should handle partial filter conditions', function () { + const fakedJWT = 'Faked JWT'; + const conditions = { + entity_type: 'activity', + limit: 10, + }; + this.client.auditLogs.token = fakedJWT; + + this.client.auditLogs.filter(conditions); + + td.verify( + get({ + url: 'audit_logs/', + qs: conditions, + token: fakedJWT, + }), + ); + }); + + it('should return the correct response type', async function () { + const fakedJWT = 'Faked JWT'; + const mockResponse = { + duration: '0.1s', + audit_logs: [ + { + entity_type: 'activity', + entity_id: '123', + action: 'create', + user_id: 'user1', + custom: {}, + created_at: '2024-01-01T00:00:00Z', + }, + ], + next: 'next_cursor', + prev: 'prev_cursor', + }; + this.client.auditLogs.token = fakedJWT; + + td.when( + get({ + url: 'audit_logs/', + qs: undefined, + token: fakedJWT, + }), + ).thenResolve(mockResponse); + + const response = await this.client.auditLogs.filter(); + expect(response).to.eql(mockResponse); + }); + }); +});