Files
puter/src/backend/drivers/util/fileInput.test.ts
T
404oopsandClaude Opus 5.5 eef8258539 fix(ai-ocr): sync OCR models and prices, route by model, cap input size
Model sync against Mistral's listing, docs and a live call per id:
mistral-ocr-latest now serves OCR 4.1, so the catalog pins it (with
mistral-ocr-4 and the retired-but-redirected 2503) to mistral-ocr-4-1,
adds mistral-ocr-4-0 and OCR 3 (mistral-ocr-2512 / -3 / -3-0), and
rejects the retired mistral-ocr-2505 before any upstream call. Mistral
usage is metered per model at vendor rates: $4/1000 pages for OCR 4.x
with $5 annotations, $2 and $3 for OCR 3, replacing the flat $1 and $3.

Textract now calls DetectDocumentText instead of AnalyzeDocument with
LAYOUT. The layout feature cost $4/1000 pages while we billed the
$1.50 detect rate, and it only added text-less LAYOUT_* blocks that put
blank lines into img2txt output. Multi-page PDFs and unsupported
formats become a 400 that names Mistral as the alternative.

Request shape: a model name alone now selects its provider, so calls
differ only by model; an explicit provider still picks its default and
must agree with the model. Mistral options gain tableFormat,
extractHeader, extractFooter and documentAnnotationPrompt; annotation
formats accept the REST (json_schema.schema) or SDK
(jsonSchema.schemaDefinition) spelling and are validated before
billing. Non-image documents such as DOCX go as document_url.

Response shape: both providers return model, text and blocks with a
0-based page; Mistral also returns document_annotation, which was
billed but dropped. img2txt resolves to that annotation when
documentAnnotationFormat is set and to the text otherwise; the return
is a string for every model, so normalize has no effect on it.

Input size: OCR input is capped at the provider limit (10 MB Textract,
50 MB Mistral), and web inputs in loadFileInput are streamed under the
cap instead of buffered whole, which also bounds speech2txt URLs.

Docs: img2txt models, options, input limits, return value and errors;
OCR limits in the quotas page.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 18:19:54 +02:00

477 lines
18 KiB
TypeScript

