chore: merge executor worktree (worktree-agent-a2b7d8cafac0f4270)
This commit is contained in:
commit
f3ace33f86
6 changed files with 303 additions and 6 deletions
73
lib/services/autotask-client.test.ts
Normal file
73
lib/services/autotask-client.test.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { AutotaskClient } from './autotask-client';
|
||||
import type { AutotaskConfig } from '@/lib/types/autotask';
|
||||
|
||||
const FIXTURE_CONFIG: AutotaskConfig = {
|
||||
apiUrl: 'https://webservices.autotask.net/atservicesrest/v1.0',
|
||||
username: 'fixture@example.com',
|
||||
password: 'fixture-secret',
|
||||
apiIntegrationCode: 'FIXTURE-CODE',
|
||||
};
|
||||
|
||||
function jsonResponse(body: unknown, ok = true, status = 200): Response {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
statusText: ok ? 'OK' : 'Error',
|
||||
text: async () => JSON.stringify(body),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe('AutotaskClient.getAttachmentContent', () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns items[0] with populated base64 data when the API returns an items-shaped envelope', async () => {
|
||||
const attachment = { id: 555, fullPath: 'rfc.eml', title: 'rfc.eml', data: 'YmFzZTY0LWNvbnRlbnQ=' };
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
jsonResponse({ items: [attachment] })
|
||||
);
|
||||
|
||||
const client = new AutotaskClient(FIXTURE_CONFIG);
|
||||
const result = await client.getAttachmentContent('Tickets', 12345, 555);
|
||||
|
||||
expect(result).toEqual(attachment);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
`${FIXTURE_CONFIG.apiUrl}/Tickets/12345/Attachments/555`,
|
||||
expect.objectContaining({ method: 'GET' })
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when the API returns an empty items array', async () => {
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
jsonResponse({ items: [] })
|
||||
);
|
||||
|
||||
const client = new AutotaskClient(FIXTURE_CONFIG);
|
||||
const result = await client.getAttachmentContent('Tickets', 12345, 999);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null (not the attachment) when the API returns an {item:...}-shaped response', async () => {
|
||||
// Guards against a future refactor copying uploadAttachment's `.item`
|
||||
// convention onto this method — the live shape is `.items`.
|
||||
const attachment = { id: 555, fullPath: 'rfc.eml', title: 'rfc.eml', data: 'YmFzZTY0LWNvbnRlbnQ=' };
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
jsonResponse({ item: attachment })
|
||||
);
|
||||
|
||||
const client = new AutotaskClient(FIXTURE_CONFIG);
|
||||
const result = await client.getAttachmentContent('Tickets', 12345, 555);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -435,6 +435,26 @@ export class AutotaskClient {
|
|||
return response.items || [];
|
||||
}
|
||||
|
||||
// Fetches a single attachment's full content (base64 `data` populated).
|
||||
// Confirmed live: the per-attachment-ID GET returns `{items:[...]}`
|
||||
// (list-shaped), NOT `{item:...}` like getEntityById/uploadAttachment —
|
||||
// do not "fix" this to read response.item, that would silently return
|
||||
// undefined content.
|
||||
async getAttachmentContent(
|
||||
entityName: string,
|
||||
entityId: number,
|
||||
attachmentId: number
|
||||
): Promise<Attachment | null> {
|
||||
const url = `${this.config.apiUrl}/${entityName}/${entityId}/Attachments/${attachmentId}`;
|
||||
|
||||
const response = await this.makeApiCall<ApiResponse<Attachment>>(url, {
|
||||
method: 'GET',
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
|
||||
return response.items?.[0] ?? null;
|
||||
}
|
||||
|
||||
// Time Entries specific methods
|
||||
async getTimeEntriesByResource(resourceId: number): Promise<AutotaskTimeEntry[]> {
|
||||
return this.queryEntity<AutotaskTimeEntry>('TimeEntries', {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
OBJECT_KEY_REGEX,
|
||||
EML_OBJECT_KEY_REGEX,
|
||||
presignDownload,
|
||||
presignUpload,
|
||||
B2InvalidObjectKeyError,
|
||||
|
|
@ -117,6 +118,58 @@ describe('presignDownload + presignUpload', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('EML_OBJECT_KEY_REGEX', () => {
|
||||
it('accepts phishing/<reportId>/<attachmentId>.eml', () => {
|
||||
expect(
|
||||
EML_OBJECT_KEY_REGEX.test('phishing/ba03268b-5528-4dde-ad76-867523446ecd/555.eml')
|
||||
).toBe(true);
|
||||
expect(EML_OBJECT_KEY_REGEX.test('phishing/report_1/attachment_1.eml')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects path traversal', () => {
|
||||
expect(EML_OBJECT_KEY_REGEX.test('phishing/../evil.eml')).toBe(false);
|
||||
expect(EML_OBJECT_KEY_REGEX.test('phishing/report/../../escape.eml')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects wrong extension and the LogLift shape', () => {
|
||||
expect(EML_OBJECT_KEY_REGEX.test('phishing/a/b.json')).toBe(false);
|
||||
expect(
|
||||
EML_OBJECT_KEY_REGEX.test(
|
||||
'ba03268b-5528-4dde-ad76-867523446ecd/unknown-server/eventlogs_20251202_173301.json.gz'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('presignUpload with a custom keyRegex', () => {
|
||||
it('succeeds for a valid .eml key validated against EML_OBJECT_KEY_REGEX', () => {
|
||||
const url = presignUpload(
|
||||
'phishing/report_1/attachment_1.eml',
|
||||
1800,
|
||||
FIXTURE_CFG,
|
||||
EML_OBJECT_KEY_REGEX
|
||||
);
|
||||
expect(url).toContain('X-Amz-Expires=1800');
|
||||
});
|
||||
|
||||
it('throws B2InvalidObjectKeyError for a LogLift-shaped key when validated against EML_OBJECT_KEY_REGEX', () => {
|
||||
expect(() =>
|
||||
presignUpload(
|
||||
'site/host/eventlogs_20260502_120000.json.gz',
|
||||
1800,
|
||||
FIXTURE_CFG,
|
||||
EML_OBJECT_KEY_REGEX
|
||||
)
|
||||
).toThrow(B2InvalidObjectKeyError);
|
||||
});
|
||||
|
||||
it('still validates against OBJECT_KEY_REGEX by default (existing LogLift call sites unchanged)', () => {
|
||||
expect(() =>
|
||||
presignUpload('phishing/report_1/attachment_1.eml', 1800, FIXTURE_CFG)
|
||||
).toThrow(B2InvalidObjectKeyError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveSigningKey', () => {
|
||||
it('produces a 32-byte HMAC-SHA256 chain', () => {
|
||||
const k = _B2_INTERNALS.deriveSigningKey(
|
||||
|
|
|
|||
|
|
@ -31,6 +31,16 @@ export const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024; // 25 MB
|
|||
export const OBJECT_KEY_REGEX =
|
||||
/^[A-Za-z0-9_-]+\/[A-Za-z0-9_.-]+\/eventlogs_[0-9_]+\.json\.gz$/;
|
||||
|
||||
/**
|
||||
* Object-key shape for raw `.eml` evidence uploads (Phase 16 / D-05):
|
||||
* `phishing/{reportId}/{attachmentId}.eml`. This is a SEPARATE regex from
|
||||
* OBJECT_KEY_REGEX — per the B2 evidence skill doc, never loosen the
|
||||
* existing LogLift guard to accommodate a new shape. Path-traversal safe:
|
||||
* each segment is restricted to `[A-Za-z0-9_-]+`, so `..` cannot appear.
|
||||
*/
|
||||
export const EML_OBJECT_KEY_REGEX =
|
||||
/^phishing\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+\.eml$/;
|
||||
|
||||
export class B2NotConfiguredError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
|
|
@ -145,18 +155,20 @@ function presign(params: PresignParams): string {
|
|||
export function presignDownload(
|
||||
objectKey: string,
|
||||
expiresInSeconds = 600,
|
||||
cfg: B2Config = getB2Config()
|
||||
cfg: B2Config = getB2Config(),
|
||||
keyRegex: RegExp = OBJECT_KEY_REGEX
|
||||
): string {
|
||||
if (!OBJECT_KEY_REGEX.test(objectKey)) throw new B2InvalidObjectKeyError(objectKey);
|
||||
if (!keyRegex.test(objectKey)) throw new B2InvalidObjectKeyError(objectKey);
|
||||
return presign({ method: 'GET', objectKey, expiresInSeconds, config: cfg });
|
||||
}
|
||||
|
||||
export function presignUpload(
|
||||
objectKey: string,
|
||||
expiresInSeconds = 1800,
|
||||
cfg: B2Config = getB2Config()
|
||||
cfg: B2Config = getB2Config(),
|
||||
keyRegex: RegExp = OBJECT_KEY_REGEX
|
||||
): string {
|
||||
if (!OBJECT_KEY_REGEX.test(objectKey)) throw new B2InvalidObjectKeyError(objectKey);
|
||||
if (!keyRegex.test(objectKey)) throw new B2InvalidObjectKeyError(objectKey);
|
||||
return presign({ method: 'PUT', objectKey, expiresInSeconds, config: cfg });
|
||||
}
|
||||
|
||||
|
|
@ -166,9 +178,10 @@ export function presignUpload(
|
|||
*/
|
||||
export async function downloadToBuffer(
|
||||
objectKey: string,
|
||||
cfg: B2Config = getB2Config()
|
||||
cfg: B2Config = getB2Config(),
|
||||
keyRegex: RegExp = OBJECT_KEY_REGEX
|
||||
): Promise<Buffer> {
|
||||
const url = presignDownload(objectKey, 600, cfg);
|
||||
const url = presignDownload(objectKey, 600, cfg, keyRegex);
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue