Skip to content

Commit 4988cc6

Browse files
feat: add audit-log-viewer example app [] (#11271)
* feat: add audit-log-viewer example app Adds the Audit Log Viewer as a self-hosted reference app under examples/audit-log-viewer. The app reads Contentful audit log files from a customer-owned cloud storage destination (AWS S3, Azure Blob Storage, or Google Cloud Storage) and renders them in a filterable, paginated table with charts — all from inside the Contentful web app. A Contentful-hosted App Action Function holds cloud credentials as Secret installation parameters and generates short-lived signed URLs; the browser never sees the credentials directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: satisfy prettier and fix pagination test in audit-log-viewer example - prettier --write across all new example files (CI prettier check diffs against master and was failing on 27 files) - EventsTable.test.tsx was asserting on a Forma 36 Pagination "To next page" control that the component never renders; it uses plain Previous/Next buttons instead Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 46141b9 commit 4988cc6

58 files changed

Lines changed: 4174 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
node_modules
2+
build
3+
.env
4+
.env.local
5+
*_accessKeys.csv
6+
contentful-audit-*.json
7+
# GCP service-account keys — never commit
8+
coffee-review-*.json

examples/audit-log-viewer/README.md

Lines changed: 359 additions & 0 deletions
Large diffs are not rendered by default.
18.8 KB
Loading
Lines changed: 53 additions & 0 deletions
Loading
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"functions": [
3+
{
4+
"id": "auditLogBroker",
5+
"name": "Audit Log Broker",
6+
"description": "Lists audit log files in the customer S3 bucket for a date range and returns short-lived pre-signed GET URLs. Never returns credentials.",
7+
"path": "functions/auditLogBroker.js",
8+
"entryFile": "functions/auditLogBroker.ts",
9+
"allowNetworks": [
10+
"*.amazonaws.com",
11+
"*.windows.net",
12+
"storage.googleapis.com",
13+
"oauth2.googleapis.com"
14+
],
15+
"accepts": ["appaction.call"]
16+
}
17+
]
18+
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// @vitest-environment node
2+
import { describe, expect, it, vi } from 'vitest';
3+
import { makeHandler } from '../auditLogBroker';
4+
5+
const goodParams = {
6+
bucketName: 'b',
7+
region: 'eu-west-1',
8+
awsAccessKeyId: 'AKIA',
9+
awsSecretAccessKey: 's',
10+
};
11+
12+
const event = (body: Record<string, unknown>) =>
13+
({ type: 'appaction.call', headers: {}, body } as never);
14+
const context = (params: Record<string, unknown>) =>
15+
({ spaceId: 'sp', environmentId: 'master', appInstallationParameters: params } as never);
16+
17+
describe('auditLogBroker handler', () => {
18+
it('returns files from storage for a valid range', async () => {
19+
const listLogFiles = vi.fn(async () => ({
20+
files: [{ key: 'k', url: 'u', size: 1, coveredDate: '2026-06-02' }],
21+
truncated: false,
22+
}));
23+
const handler = makeHandler(() => ({ listLogFiles }));
24+
const res = await handler(
25+
event({ startDate: '2026-06-01', endDate: '2026-06-10' }),
26+
context(goodParams)
27+
);
28+
expect(res).toEqual({
29+
ok: true,
30+
files: [{ key: 'k', url: 'u', size: 1, coveredDate: '2026-06-02' }],
31+
truncated: false,
32+
});
33+
expect(listLogFiles).toHaveBeenCalledWith('2026-06-01', '2026-06-10');
34+
});
35+
36+
it('rejects malformed or inverted date ranges without calling storage', async () => {
37+
const listLogFiles = vi.fn();
38+
const handler = makeHandler(() => ({ listLogFiles }));
39+
for (const body of [
40+
{},
41+
{ startDate: 'junk', endDate: '2026-06-10' },
42+
{ startDate: '2026-06-10', endDate: '2026-06-01' },
43+
]) {
44+
const res = (await handler(event(body), context(goodParams))) as { ok: boolean };
45+
expect(res.ok).toBe(false);
46+
}
47+
expect(listLogFiles).not.toHaveBeenCalled();
48+
});
49+
50+
it('reports missing installation parameters by name', async () => {
51+
const handler = makeHandler(() => ({ listLogFiles: vi.fn() }));
52+
const res = (await handler(
53+
event({ startDate: '2026-06-01', endDate: '2026-06-10' }),
54+
context({ bucketName: 'b' })
55+
)) as { ok: boolean; error: string };
56+
expect(res.ok).toBe(false);
57+
expect(res.error).toContain('region');
58+
});
59+
60+
it('converts storage errors into { ok:false } without a stack', async () => {
61+
const handler = makeHandler(() => ({
62+
listLogFiles: vi.fn(async () => {
63+
throw new Error('AccessDenied');
64+
}),
65+
}));
66+
const res = (await handler(
67+
event({ startDate: '2026-06-01', endDate: '2026-06-10' }),
68+
context(goodParams)
69+
)) as { ok: boolean; error: string };
70+
expect(res.ok).toBe(false);
71+
expect(res.error).toContain('AccessDenied');
72+
expect(JSON.stringify(res)).not.toContain('at ');
73+
});
74+
});
75+
76+
describe('provider routing and validation', () => {
77+
const listLogFiles = vi.fn(async () => ({ files: [], truncated: false }));
78+
79+
it('defaults to s3 when provider is absent and routes the s3 config', async () => {
80+
const factory = vi.fn(() => ({ listLogFiles }));
81+
const handler = makeHandler(factory);
82+
await handler(event({ startDate: '2026-06-01', endDate: '2026-06-10' }), context(goodParams));
83+
expect(factory).toHaveBeenCalledWith(
84+
expect.objectContaining({ provider: 's3', bucketName: 'b' })
85+
);
86+
});
87+
88+
it('routes azure config when provider=azure', async () => {
89+
const factory = vi.fn(() => ({ listLogFiles }));
90+
const handler = makeHandler(factory);
91+
const res = await handler(
92+
event({ startDate: '2026-06-01', endDate: '2026-06-10' }),
93+
context({
94+
provider: 'azure',
95+
azureAccountName: 'acct',
96+
azureContainerName: 'logs',
97+
azureAccountKey: 'a2V5',
98+
})
99+
);
100+
expect(res).toEqual({ ok: true, files: [], truncated: false });
101+
expect(factory).toHaveBeenCalledWith(
102+
expect.objectContaining({ provider: 'azure', azureAccountName: 'acct' })
103+
);
104+
});
105+
106+
it('reports azure missing params by name', async () => {
107+
const handler = makeHandler(() => ({ listLogFiles }));
108+
const res = (await handler(
109+
event({ startDate: '2026-06-01', endDate: '2026-06-10' }),
110+
context({ provider: 'azure', azureAccountName: 'acct' })
111+
)) as { ok: boolean; error: string };
112+
expect(res.ok).toBe(false);
113+
expect(res.error).toContain('azureContainerName');
114+
});
115+
116+
it('validates gcs service-account JSON shape', async () => {
117+
const handler = makeHandler(() => ({ listLogFiles }));
118+
const res = (await handler(
119+
event({ startDate: '2026-06-01', endDate: '2026-06-10' }),
120+
context({ provider: 'gcs', gcsBucketName: 'b', gcsServiceAccountKey: '{"nope":1}' })
121+
)) as { ok: boolean; error: string };
122+
expect(res.ok).toBe(false);
123+
expect(res.error).toContain('client_email');
124+
});
125+
126+
it('accepts valid gcs params', async () => {
127+
const factory = vi.fn(() => ({ listLogFiles }));
128+
const handler = makeHandler(factory);
129+
const key = JSON.stringify({ client_email: 'x@y.iam.gserviceaccount.com', private_key: 'PEM' });
130+
const res = await handler(
131+
event({ startDate: '2026-06-01', endDate: '2026-06-10' }),
132+
context({ provider: 'gcs', gcsBucketName: 'b', gcsServiceAccountKey: key })
133+
);
134+
expect(res).toEqual({ ok: true, files: [], truncated: false });
135+
});
136+
137+
it('rejects unknown providers cleanly', async () => {
138+
const handler = makeHandler(() => ({ listLogFiles }));
139+
const res = (await handler(
140+
event({ startDate: '2026-06-01', endDate: '2026-06-10' }),
141+
context({ provider: 'ftp' })
142+
)) as { ok: boolean; error: string };
143+
expect(res.ok).toBe(false);
144+
expect(res.error).toContain('Unknown storage provider');
145+
});
146+
});

0 commit comments

Comments
 (0)