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
48 changes: 48 additions & 0 deletions src/audit_logs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { StreamClient, UR, DefaultGenerics } from './client';

export interface AuditLog {
action: string;
created_at: string;
custom: Record<string, unknown>;
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<StreamFeedGenerics extends DefaultGenerics = DefaultGenerics> {
token: string;
client: StreamClient<StreamFeedGenerics>;

constructor(client: StreamClient<StreamFeedGenerics>, token: string) {
this.client = client;
this.token = token;
}

buildURL(...args: string[]): string {
return `${['audit_logs', ...args].join('/')}/`;
}

async filter(options?: AuditLogFilterOptions): Promise<AuditLogFilterAPIResponse> {
return this.client.get({
url: this.buildURL(),
qs: options,
token: this.token,
});
}
}
3 changes: 3 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -183,6 +184,7 @@ export class StreamClient<StreamFeedGenerics extends DefaultGenerics = DefaultGe
files: StreamFileStore;
images: StreamImageStore;
reactions: StreamReaction<StreamFeedGenerics>;
auditLogs: StreamAuditLogs<StreamFeedGenerics>;

private _personalizationToken?: string;
private _collectionsToken?: string;
Expand Down Expand Up @@ -293,6 +295,7 @@ export class StreamClient<StreamFeedGenerics extends DefaultGenerics = DefaultGe
this.files = new StreamFileStore(this as StreamClient, this.getOrCreateToken());
this.images = new StreamImageStore(this as StreamClient, this.getOrCreateToken());
this.reactions = new StreamReaction<StreamFeedGenerics>(this, this.getOrCreateToken());
this.auditLogs = new StreamAuditLogs<StreamFeedGenerics>(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) {
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ export * from './user';
export * from './batch_operations';
export * from './errors';
export * from './signing';
export * from './audit_logs';
105 changes: 105 additions & 0 deletions test/integration/node/client_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
});
});
108 changes: 108 additions & 0 deletions test/unit/node/audit_logs_test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});