todo-app/test/desktop/wrapper_attach_route_test.dart
Krzysztof kuhy Rudnicki 9f2ffdb8a9
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file-length / file length <= 250 lines (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
Attach any file to a note, of any size, not only images
📎 now picks any file (Android: Camera / Gallery / File; desktop: an
unfiltered dialog) and the desktop page accepts any dropped file. Photo and
screenshot formats up to 64 MB are still re-encoded and shown inline;
everything else is stored byte for byte on dufs beside the images and linked
as [name](url), shown as a row with its size that opens in the platform's
app (Android) or a new browser tab (desktop). BACKLOG.md rewrites file links
to ~/data/cloud/todo-images/ like image links.

There is no size cap, so no layer holds a file in memory: uploads and
downloads stream through DufsImageClient, writes land in a .tmp renamed when
whole (an interrupted download no longer leaves a truncated file that looks
done), the desktop page POSTs the browser's own Blob with fetch, and Android's
"File" uses file_picker, because file_selector's Android side reads the whole
file into memory on the UI thread. Verified with a 1 GB file end to end on
desktop (peak RSS +36 MB) and on an API 36 emulator.

The wrapper serves attachments from the origin that holds the GitHub token
and the notes, so only script-free types open inline (nosniff), the rest
download; writes carrying another site's Origin are refused (this also
closes cross-site overwrites of the backup files); and attachment routes run
concurrently so a long upload or a playing video cannot stall the page.

Also: cupertino_icons 2.0.0 (unused, but the freshness gate blocks the
pubspec otherwise), open_file 4.0.0 and file_picker 13.1.0 pinned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 12:17:06 +02:00

