diff --git a/app/bin/diet_guard_desktop.dart b/app/bin/diet_guard_desktop.dart new file mode 100644 index 0000000..91eb3af --- /dev/null +++ b/app/bin/diet_guard_desktop.dart @@ -0,0 +1,133 @@ +// coverage:ignore-file +// Entry point for the desktop wrapper: resolves real paths, starts the +// server, and launches the browser. The serving logic it delegates to is +// covered by test/desktop/wrapper_server_test.dart. +import 'dart:io'; + +import 'package:diet_guard_app/desktop/github_proxy.dart'; +import 'package:diet_guard_app/desktop/wrapper_server.dart'; +import 'package:diet_guard_app/services/desktop_wrapper.dart'; +import 'package:path/path.dart' as p; + +Future main(List args) async { + final home = Platform.environment['HOME']; + if (home == null) { + stderr.writeln('HOME is not set; cannot resolve the data directory.'); + exit(1); + } + + // `dart build cli` emits bundle/bin/ alongside bundle/lib/, so the + // installed layout is /opt/diet-guard/bin/diet_guard_desktop with the web + // assets one level up at /opt/diet-guard/web. --web-root overrides for + // development runs straight out of the repo. + final webRoot = + _argValue(args, '--web-root') ?? + p.normalize(p.join(p.dirname(Platform.resolvedExecutable), '..', 'web')); + if (!Directory(webRoot).existsSync()) { + stderr.writeln('web assets not found at $webRoot'); + exit(1); + } + + // Overridable so a verification run cannot touch the real data or the real + // sync token. + final dataDir = + _argValue(args, '--data-dir') ?? + p.join(home, '.local', 'share', 'diet-guard-desktop'); + final proxy = GitHubProxy( + tokenPath: + _argValue(args, '--token-path') ?? + p.join(home, '.config', 'diet_guard_app', 'sync_token'), + // The PC's Python gate already keeps a token for `diet-guard-sync.timer`; + // reusing it means the desktop app needs no second setup on this machine. + // Read-only: a token obtained here is written to our own path. + fallbackTokenPath: + _argValue(args, '--fallback-token-path') ?? + p.join(home, '.config', 'diet_guard', 'sync_token'), + ); + + final server = WrapperServer( + webRoot: webRoot, + dataDir: dataDir, + gitHubProxy: proxy, + ); + final port = + int.tryParse(_argValue(args, '--port') ?? '') ?? desktopWrapperPort; + await server.start(port); + stdout.writeln('diet_guard desktop serving on http://localhost:$port'); + + // A bare flag, not a valued option: requiring a dummy value meant passing a + // stray positional, which the AOT runtime tries to interpret as a snapshot. + if (!args.contains('--no-browser')) { + final ranLongEnough = await _launchBrowser(home, port); + if (!ranLongEnough) { + // Chrome exits immediately when it hands the URL to an instance that + // already owns the profile directory (or when a stale SingletonLock is + // left behind). Shutting down here would pull the server out from under + // a window that is still open, so keep serving instead. + stdout.writeln( + 'Browser returned immediately (handed off to an existing window). ' + 'Still serving on http://localhost:$port -- Ctrl-C to stop.', + ); + return; + } + // Otherwise the browser owned the session: its window closed, so we exit. + await server.stop(); + } +} + +/// Launches the app in a Chrome-family browser with a **stable** profile +/// directory, since the food log, photos and settings live in that profile. +/// Returns true when the browser ran long enough to have owned the session. +Future _launchBrowser(String home, int port) async { + // Ordered by preference, and deliberately broad: this machine runs Thorium + // behind /opt/google/chrome, and has a policy that uninstalls the `chromium` + // package, so assuming any single browser is wrong. DIET_GUARD_BROWSER + // overrides. + final candidates = [ + Platform.environment['DIET_GUARD_BROWSER'] ?? '', + '/opt/google/chrome/chrome', + '/opt/thorium-browser/thorium-browser', + '/usr/bin/google-chrome-stable', + '/usr/bin/chromium', + '/usr/bin/brave', + ]; + final browser = candidates.firstWhere( + (path) => path.isNotEmpty && File(path).existsSync(), + orElse: () => '', + ); + if (browser.isEmpty) { + stderr.writeln( + 'No Chrome-family browser found; open http://localhost:$port manually.', + ); + return false; + } + // The profile directory must stay as stable as the port: IndexedDB is keyed + // by origin and lives inside this profile, so a changing path silently + // hides the entire food log. + final profile = p.join( + home, + '.local', + 'share', + 'diet-guard-desktop', + 'profile', + ); + final process = await Process.start(browser, [ + '--app=http://localhost:$port', + '--user-data-dir=$profile', + // Sets WM_CLASS, which the .desktop entry matches on via StartupWMClass. + // Without it the window inherits the browser's class and the taskbar shows + // a browser icon instead of diet_guard's. + '--class=diet_guard_app', + '--no-first-run', + ]); + + final started = DateTime.now(); + await process.exitCode; + return DateTime.now().difference(started) > const Duration(seconds: 5); +} + +String? _argValue(List args, String name) { + final index = args.indexOf(name); + if (index == -1 || index + 1 >= args.length) return null; + return args[index + 1]; +} diff --git a/app/lib/desktop/github_proxy.dart b/app/lib/desktop/github_proxy.dart new file mode 100644 index 0000000..cb0a827 --- /dev/null +++ b/app/lib/desktop/github_proxy.dart @@ -0,0 +1,194 @@ +/// The desktop wrapper's GitHub half: token storage, device flow, API proxy. +/// +/// The desktop app runs in a browser, and a browser cannot do either half of +/// GitHub sync safely on its own: +/// +/// * GitHub's **device-flow** endpoints (`github.com/login/device/code` and +/// the token poll) send no CORS headers, so a page cannot call them at all. +/// * A token held by the page would live in `localStorage` inside the Chrome +/// profile -- `flutter_secure_storage` on web is WebCrypto-wrapped +/// `localStorage` with the wrap key sitting beside the ciphertext, which is +/// obfuscation rather than encryption at rest. +/// +/// This wrapper is a local process with neither limitation, so it owns the +/// token file and performs every authenticated call itself. The page never +/// sees the token: it asks for *status*, and its API requests are proxied +/// with the `Authorization` header attached here. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; + +/// GitHub's REST API base, which [GitHubProxy] fronts. +const githubApiBase = 'https://api.github.com'; + +/// GitHub's device-flow base (a different host, and CORS-less on purpose). +const githubDeviceBase = 'https://github.com'; + +/// Serves `/github/*` for the wrapper. +class GitHubProxy { + /// Creates a proxy storing its token at [tokenPath], optionally seeding + /// from [fallbackTokenPath] (the PC's own `diet_guard` sync token) when it + /// has none of its own. + GitHubProxy({ + required this.tokenPath, + this.fallbackTokenPath, + http.Client? httpClient, + }) : _http = httpClient ?? http.Client(); + + /// File this wrapper writes tokens to (mode 600). + final String tokenPath; + + /// Optional pre-existing token to fall back to, so a PC that already + /// configured `python -m diet_guard sync` needs no second setup. + final String? fallbackTokenPath; + + final http.Client _http; + + /// Reads the stored token, or the empty string when there is none. + Future readToken() async { + for (final path in [tokenPath, ?fallbackTokenPath]) { + final file = File(path); + if (!file.existsSync()) continue; + try { + final token = (await file.readAsString()).trim(); + if (token.isNotEmpty) return token; + } on FileSystemException { + continue; + } + } + return ''; + } + + /// Writes [token] (mode 600), or deletes the file when it is empty. + Future writeToken(String token) async { + final file = File(tokenPath); + if (token.isEmpty) { + if (file.existsSync()) await file.delete(); + return; + } + await file.parent.create(recursive: true); + await file.writeAsString(token); + // The token is a repo-scoped credential; keep it off other users' eyes + // the same way the Python side's sync_token is stored. + await Process.run('chmod', ['600', tokenPath]); + } + + /// Handles a `/github/...` [request], with [rest] the path after `/github/`. + /// + /// Returns false when [rest] matches nothing, so the caller can 404. + Future handle(HttpRequest request, String rest) async { + switch (rest) { + case 'auth/status': + final token = await readToken(); + _json(request, {'configured': token.isNotEmpty}); + return true; + case 'auth/token': + await writeToken((await utf8.decoder.bind(request).join()).trim()); + request.response.statusCode = HttpStatus.noContent; + return true; + case 'auth/device/start': + await _deviceStart(request); + return true; + case 'auth/device/poll': + await _devicePoll(request); + return true; + } + if (rest.startsWith('api/')) { + await _proxyApi(request, rest.substring('api/'.length)); + return true; + } + return false; + } + + Future _deviceStart(HttpRequest request) async { + final body = await _jsonBody(request); + final response = await _http.post( + Uri.parse('$githubDeviceBase/login/device/code'), + headers: const {'Accept': 'application/json'}, + body: {'client_id': '${body['client_id']}', 'scope': 'repo'}, + ); + _passThrough(request, response); + } + + Future _devicePoll(HttpRequest request) async { + final body = await _jsonBody(request); + final response = await _http.post( + Uri.parse('$githubDeviceBase/login/oauth/access_token'), + headers: const {'Accept': 'application/json'}, + body: { + 'client_id': '${body['client_id']}', + 'device_code': '${body['device_code']}', + 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', + }, + ); + Map decoded; + try { + decoded = jsonDecode(response.body) as Map; + } on FormatException { + _passThrough(request, response); + return; + } + final token = decoded['access_token']; + if (token is String && token.isNotEmpty) { + await writeToken(token); + // Deliberately does not echo the token back: the whole point of the + // proxy is that the browser never holds it. + _json(request, {'status': 'ok'}); + return; + } + // Still pending / denied / expired: the page drives the poll loop, so it + // needs GitHub's own `error` value verbatim. + _json(request, decoded..remove('access_token')); + } + + Future _proxyApi(HttpRequest request, String path) async { + final token = await readToken(); + final uri = Uri.parse( + '$githubApiBase/$path', + ).replace(query: request.uri.query.isEmpty ? null : request.uri.query); + final proxied = http.Request(request.method, uri) + ..headers.addAll({ + 'Authorization': 'Bearer $token', + 'Accept': 'application/vnd.github+json', + 'Content-Type': 'application/json', + }) + ..bodyBytes = await _bodyBytes(request); + final response = await http.Response.fromStream(await _http.send(proxied)); + _passThrough(request, response); + } + + Future> _jsonBody(HttpRequest request) async { + final raw = await utf8.decoder.bind(request).join(); + if (raw.isEmpty) return {}; + try { + final decoded = jsonDecode(raw); + return decoded is Map ? decoded : {}; + } on FormatException { + return {}; + } + } + + Future> _bodyBytes(HttpRequest request) async { + final chunks = await request.toList(); + return [for (final chunk in chunks) ...chunk]; + } + + void _passThrough(HttpRequest request, http.Response response) { + request.response + ..statusCode = response.statusCode + ..headers.contentType = ContentType.json + ..add(response.bodyBytes); + } + + void _json(HttpRequest request, Map body) { + request.response + ..headers.contentType = ContentType.json + ..write(jsonEncode(body)); + } + + /// Releases the outbound HTTP client. + void close() => _http.close(); +} diff --git a/app/lib/desktop/wrapper_server.dart b/app/lib/desktop/wrapper_server.dart new file mode 100644 index 0000000..5f990bf --- /dev/null +++ b/app/lib/desktop/wrapper_server.dart @@ -0,0 +1,200 @@ +/// Local HTTP server backing the desktop app. +/// +/// The desktop app is a Flutter **web** build (Flutter's Linux embedder +/// manages only ~20fps at 4K, where the same Dart code in Chrome sustains +/// ~144fps -- see `~/todo/docs/desktop-performance-findings.md`), so it runs +/// in a browser and cannot touch the filesystem. This process is the other +/// half of the desktop app: +/// +/// * it serves the built web assets; +/// * it owns an on-disk copy of every document and photo the app stores, so a +/// wiped Chrome profile is not a total-loss event (photos in particular +/// never sync, so nothing else could restore them); +/// * it fronts GitHub, holding the sync token and running the CORS-less +/// device flow the page cannot (see `github_proxy.dart`). +/// +/// Binds to loopback only. The endpoints overwrite files in the user's home +/// directory with no authentication, so exposing them on a routable address +/// would let anything on the network rewrite the food log. +library; + +import 'dart:async'; +import 'dart:io'; + +import 'package:diet_guard_app/desktop/github_proxy.dart'; +import 'package:diet_guard_app/services/desktop_wrapper.dart'; +import 'package:path/path.dart' as p; + +/// Serves the desktop app's web build plus its disk-backed endpoints. +class WrapperServer { + /// Creates a server serving [webRoot], mirroring the app's storage under + /// [dataDir], and proxying GitHub through [gitHubProxy]. + WrapperServer({ + required this.webRoot, + required this.dataDir, + required this.gitHubProxy, + }); + + /// Directory holding the built Flutter web assets. + final String webRoot; + + /// Directory holding the on-disk copies of documents and photo blobs. + final String dataDir; + + /// GitHub half of the wrapper. + final GitHubProxy gitHubProxy; + + HttpServer? _server; + + /// Port the server is listening on, once [start] has completed. + int get port => _server!.port; + + /// Binds to loopback on [requestedPort] and begins serving. + /// + /// Pass 0 to let the OS choose a port (tests do this); the desktop launcher + /// passes [desktopWrapperPort], because the browser keys IndexedDB by + /// origin -- a changing port would look like an app with no history. + Future start(int requestedPort) async { + _server = await HttpServer.bind( + InternetAddress.loopbackIPv4, + requestedPort, + ); + unawaited(_serve(_server!)); + } + + /// Stops serving and releases the port. + Future stop() async { + await _server?.close(force: true); + gitHubProxy.close(); + } + + Future _serve(HttpServer server) async { + await for (final request in server) { + try { + await _handle(request); + } on Exception { + request.response.statusCode = HttpStatus.internalServerError; + } + await request.response.close(); + } + } + + Future _handle(HttpRequest request) async { + final path = request.uri.path; + if (path.startsWith(WrapperPaths.github)) { + final rest = path.substring(WrapperPaths.github.length); + if (await gitHubProxy.handle(request, rest)) return; + request.response.statusCode = HttpStatus.notFound; + return; + } + if (path.startsWith(WrapperPaths.documents)) { + return _storedFile( + request, + 'documents', + path.substring(WrapperPaths.documents.length), + ); + } + if (path.startsWith(WrapperPaths.blobs)) { + return _storedFile( + request, + 'blobs', + path.substring(WrapperPaths.blobs.length), + ); + } + return _static(request, path); + } + + /// GET returns the stored bytes (404 when absent); POST overwrites them. + /// + /// [name] is rejected unless it is a plain filename: these handlers write + /// into the user's home directory, so a `..` segment would turn a mirror + /// write into an arbitrary-file overwrite. + Future _storedFile( + HttpRequest request, + String subdirectory, + String name, + ) async { + if (name.isEmpty || name.contains('/') || name.contains('..')) { + request.response.statusCode = HttpStatus.badRequest; + return; + } + final file = File(p.join(dataDir, subdirectory, name)); + if (request.method == 'POST') { + await file.parent.create(recursive: true); + final bytes = [for (final chunk in await request.toList()) ...chunk]; + await file.writeAsBytes(bytes); + request.response.statusCode = HttpStatus.noContent; + return; + } + if (request.method != 'GET') { + request.response.statusCode = HttpStatus.methodNotAllowed; + return; + } + if (!file.existsSync()) { + request.response.statusCode = HttpStatus.notFound; + return; + } + request.response.headers.contentType = subdirectory == 'documents' + ? ContentType.json + : ContentType.binary; + await request.response.addStream(file.openRead()); + } + + Future _static(HttpRequest request, String path) async { + final relative = path == '/' ? 'index.html' : path.substring(1); + // Reject traversal before touching the filesystem: the served root sits + // next to the user's files, so `../` must not escape it. + final resolved = p.normalize(p.join(webRoot, relative)); + // coverage:ignore-start + // Defence in depth, and currently unreachable: Dart's HttpServer decodes + // and normalises the path before a handler runs, so even `%2e%2e` arrives + // already collapsed. Kept so the guarantee does not depend on that + // implementation detail holding. + if (!p.isWithin(webRoot, resolved) && resolved != p.normalize(webRoot)) { + request.response.statusCode = HttpStatus.forbidden; + return; + } + // coverage:ignore-end + final file = File(resolved); + if (!file.existsSync()) { + request.response.statusCode = HttpStatus.notFound; + return; + } + request.response.headers.contentType = contentTypeFor(resolved); + await request.response.addStream(file.openRead()); + } + + /// Content type for [filePath]. + /// + /// Flutter web is strict here: CanvasKit refuses to instantiate a `.wasm` + /// served as anything but `application/wasm`, and the app then renders + /// nothing at all. + static ContentType contentTypeFor(String filePath) { + switch (p.extension(filePath).toLowerCase()) { + case '.html': + return ContentType.html; + case '.js' || '.mjs': + return ContentType('text', 'javascript', charset: 'utf-8'); + case '.json': + return ContentType.json; + case '.wasm': + return ContentType('application', 'wasm'); + case '.css': + return ContentType('text', 'css', charset: 'utf-8'); + case '.png': + return ContentType('image', 'png'); + case '.jpg' || '.jpeg': + return ContentType('image', 'jpeg'); + case '.svg': + return ContentType('image', 'svg+xml'); + case '.ttf': + return ContentType('font', 'ttf'); + case '.otf': + return ContentType('font', 'otf'); + case '.woff2': + return ContentType('font', 'woff2'); + default: + return ContentType.binary; + } + } +} diff --git a/app/test/desktop/wrapper_server_test.dart b/app/test/desktop/wrapper_server_test.dart new file mode 100644 index 0000000..8223825 --- /dev/null +++ b/app/test/desktop/wrapper_server_test.dart @@ -0,0 +1,292 @@ +// The desktop wrapper is the other half of the web-build desktop app: it +// serves the assets, owns the on-disk mirror of the app's storage, and fronts +// GitHub. These tests drive it over a real loopback socket, since its +// contract is HTTP. +import 'dart:convert'; +import 'dart:io'; + +import 'package:diet_guard_app/desktop/github_proxy.dart'; +import 'package:diet_guard_app/desktop/wrapper_server.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:path/path.dart' as p; + +void main() { + late Directory tempDir; + late String webRoot; + late String dataDir; + late WrapperServer server; + late List outbound; + late Map canned; + + Future startServer() async { + outbound = []; + canned = {}; + final proxy = GitHubProxy( + tokenPath: p.join(tempDir.path, 'config', 'sync_token'), + fallbackTokenPath: p.join(tempDir.path, 'config', 'fallback_token'), + httpClient: MockClient((request) async { + outbound.add(request); + return canned[request.url.toString()] ?? + http.Response('{"ok":true}', 200); + }), + ); + server = WrapperServer( + webRoot: webRoot, + dataDir: dataDir, + gitHubProxy: proxy, + ); + await server.start(0); + } + + Uri url(String path) => Uri.parse('http://localhost:${server.port}$path'); + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_wrapper_'); + webRoot = p.join(tempDir.path, 'web'); + dataDir = p.join(tempDir.path, 'data'); + await Directory(webRoot).create(recursive: true); + File(p.join(webRoot, 'index.html')).writeAsStringSync('app'); + File(p.join(webRoot, 'main.wasm')).writeAsBytesSync([0, 97, 115, 109]); + await startServer(); + }); + + tearDown(() async { + await server.stop(); + await tempDir.delete(recursive: true); + }); + + group('static assets', () { + test('serves index.html at the root', () async { + final response = await http.get(url('/')); + + expect(response.statusCode, 200); + expect(response.body, 'app'); + expect(response.headers['content-type'], contains('text/html')); + }); + + test('serves .wasm as application/wasm', () async { + // CanvasKit refuses any other content type and the app then renders + // nothing at all -- the failure looks like a blank window, not an error. + final response = await http.get(url('/main.wasm')); + + expect(response.statusCode, 200); + expect(response.headers['content-type'], 'application/wasm'); + }); + + test('404s a missing asset', () async { + expect((await http.get(url('/nope.js'))).statusCode, 404); + }); + }); + + group('document mirror', () { + test('round-trips a document', () async { + final posted = await http.post( + url('/documents/food_log.json'), + body: '{"2026-07-20":[]}', + ); + final fetched = await http.get(url('/documents/food_log.json')); + + expect(posted.statusCode, 204); + expect(fetched.body, '{"2026-07-20":[]}'); + expect( + File(p.join(dataDir, 'documents', 'food_log.json')).readAsStringSync(), + '{"2026-07-20":[]}', + ); + }); + + test('404s an unmirrored document', () async { + expect((await http.get(url('/documents/food_bank.json'))).statusCode, 404); + }); + + test('rejects a traversing name instead of writing outside dataDir', () { + // The mirror writes into the user's home directory, so a `..` segment + // would turn a document write into an arbitrary-file overwrite. + expect( + http + .post(url('/documents/..%2F..%2Fevil'), body: 'x') + .then((r) => r.statusCode), + completion(400), + ); + }); + + test('rejects a method other than GET/POST', () async { + expect((await http.delete(url('/documents/x.json'))).statusCode, 405); + }); + }); + + group('blob mirror', () { + test('round-trips photo bytes', () async { + final bytes = [1, 2, 3, 4]; + final posted = await http.post(url('/blobs/photo.jpg'), body: bytes); + final fetched = await http.get(url('/blobs/photo.jpg')); + + expect(posted.statusCode, 204); + expect(fetched.bodyBytes, bytes); + }); + }); + + group('github proxy', () { + test('reports no token before one is stored', () async { + final response = await http.get(url('/github/auth/status')); + + expect(jsonDecode(response.body), {'configured': false}); + }); + + test('stores a pasted token and then reports configured', () async { + await http.post(url('/github/auth/token'), body: 'ghp_pasted'); + + final response = await http.get(url('/github/auth/status')); + + expect(jsonDecode(response.body), {'configured': true}); + expect( + File(p.join(tempDir.path, 'config', 'sync_token')).readAsStringSync(), + 'ghp_pasted', + ); + }); + + test('falls back to the PC gate token when it has none of its own', () async { + // The Python side already keeps a token for diet-guard-sync.timer; + // reusing it is what makes the desktop app need no second setup. + final fallback = File(p.join(tempDir.path, 'config', 'fallback_token')) + ..createSync(recursive: true) + ..writeAsStringSync('ghp_from_gate\n'); + addTearDown(fallback.deleteSync); + + final response = await http.get(url('/github/auth/status')); + + expect(jsonDecode(response.body), {'configured': true}); + }); + + test('attaches the token to a proxied API call', () async { + await http.post(url('/github/auth/token'), body: 'ghp_stored'); + canned['https://api.github.com/repos/kuhyx/syncs'] = http.Response( + '{"name":"syncs"}', + 200, + ); + + final response = await http.get(url('/github/api/repos/kuhyx/syncs')); + + expect(response.body, '{"name":"syncs"}'); + expect(outbound.single.headers['Authorization'], 'Bearer ghp_stored'); + }); + + test('keeps a device-flow token server-side', () async { + canned['https://github.com/login/oauth/access_token'] = http.Response( + '{"access_token":"ghp_device"}', + 200, + ); + + final response = await http.post( + url('/github/auth/device/poll'), + body: jsonEncode({'client_id': 'cid', 'device_code': 'dc'}), + ); + + // The point of the proxy: the browser is told "ok", never the token. + expect(jsonDecode(response.body), {'status': 'ok'}); + expect(response.body, isNot(contains('ghp_device'))); + expect( + File(p.join(tempDir.path, 'config', 'sync_token')).readAsStringSync(), + 'ghp_device', + ); + }); + + test('passes a still-pending device-flow poll straight back', () async { + canned['https://github.com/login/oauth/access_token'] = http.Response( + '{"error":"authorization_pending"}', + 200, + ); + + final response = await http.post( + url('/github/auth/device/poll'), + body: jsonEncode({'client_id': 'cid', 'device_code': 'dc'}), + ); + + expect(jsonDecode(response.body), {'error': 'authorization_pending'}); + }); + + test('forwards the device-code request with the client id', () async { + canned['https://github.com/login/device/code'] = http.Response( + '{"device_code":"dc","user_code":"ABCD-1234"}', + 200, + ); + + final response = await http.post( + url('/github/auth/device/start'), + body: jsonEncode({'client_id': 'cid'}), + ); + + expect(jsonDecode(response.body), containsPair('user_code', 'ABCD-1234')); + expect(outbound.single.bodyFields['client_id'], 'cid'); + }); + + test('404s an unknown /github path', () async { + expect((await http.get(url('/github/nope'))).statusCode, 404); + }); + + test('clears the stored token when handed an empty one', () async { + await http.post(url('/github/auth/token'), body: 'ghp_stored'); + + await http.post(url('/github/auth/token'), body: ''); + + expect(jsonDecode((await http.get(url('/github/auth/status'))).body), { + 'configured': false, + }); + }); + + test('ignores an unreadable token file rather than failing', () async { + // A directory where the token file should be: readToken must fall + // through to "no token" instead of throwing out of every request. + Directory(p.join(tempDir.path, 'config', 'sync_token')) + .createSync(recursive: true); + + expect(jsonDecode((await http.get(url('/github/auth/status'))).body), { + 'configured': false, + }); + }); + + test('passes a non-JSON device-flow response straight back', () async { + canned['https://github.com/login/oauth/access_token'] = http.Response( + 'rate limited', + 200, + ); + + final response = await http.post( + url('/github/auth/device/poll'), + body: jsonEncode({'client_id': 'cid', 'device_code': 'dc'}), + ); + + expect(response.body, contains('rate limited')); + }); + + test('treats a malformed request body as empty fields', () async { + final response = await http.post( + url('/github/auth/device/start'), + body: 'not json', + ); + + expect(response.statusCode, 200); + expect(outbound.single.bodyFields['client_id'], 'null'); + }); + }); + + group('content types', () { + test('labels each asset kind the browser is strict about', () { + ContentType typeOf(String name) => + WrapperServer.contentTypeFor('/web/$name'); + + expect(typeOf('main.dart.js').toString(), contains('text/javascript')); + expect(typeOf('m.mjs').toString(), contains('text/javascript')); + expect(typeOf('manifest.json').mimeType, 'application/json'); + expect(typeOf('style.css').toString(), contains('text/css')); + expect(typeOf('icon.png').mimeType, 'image/png'); + expect(typeOf('photo.jpg').mimeType, 'image/jpeg'); + expect(typeOf('logo.svg').mimeType, 'image/svg+xml'); + expect(typeOf('f.ttf').mimeType, 'font/ttf'); + expect(typeOf('f.otf').mimeType, 'font/otf'); + expect(typeOf('f.woff2').mimeType, 'font/woff2'); + expect(typeOf('data.bin').mimeType, 'application/octet-stream'); + }); + }); +}