@datacules/agent-identity-audit 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/audit.test.ts +213 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datacules/agent-identity-audit",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "private": false,
5
5
  "description": "Pre-built audit logger sinks for @datacules/agent-identity (Console, Webhook, Datadog, Splunk)",
6
6
  "main": "./dist/cjs/index.js",
@@ -12,7 +12,7 @@
12
12
  "type-check": "tsc --noEmit"
13
13
  },
14
14
  "peerDependencies": {
15
- "@datacules/agent-identity": "^0.1.0"
15
+ "@datacules/agent-identity": "^0.6.0"
16
16
  },
17
17
  "devDependencies": {
18
18
  "@datacules/agent-identity": "*",
@@ -0,0 +1,213 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import {
3
+ ConsoleAuditLogger,
4
+ WebhookAuditLogger,
5
+ DatadogAuditLogger,
6
+ SplunkAuditLogger,
7
+ CompositeAuditLogger,
8
+ } from './index';
9
+ import type { AuditLogEntry } from '@datacules/agent-identity';
10
+
11
+ // All HTTP calls are mocked via vi.stubGlobal('fetch', ...).
12
+ // No live webhook, Datadog, or Splunk endpoint is needed.
13
+ const mockFetch = vi.fn();
14
+ vi.stubGlobal('fetch', mockFetch);
15
+
16
+ const ENTRY: AuditLogEntry = {
17
+ userId: 'user-alice',
18
+ resourceId: 'knowledge-base',
19
+ resourceKind: 'shared',
20
+ provider: 'openai',
21
+ action: 'read',
22
+ credentialId: 'cred-openai-prod',
23
+ credentialKind: 'fixed',
24
+ resolvedFor: 'service',
25
+ traceId: 'trace-abc123',
26
+ sessionId: 'sess-xyz',
27
+ requestedAt: '2026-05-30T12:00:00.000Z',
28
+ model: 'gpt-4o',
29
+ };
30
+
31
+ // ── ConsoleAuditLogger ─────────────────────────────────────────────────────
32
+
33
+ describe('ConsoleAuditLogger', () => {
34
+ it('calls console.log with the [agent-identity audit] prefix and JSON entry', async () => {
35
+ const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
36
+ const logger = new ConsoleAuditLogger();
37
+ await logger.log(ENTRY);
38
+ expect(spy).toHaveBeenCalledOnce();
39
+ const [prefix, json] = spy.mock.calls[0] as [string, string];
40
+ expect(prefix).toBe('[agent-identity audit]');
41
+ expect(json).toContain('cred-openai-prod');
42
+ spy.mockRestore();
43
+ });
44
+
45
+ it('resolves without throwing on any valid AuditLogEntry', async () => {
46
+ vi.spyOn(console, 'log').mockImplementation(() => {});
47
+ const logger = new ConsoleAuditLogger();
48
+ await expect(logger.log(ENTRY)).resolves.not.toThrow();
49
+ vi.restoreAllMocks();
50
+ });
51
+ });
52
+
53
+ // ── WebhookAuditLogger ─────────────────────────────────────────────────────
54
+
55
+ describe('WebhookAuditLogger', () => {
56
+ beforeEach(() => vi.clearAllMocks());
57
+
58
+ it('POSTs the entry as JSON to the configured webhook URL', async () => {
59
+ mockFetch.mockResolvedValueOnce({ ok: true } as Response);
60
+ const logger = new WebhookAuditLogger({ url: 'https://hooks.example.com/agent-identity' });
61
+ await logger.log(ENTRY);
62
+ expect(mockFetch).toHaveBeenCalledWith(
63
+ 'https://hooks.example.com/agent-identity',
64
+ expect.objectContaining({
65
+ method: 'POST',
66
+ headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
67
+ body: expect.stringContaining('cred-openai-prod'),
68
+ })
69
+ );
70
+ });
71
+
72
+ it('adds the X-Webhook-Secret header when a secret is configured', async () => {
73
+ mockFetch.mockResolvedValueOnce({ ok: true } as Response);
74
+ const logger = new WebhookAuditLogger({
75
+ url: 'https://hooks.example.com/ai',
76
+ secret: 'hmac-secret-xyz',
77
+ });
78
+ await logger.log(ENTRY);
79
+ expect(mockFetch).toHaveBeenCalledWith(
80
+ expect.any(String),
81
+ expect.objectContaining({
82
+ headers: expect.objectContaining({ 'X-Webhook-Secret': 'hmac-secret-xyz' }),
83
+ })
84
+ );
85
+ });
86
+
87
+ it('resolves without throwing when fetch fails and silent=true (default)', async () => {
88
+ mockFetch.mockRejectedValueOnce(new Error('Network unreachable'));
89
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
90
+ const logger = new WebhookAuditLogger({ url: 'https://hooks.example.com/agent-identity' });
91
+ await expect(logger.log(ENTRY)).resolves.not.toThrow();
92
+ vi.restoreAllMocks();
93
+ });
94
+
95
+ it('throws when fetch fails and silent=false', async () => {
96
+ mockFetch.mockRejectedValueOnce(new Error('Connection refused'));
97
+ const logger = new WebhookAuditLogger({
98
+ url: 'https://hooks.example.com/ai',
99
+ silent: false,
100
+ });
101
+ await expect(logger.log(ENTRY)).rejects.toThrow('Connection refused');
102
+ });
103
+ });
104
+
105
+ // ── DatadogAuditLogger ─────────────────────────────────────────────────────
106
+
107
+ describe('DatadogAuditLogger', () => {
108
+ beforeEach(() => vi.clearAllMocks());
109
+
110
+ it('POSTs to the Datadog log intake URL with the DD-API-KEY header', async () => {
111
+ mockFetch.mockResolvedValueOnce({ ok: true } as Response);
112
+ const logger = new DatadogAuditLogger({ apiKey: 'dd-api-key-test-123' });
113
+ await logger.log(ENTRY);
114
+ expect(mockFetch).toHaveBeenCalledWith(
115
+ expect.stringContaining('https://http-intake.logs.datadoghq.com/api/v2/logs'),
116
+ expect.objectContaining({
117
+ headers: expect.objectContaining({ 'DD-API-KEY': 'dd-api-key-test-123' }),
118
+ })
119
+ );
120
+ });
121
+
122
+ it('uses a custom Datadog site when the site option is specified', async () => {
123
+ mockFetch.mockResolvedValueOnce({ ok: true } as Response);
124
+ const logger = new DatadogAuditLogger({ apiKey: 'key', site: 'datadoghq.eu' });
125
+ await logger.log(ENTRY);
126
+ const [url] = mockFetch.mock.calls[0] as [string];
127
+ expect(url).toContain('datadoghq.eu');
128
+ });
129
+
130
+ it('is silent by default when fetch fails (resolves without throwing)', async () => {
131
+ mockFetch.mockRejectedValueOnce(new Error('Datadog unreachable'));
132
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
133
+ const logger = new DatadogAuditLogger({ apiKey: 'key' });
134
+ await expect(logger.log(ENTRY)).resolves.not.toThrow();
135
+ vi.restoreAllMocks();
136
+ });
137
+ });
138
+
139
+ // ── SplunkAuditLogger ─────────────────────────────────────────────────────
140
+
141
+ describe('SplunkAuditLogger', () => {
142
+ beforeEach(() => vi.clearAllMocks());
143
+
144
+ it('POSTs to the HEC URL with a Splunk token Authorization header', async () => {
145
+ mockFetch.mockResolvedValueOnce({ ok: true } as Response);
146
+ const logger = new SplunkAuditLogger({
147
+ hecUrl: 'https://splunk.example.com:8088/services/collector',
148
+ token: 'splunk-token-abc',
149
+ });
150
+ await logger.log(ENTRY);
151
+ expect(mockFetch).toHaveBeenCalledWith(
152
+ 'https://splunk.example.com:8088/services/collector',
153
+ expect.objectContaining({
154
+ headers: expect.objectContaining({ Authorization: 'Splunk splunk-token-abc' }),
155
+ })
156
+ );
157
+ });
158
+
159
+ it('includes the audit entry inside the Splunk event payload', async () => {
160
+ mockFetch.mockResolvedValueOnce({ ok: true } as Response);
161
+ const logger = new SplunkAuditLogger({
162
+ hecUrl: 'https://splunk.example.com/collector',
163
+ token: 'tok',
164
+ });
165
+ await logger.log(ENTRY);
166
+ const body = JSON.parse(
167
+ (mockFetch.mock.calls[0][1] as RequestInit).body as string
168
+ ) as { event: AuditLogEntry; sourcetype: string };
169
+ expect(body.event).toMatchObject({ credentialId: 'cred-openai-prod' });
170
+ expect(body.sourcetype).toBe('agent_identity');
171
+ });
172
+
173
+ it('is silent by default when fetch fails (resolves without throwing)', async () => {
174
+ mockFetch.mockRejectedValueOnce(new Error('HEC unreachable'));
175
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
176
+ const logger = new SplunkAuditLogger({
177
+ hecUrl: 'https://splunk.example.com/collector',
178
+ token: 'tok',
179
+ });
180
+ await expect(logger.log(ENTRY)).resolves.not.toThrow();
181
+ vi.restoreAllMocks();
182
+ });
183
+ });
184
+
185
+ // ── CompositeAuditLogger ───────────────────────────────────────────────────
186
+
187
+ describe('CompositeAuditLogger', () => {
188
+ beforeEach(() => vi.clearAllMocks());
189
+
190
+ it('forwards the entry to all registered loggers', async () => {
191
+ const logA = { log: vi.fn<[AuditLogEntry], Promise<void>>().mockResolvedValue(undefined) };
192
+ const logB = { log: vi.fn<[AuditLogEntry], Promise<void>>().mockResolvedValue(undefined) };
193
+ const composite = new CompositeAuditLogger([logA, logB]);
194
+ await composite.log(ENTRY);
195
+ expect(logA.log).toHaveBeenCalledWith(ENTRY);
196
+ expect(logB.log).toHaveBeenCalledWith(ENTRY);
197
+ });
198
+
199
+ it('continues via Promise.allSettled even when one logger rejects', async () => {
200
+ const logA = { log: vi.fn<[AuditLogEntry], Promise<void>>().mockRejectedValue(new Error('Sink A failed')) };
201
+ const logB = { log: vi.fn<[AuditLogEntry], Promise<void>>().mockResolvedValue(undefined) };
202
+ const composite = new CompositeAuditLogger([logA, logB]);
203
+ await expect(composite.log(ENTRY)).resolves.not.toThrow();
204
+ expect(logB.log).toHaveBeenCalledWith(ENTRY);
205
+ });
206
+
207
+ it('works correctly with a single logger', async () => {
208
+ const logA = { log: vi.fn<[AuditLogEntry], Promise<void>>().mockResolvedValue(undefined) };
209
+ const composite = new CompositeAuditLogger([logA]);
210
+ await composite.log(ENTRY);
211
+ expect(logA.log).toHaveBeenCalledOnce();
212
+ });
213
+ });