Skip to content

Commit 155f548

Browse files
[FEEDS-145] Audit logs (#623)
* WIP * lint fix * WIP * integration tests
1 parent c4dd78f commit 155f548

File tree

5 files changed

+265
-0
lines changed

5 files changed

+265
-0
lines changed

src/audit_logs.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { StreamClient, UR, DefaultGenerics } from './client';
2+
3+
export interface AuditLog {
4+
action: string;
5+
created_at: string;
6+
custom: Record<string, unknown>;
7+
entity_id: string;
8+
entity_type: string;
9+
user_id: string;
10+
}
11+
12+
export interface AuditLogFilterAPIResponse {
13+
audit_logs: AuditLog[];
14+
duration: string;
15+
next?: string;
16+
prev?: string;
17+
}
18+
19+
export interface AuditLogFilterOptions extends UR {
20+
entity_id?: string;
21+
entity_type?: string;
22+
limit?: number;
23+
next?: string;
24+
prev?: string;
25+
user_id?: string;
26+
}
27+
28+
export class StreamAuditLogs<StreamFeedGenerics extends DefaultGenerics = DefaultGenerics> {
29+
token: string;
30+
client: StreamClient<StreamFeedGenerics>;
31+
32+
constructor(client: StreamClient<StreamFeedGenerics>, token: string) {
33+
this.client = client;
34+
this.token = token;
35+
}
36+
37+
buildURL(...args: string[]): string {
38+
return `${['audit_logs', ...args].join('/')}/`;
39+
}
40+
41+
async filter(options?: AuditLogFilterOptions): Promise<AuditLogFilterAPIResponse> {
42+
return this.client.get({
43+
url: this.buildURL(),
44+
qs: options,
45+
token: this.token,
46+
});
47+
}
48+
}

src/client.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { StreamFileStore } from './files';
1313
import { StreamImageStore } from './images';
1414
import { StreamReaction } from './reaction';
1515
import { StreamUser } from './user';
16+
import { StreamAuditLogs } from './audit_logs';
1617
import { JWTScopeToken, JWTUserSessionToken } from './signing';
1718
import { FeedError, StreamApiError, SiteError } from './errors';
1819
import utils from './utils';
@@ -183,6 +184,7 @@ export class StreamClient<StreamFeedGenerics extends DefaultGenerics = DefaultGe
183184
files: StreamFileStore;
184185
images: StreamImageStore;
185186
reactions: StreamReaction<StreamFeedGenerics>;
187+
auditLogs: StreamAuditLogs<StreamFeedGenerics>;
186188

187189
private _personalizationToken?: string;
188190
private _collectionsToken?: string;
@@ -293,6 +295,7 @@ export class StreamClient<StreamFeedGenerics extends DefaultGenerics = DefaultGe
293295
this.files = new StreamFileStore(this as StreamClient, this.getOrCreateToken());
294296
this.images = new StreamImageStore(this as StreamClient, this.getOrCreateToken());
295297
this.reactions = new StreamReaction<StreamFeedGenerics>(this, this.getOrCreateToken());
298+
this.auditLogs = new StreamAuditLogs<StreamFeedGenerics>(this, this.getOrCreateToken());
296299

297300
// If we are in a node environment and batchOperations/createRedirectUrl is available add the methods to the prototype of StreamClient
298301
if (BatchOperations && !!createRedirectUrl && DataPrivacy) {

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ export * from './user';
2020
export * from './batch_operations';
2121
export * from './errors';
2222
export * from './signing';
23+
export * from './audit_logs';

test/integration/node/client_test.js

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -746,4 +746,109 @@ describe('[INTEGRATION] Stream client (Node)', function () {
746746
expect(resp.export.activity_ids).to.eql([activityRes.id]);
747747
expect(resp.export.reaction_ids).to.eql([reaction1.id, reaction3.id]);
748748
});
749+
750+
describe('Audit Logs', function () {
751+
it('filter audit logs by entity type and ID', async function () {
752+
// First create an activity to generate an audit log
753+
const activity = {
754+
actor: 'user:1',
755+
verb: 'tweet',
756+
object: '1',
757+
foreign_id: `audit-test-${Date.now()}`,
758+
};
759+
760+
const activityRes = await this.user1.addActivity(activity);
761+
762+
// Filter audit logs for this activity
763+
const response = await this.client.auditLogs.filter({
764+
entity_type: 'activity',
765+
entity_id: activityRes.id,
766+
limit: 5,
767+
});
768+
769+
// Verify response structure
770+
expect(response).to.have.property('duration');
771+
expect(response).to.have.property('audit_logs');
772+
expect(Array.isArray(response.audit_logs)).to.be(true);
773+
774+
// There should be at least one audit log for this activity
775+
expect(response.audit_logs.length).to.be.greaterThan(0);
776+
777+
// Check log structure
778+
const log = response.audit_logs[0];
779+
expect(log).to.have.property('entity_type');
780+
expect(log).to.have.property('entity_id');
781+
expect(log).to.have.property('action');
782+
expect(log).to.have.property('user_id');
783+
expect(log).to.have.property('created_at');
784+
});
785+
786+
it('filter audit logs with pagination', async function () {
787+
// First create an activity to generate an audit log
788+
const activity = {
789+
actor: 'user:1',
790+
verb: 'tweet',
791+
object: '1',
792+
foreign_id: `audit-pagination-test-${Date.now()}`,
793+
};
794+
795+
const activityRes = await this.user1.addActivity(activity);
796+
797+
// Filter with a small limit to ensure pagination
798+
const response = await this.client.auditLogs.filter({
799+
entity_type: 'activity',
800+
entity_id: activityRes.id,
801+
limit: 1,
802+
});
803+
804+
// Verify response structure includes pagination
805+
expect(response).to.have.property('next');
806+
807+
// If there's more than one result, test pagination
808+
if (response.next) {
809+
const nextPage = await this.client.auditLogs.filter({
810+
entity_type: 'activity',
811+
entity_id: activityRes.id,
812+
limit: 1,
813+
next: response.next,
814+
});
815+
816+
expect(nextPage).to.have.property('audit_logs');
817+
expect(Array.isArray(nextPage.audit_logs)).to.be(true);
818+
819+
// Next page should have results
820+
expect(nextPage.audit_logs.length).to.be(1);
821+
822+
// Next page should have different results
823+
if (response.audit_logs.length > 0 && nextPage.audit_logs.length > 0) {
824+
expect(response.audit_logs[0].id).to.not.eql(nextPage.audit_logs[0].id);
825+
}
826+
}
827+
});
828+
829+
it('filter audit logs by user ID', async function () {
830+
// Create an activity with a specific user ID
831+
const userId = randUserId('audit');
832+
const activity = {
833+
actor: userId,
834+
verb: 'tweet',
835+
object: '1',
836+
foreign_id: `audit-user-test-${Date.now()}`,
837+
};
838+
839+
await this.user1.addActivity(activity);
840+
841+
// Filter audit logs for this user
842+
const response = await this.client.auditLogs.filter({
843+
user_id: userId,
844+
limit: 5,
845+
});
846+
847+
// Check that we get logs for this user
848+
if (response.audit_logs.length > 0) {
849+
const log = response.audit_logs[0];
850+
expect(log.user_id).to.be(userId);
851+
}
852+
});
853+
});
749854
});