/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import type { Actor } from '../../core/actor.js';
import { runWithContext } from '../../core/context.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
import { inferFilenameFromUrlOrPath, loadFileInput } from './fileInput.js';
// Web inputs leave the process through secureFetch; stub that boundary so
// the tests control the remote response without real network access.
const { secureFetchMock } = vi.hoisted(() => ({ secureFetchMock: vi.fn() }));
vi.mock('../../util/secureHttp.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../../util/secureHttp.js')>()),
secureFetch: secureFetchMock,
}));
// ── Test harness ────────────────────────────────────────────────────
//
// Boots one real PuterServer (in-memory sqlite + dynamo + s3 + mock
// redis) and exercises `loadFileInput` against the live wired stores
// and FSService. Each test makes its own user via `makeUser` and,
// where the FS-resolution path is being exercised, writes a real
// file through FSService.write so there's an actual fsentry +
// in-memory S3 object behind the path / uuid we hand to loadFileInput.
let server: PuterServer;
beforeAll(async () => {
server = await setupTestServer();
});
afterAll(async () => {
await server?.shutdown();
});
const makeUser = async (): Promise<{ actor: Actor; userId: number }> => {
const username = `fic-${Math.random().toString(36).slice(2, 10)}`;
const created = await server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
free_storage: 100 * 1024 * 1024,
requires_email_confirmation: false,
});
await generateDefaultFsentries(
server.clients.db,
server.stores.user,
created,
);
const refreshed = (await server.stores.user.getById(created.id))!;
return {
userId: refreshed.id,
actor: {
user: {
id: refreshed.id,
uuid: refreshed.uuid,
username: refreshed.username,
email: refreshed.email ?? null,
email_confirmed: true,
} as Actor['user'],
},
};
};
const withActor = async <T>(actor: Actor, fn: () => Promise<T>): Promise<T> =>
runWithContext({ actor }, fn);
const writeFile = async (
userId: number,
path: string,
body: Buffer,
contentType = 'application/octet-stream',
) => {
const result = await server.services.fs.write(userId, {
fileMetadata: {
path,
size: body.byteLength,
contentType,
},
fileContent: body,
});
return result.fsEntry;
};
const callLoadFileInput = (
actor: Actor,
input: unknown,
options?: Parameters<typeof loadFileInput>[4],
) =>
withActor(actor, () =>
loadFileInput(
server.stores,
server.services.fs,
actor,
input,
options,
),
);
// ── inferFilenameFromUrlOrPath ─────────────────────────────────────
describe('inferFilenameFromUrlOrPath', () => {
it('returns the basename of a URL pathname', () => {
expect(inferFilenameFromUrlOrPath('https://cdn.test/a/b/photo.png')).toBe(
'photo.png',
);
});
it('returns the basename of a posix-style path string', () => {
expect(inferFilenameFromUrlOrPath('/alice/Music/song.mp3')).toBe(
'song.mp3',
);
});
it('falls back to the supplied default when there is no basename', () => {
// Empty string has no URL form and no posix basename, so the
// explicit fallback wins.
expect(inferFilenameFromUrlOrPath('', 'fallback-name')).toBe(
'fallback-name',
);
});
it('uses the literal `input` as fallback when no override is passed', () => {
expect(inferFilenameFromUrlOrPath('')).toBe('input');
});
it('handles bare filenames (no slashes, not a URL)', () => {
expect(inferFilenameFromUrlOrPath('plain.txt')).toBe('plain.txt');
});
});
// ── loadFileInput — argument validation ─────────────────────────────
describe('loadFileInput validation', () => {
it('throws 400 when input is empty/falsy', async () => {
const { actor } = await makeUser();
await expect(callLoadFileInput(actor, undefined)).rejects.toMatchObject(
{ statusCode: 400 },
);
await expect(callLoadFileInput(actor, null)).rejects.toMatchObject({
statusCode: 400,
});
await expect(callLoadFileInput(actor, '')).rejects.toMatchObject({
statusCode: 400,
});
});
it('throws 401 when actor.user.id is not a finite number', async () => {
// Missing actor entirely
await expect(
loadFileInput(
server.stores,
server.services.fs,
undefined as unknown as Actor,
'data:text/plain,hi',
),
).rejects.toMatchObject({ statusCode: 401 });
// Actor with no user
await expect(
loadFileInput(
server.stores,
server.services.fs,
{} as Actor,
'data:text/plain,hi',
),
).rejects.toMatchObject({ statusCode: 401 });
// Actor with non-numeric user.id
await expect(
loadFileInput(
server.stores,
server.services.fs,
{ user: { id: 'nope' } } as unknown as Actor,
'data:text/plain,hi',
),
).rejects.toMatchObject({ statusCode: 401 });
});
});
// ── loadFileInput — data URL path ───────────────────────────────────
describe('loadFileInput data URL', () => {
it('decodes a base64 data URL and reports the declared MIME', async () => {
const { actor } = await makeUser();
const payload = Buffer.from('hello world');
const dataUrl = `data:text/plain;base64,${payload.toString('base64')}`;
const result = await callLoadFileInput(actor, dataUrl);
expect(result.buffer.equals(payload)).toBe(true);
expect(result.mimeType).toBe('text/plain');
expect(result.fsEntry).toBeNull();
// Filename derives from the MIME subtype.
expect(result.filename).toBe('input.plain');
});
it('decodes a non-base64 (URL-encoded) data URL', async () => {
const { actor } = await makeUser();
// Plain (no `;base64`) → URL-decoded payload.
const result = await callLoadFileInput(
actor,
'data:text/plain,hello%20world',
);
expect(result.buffer.toString('utf8')).toBe('hello world');
expect(result.mimeType).toBe('text/plain');
});
it('infers MIME-derived filename for compound types like svg+xml', async () => {
const { actor } = await makeUser();
const result = await callLoadFileInput(
actor,
'data:image/svg+xml;base64,PHN2Zy8+',
);
// `image/svg+xml` → input.svg (subtype, before the `+`).
expect(result.filename).toBe('input.svg');
expect(result.mimeType).toBe('image/svg+xml');
});
it('defaults MIME to application/octet-stream when omitted', async () => {
const { actor } = await makeUser();
const result = await callLoadFileInput(actor, 'data:;base64,QUJD');
expect(result.mimeType).toBe('application/octet-stream');
expect(result.buffer.toString('utf8')).toBe('ABC');
});
it('throws 400 on a malformed data URL', async () => {
const { actor } = await makeUser();
// Missing comma → DATA_URL_PATTERN.exec returns null.
await expect(
callLoadFileInput(actor, 'data:not-a-url'),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects an oversize data URL with 413 + storage_limit_reached', async () => {
const { actor } = await makeUser();
const payload = Buffer.alloc(64);
const dataUrl = `data:application/octet-stream;base64,${payload.toString('base64')}`;
await expect(
callLoadFileInput(actor, dataUrl, { maxBytes: 32 }),
).rejects.toMatchObject({
statusCode: 413,
legacyCode: 'storage_limit_reached',
});
});
it('accepts a data URL exactly at the maxBytes threshold', async () => {
const { actor } = await makeUser();
const payload = Buffer.alloc(16, 0x41); // 16 bytes of 'A'
const dataUrl = `data:application/octet-stream;base64,${payload.toString('base64')}`;
const result = await callLoadFileInput(actor, dataUrl, {
maxBytes: 16,
});
expect(result.buffer.byteLength).toBe(16);
});
});
// ── loadFileInput — FS path ─────────────────────────────────────────
describe('loadFileInput FS path', () => {
it('reads bytes back through a real fsentry written via FSService', async () => {
const { actor, userId } = await makeUser();
const username = actor.user!.username!;
const path = `/${username}/Documents/sample.txt`;
const body = Buffer.from('hello from sql + s3');
const entry = await withActor(actor, () =>
writeFile(userId, path, body, 'text/plain'),
);
const result = await callLoadFileInput(actor, path);
expect(result.buffer.equals(body)).toBe(true);
expect(result.filename).toBe('sample.txt');
// FSService stamps the contentType into S3 metadata; loadFileInput
// returns it on the way out.
expect(result.mimeType).toBe('text/plain');
expect(result.fsEntry?.uuid).toBe(entry.uuid);
expect(result.fsEntry?.path).toBe(path);
});
it('returns empty bytes for an empty file with no backing S3 object', async () => {
// Files created via touch have size 0 and a null bucket — no S3
// object exists. loadFileInput must return empty content rather than
// throwing NoSuchKey from getObjectStream.
const { actor, userId } = await makeUser();
const username = actor.user!.username!;
const path = `/${username}/Documents/empty.txt`;
const entry = await withActor(actor, () =>
server.services.fs.touch(userId, { path }),
);
const result = await callLoadFileInput(actor, path);
expect(result.buffer.byteLength).toBe(0);
expect(result.fsEntry?.uuid).toBe(entry.uuid);
});
it('also accepts a `{ uuid }` object reference', async () => {
const { actor, userId } = await makeUser();
const username = actor.user!.username!;
const path = `/${username}/Documents/by-uuid.bin`;
const body = Buffer.from([0x01, 0x02, 0x03, 0x04]);
const entry = await withActor(actor, () =>
writeFile(userId, path, body),
);
const result = await callLoadFileInput(actor, { uuid: entry.uuid });
expect(result.buffer.equals(body)).toBe(true);
expect(result.fsEntry?.uuid).toBe(entry.uuid);
});
it('expands `~/...` paths against the actor home before resolving', async () => {
const { actor, userId } = await makeUser();
const username = actor.user!.username!;
const body = Buffer.from('tilde');
await withActor(actor, () =>
writeFile(userId, `/${username}/Documents/tilde.txt`, body),
);
const result = await callLoadFileInput(actor, '~/Documents/tilde.txt');
expect(result.buffer.equals(body)).toBe(true);
});
it('throws 404 when the fsentry cannot be resolved', async () => {
const { actor } = await makeUser();
await expect(
callLoadFileInput(actor, {
uuid: '00000000-0000-0000-0000-000000000000',
}),
).rejects.toMatchObject({ statusCode: 404 });
});
it('rejects directory entries with 400', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
// The Documents folder is a real fsentry directory created by
// generateDefaultFsentries.
await expect(
callLoadFileInput(actor, `/${username}/Documents`),
).rejects.toMatchObject({ statusCode: 400 });
});
it("rejects when the FS access check denies the actor", async () => {
// Owner writes a file; intruder tries to read it. The real
// FSService.checkFSAccess walks the ACL and refuses.
const owner = await makeUser();
const intruder = await makeUser();
const ownerName = owner.actor.user!.username!;
const path = `/${ownerName}/Documents/private.txt`;
await withActor(owner.actor, () =>
writeFile(owner.userId, path, Buffer.from('owned')),
);
const err = await callLoadFileInput(intruder.actor, path).then(
() => null,
(e: unknown) => e,
);
const status = (err as { statusCode?: number } | null)?.statusCode;
// Access denied lands as 403; "can't see" lands as 404 — both
// are valid refusals from ACLService.getSafeAclError.
expect([403, 404]).toContain(status);
});
it('rejects up-front when contentLength exceeds maxBytes', async () => {
const { actor, userId } = await makeUser();
const username = actor.user!.username!;
const path = `/${username}/Documents/big.bin`;
const body = Buffer.alloc(2048, 0x42);
await withActor(actor, () =>
writeFile(userId, path, body, 'application/octet-stream'),
);
await expect(
callLoadFileInput(actor, path, { maxBytes: 64 }),
).rejects.toMatchObject({
statusCode: 413,
legacyCode: 'storage_limit_reached',
});
});
});
// ── loadFileInput web URL ──────────────────────────────────────────
/** A web stream that yields `chunks` one pull at a time, counting pulls. */
const chunkedBody = (chunks: Buffer[]) => {
const state = { pulls: 0, cancelled: false };
const body = new ReadableStream<Uint8Array>({
pull(controller) {
const next = chunks[state.pulls++];
if (next) controller.enqueue(new Uint8Array(next));
else controller.close();
},
cancel() {
state.cancelled = true;
},
});
return { body, state };
};
describe('loadFileInput web URL', () => {
it('returns the fetched bytes with the response content type', async () => {
const { actor } = await makeUser();
const { body } = chunkedBody([Buffer.from('hello '), Buffer.from('web')]);
secureFetchMock.mockResolvedValueOnce(
new Response(body, {
headers: { 'content-type': 'image/png; charset=binary' },
}),
);
const loaded = await callLoadFileInput(
actor,
'https://example.com/scan.png',
{ acceptWebInput: true, maxBytes: 64 },
);
expect(loaded.buffer.toString()).toBe('hello web');
expect(loaded.mimeType).toBe('image/png');
expect(loaded.filename).toBe('scan.png');
});
it('refuses a declared content-length over maxBytes without reading the body', async () => {
const { actor } = await makeUser();
const { body, state } = chunkedBody([Buffer.alloc(32)]);
secureFetchMock.mockResolvedValueOnce(
new Response(body, { headers: { 'content-length': '4096' } }),
);
await expect(
callLoadFileInput(actor, 'https://example.com/big.pdf', {
acceptWebInput: true,
maxBytes: 64,
}),
).rejects.toMatchObject({
statusCode: 413,
legacyCode: 'storage_limit_reached',
});
expect(state.cancelled).toBe(true);
});
it('stops reading an undeclared-length body once it passes maxBytes', async () => {
const { actor } = await makeUser();
const chunks = Array.from({ length: 50 }, () => Buffer.alloc(40));
const { body, state } = chunkedBody(chunks);
secureFetchMock.mockResolvedValueOnce(new Response(body));
await expect(
callLoadFileInput(actor, 'https://example.com/stream', {
acceptWebInput: true,
maxBytes: 64,
}),
).rejects.toMatchObject({ statusCode: 413 });
expect(state.cancelled).toBe(true);
expect(state.pulls).toBeLessThan(chunks.length);
});
});