chore: merge executor worktree (worktree-agent-a2b7d8cafac0f4270)

This commit is contained in:
lorentz 2026-07-15 10:32:42 -04:00
commit f3ace33f86
6 changed files with 303 additions and 6 deletions

View 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();
});
});

View file

@ -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', {

View file

@ -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(

View file

@ -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(() => '');