feat(inbox): staged upload ack, HEIC/HEIF validation, WhatsApp silence fixes (#1605)
* fix(whatsapp): app-side unmute, close silent intake paths, health visibility - add POST /link/unmute and a Reactivate control on the Pausad state - company resolution: transient query errors release the row for sweep retry; genuine zero-options sends M19 instead of parking silently - media from unlinked senders bypasses the hourly greeting throttle (10 min burst window, daily cap kept) - GET /link returns 7-day failed-delivery and parked-inbound counts; sweep summary logs outboundFailed24h Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(documents): real HEIC/HEIF magic-byte validation, bilingual upload errors - detect ISO-BMFF ftyp brands (heic/heix/heim/heis/hevc/hevx/hevm/hevs, mif1/msf1) instead of exempting image/heic from validation; declared heic/heif accepts either family member (iOS labels vary) - new INBOX_UPLOAD_* structured error codes replace raw English strings on the inbox upload and attach-document routes - registry doc corrected to the real 10 MB cap Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(inbox): staged upload with instant ack and deferred AI extraction - web uploads insert the inbox item as status processing and respond immediately; Bedrock extraction and supplier match run via after() with a CAS flip to received (email and WhatsApp channels keep the synchronous path) - widen invoice_inbox_items.status CHECK to include processing (migration 20260813180000, pg-real test included) - crash-recovery sweep cron (*/2) flips stale processing rows; bulk-book skips extraction_in_progress items - workspace: processing chip, in-flight rows disable actions, realtime flip, retry-extraction button for empty extractions - picker accept list drops HEIC/HEIF so iOS transcodes library photos to JPEG; server allowlists unchanged (supersedes 2026-08-01 HEIC decision, see DECISIONS.md) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(migrations): bump inbox processing-status migration past main's latest Main merged 20260813210000 while this PR was in flight; an inserted version older than the latest applied aborts the prod db push at merge. Renamed 20260813180000 to 20260813213000 and updated references. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(decisions): log preview-tracker orphan repair after migration rename Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
456366fa70
commit
4a9fa5e6c5
@@ -63,6 +63,7 @@ import {
|
||||
createNewVersion,
|
||||
deleteDocument,
|
||||
verifyIntegrity,
|
||||
detectFileMagic,
|
||||
validateDocumentMagicBytes,
|
||||
buildDocumentStoragePath,
|
||||
buildPendingDocumentStoragePath,
|
||||
@@ -273,6 +274,59 @@ describe('validateDocumentMagicBytes: PDF header offset tolerance', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateDocumentMagicBytes: HEIC/HEIF (ISO-BMFF ftyp brands)', () => {
|
||||
// Minimal ISO-BMFF head: a 16-byte ftyp box whose major brand is `brand`.
|
||||
// Real files carry compatible brands and media data after this, but the
|
||||
// detector only reads the first 12 bytes.
|
||||
const isoBmff = (brand: string): ArrayBuffer => {
|
||||
const bytes = new Uint8Array(16)
|
||||
bytes[3] = 16 // box size (big-endian 0x00000010)
|
||||
bytes.set([0x66, 0x74, 0x79, 0x70], 4) // 'ftyp'
|
||||
bytes.set(new TextEncoder().encode(brand), 8)
|
||||
return bytes.buffer as ArrayBuffer
|
||||
}
|
||||
const jpegBytes = (): ArrayBuffer =>
|
||||
new Uint8Array([0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46]).buffer as ArrayBuffer
|
||||
|
||||
it('detects HEVC-coded brands as image/heic and MIAF brands as image/heif', () => {
|
||||
for (const brand of ['heic', 'heix', 'heim', 'heis', 'hevc', 'hevx', 'hevm', 'hevs']) {
|
||||
expect(detectFileMagic(new Uint8Array(isoBmff(brand)))).toBe('image/heic')
|
||||
}
|
||||
for (const brand of ['mif1', 'msf1']) {
|
||||
expect(detectFileMagic(new Uint8Array(isoBmff(brand)))).toBe('image/heif')
|
||||
}
|
||||
// Other ISO-BMFF brands (video containers) stay undetected.
|
||||
expect(detectFileMagic(new Uint8Array(isoBmff('isom')))).toBeNull()
|
||||
expect(detectFileMagic(new Uint8Array(isoBmff('qt ')))).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts a heic-brand file under both declared family members', () => {
|
||||
expect(validateDocumentMagicBytes(isoBmff('heic'), 'image/heic')).toBeNull()
|
||||
expect(validateDocumentMagicBytes(isoBmff('heic'), 'image/heif')).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts a mif1-brand file under both declared family members', () => {
|
||||
expect(validateDocumentMagicBytes(isoBmff('mif1'), 'image/heif')).toBeNull()
|
||||
expect(validateDocumentMagicBytes(isoBmff('mif1'), 'image/heic')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects garbage bytes declared image/heic (formerly blanket-exempted)', () => {
|
||||
const garbage = new TextEncoder().encode('this is not an image at all')
|
||||
const buffer = garbage.buffer.slice(garbage.byteOffset, garbage.byteOffset + garbage.byteLength) as ArrayBuffer
|
||||
expect(validateDocumentMagicBytes(buffer, 'image/heic')).toMatch(/kunde inte verifieras/)
|
||||
expect(validateDocumentMagicBytes(buffer, 'image/heif')).toMatch(/kunde inte verifieras/)
|
||||
})
|
||||
|
||||
it('rejects JPEG bytes declared image/heic as a type mismatch', () => {
|
||||
expect(validateDocumentMagicBytes(jpegBytes(), 'image/heic')).toMatch(/matchar inte/)
|
||||
})
|
||||
|
||||
it('does not loosen validation for other declared types', () => {
|
||||
// HEIC bytes declared as JPEG must still be rejected as a mismatch.
|
||||
expect(validateDocumentMagicBytes(isoBmff('heic'), 'image/jpeg')).toMatch(/matchar inte/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadDocument', () => {
|
||||
it('computes SHA-256 hash, stores metadata, emits document.uploaded', async () => {
|
||||
const doc = makeDocumentAttachment({
|
||||
|
||||
Reference in New Issue
Block a user