From 4c65e2208b1a387812fb9080b358bf8770c93832 Mon Sep 17 00:00:00 2001 From: Jimmy Pettersson Date: Tue, 25 Mar 2025 10:05:17 +0100 Subject: [PATCH 1/4] WIP --- src/audit_logs.ts | 59 ++++++++++++++++ src/client.ts | 3 + src/index.ts | 1 + test/unit/node/audit_logs_test.js | 107 ++++++++++++++++++++++++++++++ 4 files changed, 170 insertions(+) create mode 100644 src/audit_logs.ts create mode 100644 test/unit/node/audit_logs_test.js diff --git a/src/audit_logs.ts b/src/audit_logs.ts new file mode 100644 index 00000000..d4cec546 --- /dev/null +++ b/src/audit_logs.ts @@ -0,0 +1,59 @@ +import { StreamClient, APIResponse, UR, DefaultGenerics } from './client'; +import { SiteError } from './errors'; + +export type AuditLogFilterConditions = { + entity_type?: string; + entity_id?: string; + user_id?: string; + limit?: number; + next?: string; + prev?: string; +}; + +export type AuditLog = { + entity_type: string; + entity_id: string; + action: string; + user_id: string; + custom: Record; + created_at: string; +}; + +export type AuditLogAPIResponse = APIResponse & AuditLog; + +export type AuditLogFilterAPIResponse = APIResponse & { + audit_logs: AuditLogAPIResponse[]; + next: string; + prev: string; +}; + +export class StreamAuditLogs { + client: StreamClient; + token: string; + + constructor(client: StreamClient, token: string) { + this.client = client; + this.token = token; + } + + buildURL = (...args: string[]) => { + return `${['audit_logs', ...args].join('/')}/`; + }; + + filter(conditions: AuditLogFilterConditions) { + const url = this.buildURL(); + return this.client.get({ + url, + qs: conditions, + token: this.token, + }); + } + + get(id: string) { + const url = this.buildURL(id); + return this.client.get({ + url, + token: this.token, + }); + } +} \ No newline at end of file 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/unit/node/audit_logs_test.js b/test/unit/node/audit_logs_test.js new file mode 100644 index 00000000..8de840f9 --- /dev/null +++ b/test/unit/node/audit_logs_test.js @@ -0,0 +1,107 @@ +import expect from 'expect.js'; +import * as td from 'testdouble'; + +import { StreamClient } from '../../../src'; +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); + }); + }); +}); \ No newline at end of file From 4e37034593968b90f04c66d546de32337a6a694f Mon Sep 17 00:00:00 2001 From: Jimmy Pettersson Date: Tue, 25 Mar 2025 10:50:30 +0100 Subject: [PATCH 2/4] lint fix --- src/audit_logs.ts | 63 +++++++++++++++---------------- test/unit/node/audit_logs_test.js | 23 +++++------ 2 files changed, 42 insertions(+), 44 deletions(-) diff --git a/src/audit_logs.ts b/src/audit_logs.ts index d4cec546..547ec6e3 100644 --- a/src/audit_logs.ts +++ b/src/audit_logs.ts @@ -1,59 +1,56 @@ -import { StreamClient, APIResponse, UR, DefaultGenerics } from './client'; -import { SiteError } from './errors'; +import { StreamClient, UR, DefaultGenerics } from './client'; -export type AuditLogFilterConditions = { - entity_type?: string; - entity_id?: string; - user_id?: string; - limit?: number; - next?: string; - prev?: string; -}; - -export type AuditLog = { - entity_type: string; - entity_id: string; +export interface AuditLog { action: string; - user_id: string; - custom: Record; created_at: string; -}; + custom: Record; + entity_id: string; + entity_type: string; + user_id: string; +} -export type AuditLogAPIResponse = APIResponse & AuditLog; +export interface AuditLogFilterAPIResponse { + audit_logs: AuditLog[]; + duration: string; + next?: string; + prev?: string; +} -export type AuditLogFilterAPIResponse = APIResponse & { - audit_logs: AuditLogAPIResponse[]; - 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 { - client: StreamClient; token: string; + client: StreamClient; constructor(client: StreamClient, token: string) { this.client = client; this.token = token; } - buildURL = (...args: string[]) => { + buildURL(...args: string[]): string { return `${['audit_logs', ...args].join('/')}/`; - }; + } - filter(conditions: AuditLogFilterConditions) { - const url = this.buildURL(); - return this.client.get({ - url, - qs: conditions, + async filter(options?: AuditLogFilterOptions): Promise { + return this.client.get({ + url: this.buildURL(), + qs: options, token: this.token, }); } get(id: string) { const url = this.buildURL(id); - return this.client.get({ + return this.client.get({ url, token: this.token, }); } -} \ No newline at end of file +} diff --git a/test/unit/node/audit_logs_test.js b/test/unit/node/audit_logs_test.js index 8de840f9..f50ce0c5 100644 --- a/test/unit/node/audit_logs_test.js +++ b/test/unit/node/audit_logs_test.js @@ -1,7 +1,6 @@ import expect from 'expect.js'; import * as td from 'testdouble'; -import { StreamClient } from '../../../src'; import { beforeEachFn } from '../utils/hooks'; describe('[UNIT] Stream Audit Logs (node)', function () { @@ -21,7 +20,7 @@ describe('[UNIT] Stream Audit Logs (node)', 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( @@ -44,7 +43,7 @@ describe('[UNIT] Stream Audit Logs (node)', function () { prev: 'prev_cursor', }; this.client.auditLogs.token = fakedJWT; - + this.client.auditLogs.filter(conditions); td.verify( @@ -63,7 +62,7 @@ describe('[UNIT] Stream Audit Logs (node)', function () { limit: 10, }; this.client.auditLogs.token = fakedJWT; - + this.client.auditLogs.filter(conditions); td.verify( @@ -93,15 +92,17 @@ describe('[UNIT] Stream Audit Logs (node)', function () { prev: 'prev_cursor', }; this.client.auditLogs.token = fakedJWT; - - td.when(get({ - url: 'audit_logs/', - qs: undefined, - token: fakedJWT, - })).thenResolve(mockResponse); + + td.when( + get({ + url: 'audit_logs/', + qs: undefined, + token: fakedJWT, + }), + ).thenResolve(mockResponse); const response = await this.client.auditLogs.filter(); expect(response).to.eql(mockResponse); }); }); -}); \ No newline at end of file +}); From 58a11fbce041cfd6d0c07763f884fd8219849eb3 Mon Sep 17 00:00:00 2001 From: Jimmy Pettersson Date: Tue, 25 Mar 2025 11:19:04 +0100 Subject: [PATCH 3/4] WIP --- src/audit_logs.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/audit_logs.ts b/src/audit_logs.ts index 547ec6e3..5e33ab95 100644 --- a/src/audit_logs.ts +++ b/src/audit_logs.ts @@ -45,12 +45,4 @@ export class StreamAuditLogs({ - url, - token: this.token, - }); - } } From c21d9f77f1a1610d15a2449c16c8e74d3afb6c3a Mon Sep 17 00:00:00 2001 From: Jimmy Pettersson Date: Tue, 25 Mar 2025 12:10:50 +0100 Subject: [PATCH 4/4] integration tests --- test/integration/node/client_test.js | 105 +++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) 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); + } + }); + }); });