216 lines
7.6 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import 'package:todo/desktop/wrapper_image_routes.dart';
import 'package:todo/desktop/wrapper_server.dart';
import 'package:todo/images/attachment_ref.dart';
import 'package:todo/images/image_cache_store.dart';
void main() {
late Directory root;
late WrapperServer server;
late String base;
late ImageCacheStore store;
setUp(() async {
root = Directory.systemTemp.createTempSync('wrapper_attach');
File(p.join(root.path, 'index.html')).writeAsStringSync('<h1>todo</h1>');
store = ImageCacheStore(Directory(p.join(root.path, 'images')));
server = WrapperServer(
webRoot: root.path,
backlogPath: p.join(root.path, 'BACKLOG.md'),
logPath: p.join(root.path, 'log.json'),
todoCredentialsPath: p.join(root.path, 'none.json'),
images: WrapperImageRoutes(
store: store,
loginEnvPath: p.join(root.path, 'none.env'),
// Identity "re-encode": decodes anything starting like a JPEG.
prepare: (bytes) async => bytes.first == 0xFF ? bytes : null,
),
);
await server.start(0);
base = 'http://localhost:${server.port}';
});
tearDown(() async {
await server.stop();
root.deleteSync(recursive: true);
});
Future<http.Response> attach(
List<int> body, {
String note = 'n1',
String name = 'Raport.pdf',
Map<String, String> headers = const {},
}) => http.post(
Uri.parse('$base/images/attach?note=$note&name=$name'),
body: body,
headers: headers,
);
Attachment attached(http.Response response) =>
Attachment.fromJson(jsonDecode(response.body));
test('attach stores any file byte for byte and answers with it', () async {
final response = await attach('%PDF-1.7 x'.codeUnits);
expect(response.statusCode, 200);
final a = attached(response);
expect((a.name, a.isImage, a.ref.extension), ('Raport.pdf', false, 'pdf'));
expect(store.fileFor(a.ref).readAsStringSync(), '%PDF-1.7 x');
expect(store.isPending(a.ref), isTrue);
});
test('attach turns an image into a re-encoded jpg', () async {
final a = attached(await attach([0xFF, 0xD8, 0xFF, 1], name: 'p.png'));
expect(a.isImage, isTrue);
expect(a.ref.extension, 'jpg');
});
test('attach refuses an unsafe note and reports a disk failure', () async {
expect((await attach([1], note: '..')).statusCode, 400);
expect((await attach([1], note: '')).statusCode, 400);
File(store.root.path).writeAsStringSync('in the way');
expect((await attach([1])).statusCode, 500);
});
group('serving', () {
late AttachmentRef pdf;
setUp(() async {
pdf = attached(await attach(List.filled(100, 7))).ref;
});
test('GET names the file and keeps the browser from sniffing', () async {
final response = await http.get(
Uri.parse('$base/images/${pdf.relativePath}?name=Raport%20v2.pdf'),
);
expect(response.statusCode, 200);
expect(response.headers['content-type'], 'application/pdf');
expect(response.headers['x-content-type-options'], 'nosniff');
expect(response.headers['accept-ranges'], 'bytes');
expect(
response.headers['content-disposition'],
"inline; filename*=UTF-8''Raport%20v2.pdf",
);
expect(response.bodyBytes, List.filled(100, 7));
});
test('a scriptable type is only ever a download', () async {
final html = attached(await attach([1], name: 'evil.html')).ref;
final response = await http.get(
Uri.parse('$base/images/${html.relativePath}'),
);
expect(response.headers['content-type'], 'application/octet-stream');
expect(
response.headers['content-disposition'],
startsWith('attachment;'),
);
});
test('HEAD reports the size without a body', () async {
final response = await http.head(
Uri.parse('$base/images/${pdf.relativePath}'),
);
expect(response.statusCode, 200);
expect(response.headers['content-length'], '100');
expect(response.bodyBytes, isEmpty);
});
test('a range is served partially, a bad one refused', () async {
final url = Uri.parse('$base/images/${pdf.relativePath}');
final part = await http.get(url, headers: {'range': 'bytes=10-19'});
expect(part.statusCode, 206);
expect(part.headers['content-range'], 'bytes 10-19/100');
expect(part.bodyBytes, hasLength(10));
final bad = await http.get(url, headers: {'range': 'bytes=500-'});
expect(bad.statusCode, 416);
expect(bad.headers['content-range'], 'bytes */100');
});
test('an empty file is served empty', () async {
final empty = attached(await attach([], name: 'empty.txt')).ref;
final response = await http.get(
Uri.parse('$base/images/${empty.relativePath}'),
);
expect(response.statusCode, 200);
expect(response.bodyBytes, isEmpty);
});
test('a client leaving mid-download does not stop the server', () async {
final big = attached(
await attach(List.filled(16 * 1024 * 1024, 1), name: 'big.bin'),
).ref;
final client = HttpClient();
final request = await client.getUrl(
Uri.parse('$base/images/${big.relativePath}'),
);
final response = await request.close();
await response.first;
client.close(force: true);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect((await http.get(Uri.parse('$base/'))).statusCode, 200);
});
});
test('writes from another site are refused, the app own allowed', () async {
final foreign = await attach(
[1],
headers: {'origin': 'https://evil.example'},
);
expect(foreign.statusCode, 403);
expect(store.pending(), isEmpty);
final backup = await http.post(
Uri.parse('$base/backup/backlog'),
headers: {'origin': 'https://evil.example'},
body: 'pwned',
);
expect(backup.statusCode, 403);
expect(File(p.join(root.path, 'BACKLOG.md')).existsSync(), isFalse);
for (final own in [
'http://localhost:${server.port}',
'http://127.0.0.1:${server.port}',
]) {
expect((await attach([1], headers: {'origin': own})).statusCode, 200);
}
final read = await http.get(
Uri.parse('$base/'),
headers: {'origin': 'https://evil.example'},
);
expect(read.statusCode, 200, reason: 'reads stay CORS-protected');
});
test('an upload abandoned halfway leaves nothing behind', () async {
final socket = await Socket.connect('localhost', server.port);
socket.write(
'POST /images/attach?note=n1&name=a.bin HTTP/1.1\r\n'
'Host: localhost\r\nContent-Length: 1000000\r\n\r\nabc',
);
await socket.flush();
await Future<void>.delayed(const Duration(milliseconds: 100));
socket.destroy();
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(store.root.listSync(recursive: true).whereType<File>(), isEmpty);
expect((await http.get(Uri.parse('$base/'))).statusCode, 200);
});
test('a slow upload does not hold up the page', () async {
final slow = http.StreamedRequest(
'POST',
Uri.parse('$base/images/attach?note=n1&name=slow.bin'),
)..contentLength = 4;
final sent = http.Client().send(slow);
slow.sink.add([1, 2]);
await Future<void>.delayed(const Duration(milliseconds: 50));
final page = await http
.get(Uri.parse('$base/'))
.timeout(const Duration(seconds: 5));
expect(page.statusCode, 200);
slow.sink.add([3, 4]);
unawaited(slow.sink.close());
expect((await sent).statusCode, 200);
});
}