test/unit/node/audit_logs_test.js

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import expect from 'expect.js';
2+
import * as td from 'testdouble';
3+
4+
import { beforeEachFn } from '../utils/hooks';
5+
6+
describe('[UNIT] Stream Audit Logs (node)', function () {
7+
let get;
8+
9+
beforeEach(beforeEachFn);
10+
beforeEach(function () {
11+
get = td.function();
12+
td.replace(this.client, 'get', get);
13+
});
14+
15+
afterEach(function () {
16+
td.reset();
17+
});
18+
19+
describe('filter', function () {
20+
it('should send get request with no conditions', function () {
21+
const fakedJWT = 'Faked JWT';
22+
this.client.auditLogs.token = fakedJWT;
23+
24+
this.client.auditLogs.filter();
25+
26+
td.verify(
27+
get({
28+
url: 'audit_logs/',
29+
qs: undefined,
30+
token: fakedJWT,
31+
}),
32+
);
33+
});
34+
35+
it('should send get request with all filter conditions', function () {
36+
const fakedJWT = 'Faked JWT';
37+
const conditions = {
38+
entity_type: 'activity',
39+
entity_id: '123',
40+
user_id: 'user1',
41+
limit: 10,
42+
next: 'next_cursor',
43+
prev: 'prev_cursor',
44+
};
45+
this.client.auditLogs.token = fakedJWT;
46+
47+
this.client.auditLogs.filter(conditions);
48+
49+
td.verify(
50+
get({
51+
url: 'audit_logs/',
52+
qs: conditions,
53+
token: fakedJWT,
54+
}),
55+
);
56+
});
57+
58+
it('should handle partial filter conditions', function () {
59+
const fakedJWT = 'Faked JWT';
60+
const conditions = {
61+
entity_type: 'activity',
62+
limit: 10,
63+
};
64+
this.client.auditLogs.token = fakedJWT;
65+
66+
this.client.auditLogs.filter(conditions);
67+
68+
td.verify(
69+
get({
70+
url: 'audit_logs/',
71+
qs: conditions,
72+
token: fakedJWT,
73+
}),
74+
);
75+
});
76+
77+
it('should return the correct response type', async function () {
78+
const fakedJWT = 'Faked JWT';
79+
const mockResponse = {
80+
duration: '0.1s',
81+
audit_logs: [
82+
{
83+
entity_type: 'activity',
84+
entity_id: '123',
85+
action: 'create',
86+
user_id: 'user1',
87+
custom: {},
88+
created_at: '2024-01-01T00:00:00Z',
89+
},
90+
],
91+
next: 'next_cursor',
92+
prev: 'prev_cursor',
93+
};
94+
this.client.auditLogs.token = fakedJWT;
95+
96+
td.when(
97+
get({
98+
url: 'audit_logs/',
99+
qs: undefined,
100+
token: fakedJWT,
101+
}),
102+
).thenResolve(mockResponse);
103+
104+
const response = await this.client.auditLogs.filter();
105+
expect(response).to.eql(mockResponse);
106+
});
107+
});
108+
});

0 commit comments

Comments
 (0)