diff --git a/.github/workflows/github-device-auth-tests.yml b/.github/workflows/github-device-auth-tests.yml new file mode 100644 index 0000000..a91fe3f --- /dev/null +++ b/.github/workflows/github-device-auth-tests.yml @@ -0,0 +1,36 @@ +name: github_device_auth tests + +on: + push: + branches: [main] + paths: ['github_device_auth/**'] + pull_request: + branches: [main] + paths: ['github_device_auth/**'] + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: github_device_auth + + steps: + - uses: actions/checkout@v4 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: Install dependencies + run: flutter pub get + + - name: Analyze + run: flutter analyze + + - name: Run tests with coverage + run: flutter test --coverage + + - name: Enforce 100% line coverage + run: scripts/check_coverage.sh coverage/lcov.info 100 diff --git a/github_device_auth/.gitignore b/github_device_auth/.gitignore new file mode 100644 index 0000000..cd59cbc --- /dev/null +++ b/github_device_auth/.gitignore @@ -0,0 +1,3 @@ +build/ +coverage/ +.dart_tool/ diff --git a/github_device_auth/README.md b/github_device_auth/README.md new file mode 100644 index 0000000..c38b956 --- /dev/null +++ b/github_device_auth/README.md @@ -0,0 +1,69 @@ +# `github_device_auth` + +GitHub OAuth **Device Flow** for kuhy's Flutter apps: the auth client and the +modal that drives it. + +Device flow needs only a public `client_id` (no client secret), which is what +makes it safe for a distributed app. The resulting access token is then used +exactly like a PAT. + +## Why this is its own package + +Four apps each carried a copy of both halves: + +| App | Client | Dialog | +| --- | --- | --- | +| todo | `lib/sync/github_device_auth.dart` (154) | `lib/ui/device_code_dialog.dart` (110) | +| home_inventory | `lib/sync/github_device_auth.dart` (154, **byte-identical** to todo's) | inline | +| diet-guard | `app/lib/services/github_device_auth.dart` (175) | inline (~84, **byte-identical** to wake-alarm's) | +| wake-alarm | `phone_app/lib/services/github_device_auth.dart` (166) | inline (~84) | + +~650 lines, two pairs of them byte-for-byte identical. + +**It is deliberately not part of `sync_settings_ui`.** That package is +Firebase-only by an explicit decision (2026-08-12), recorded with "do not +re-add a GitHub section without kuhy asking again". Folding a GitHub flow into +it would reverse that. The GitHub mirror in these apps is legacy-but- +load-bearing code, so it gets its own home rather than being merged into the +Firebase path — or deleted, which was never authorised. + +## Use + +```yaml +dependencies: + github_device_auth: + git: + url: https://github.com/kuhyx/utils + ref: github_device_auth-v0.1.0 + path: github_device_auth +``` + +```dart +final auth = GitHubDeviceAuth(clientId: ''); +final device = await auth.requestDeviceCode(); +final token = await showDialog( + context: context, + builder: (_) => DeviceCodeDialog(device: device, auth: auth), +); +auth.close(); +``` + +## One behaviour change from the extracted copies + +The dialog now catches `on Object`, not `on Exception`. + +An `Error` is not an `Exception` in Dart, so `on Exception` let `ArgumentError` +(notably from URI parsing) escape `_poll()` — leaving the dialog spinning on +"Waiting for authorization…" forever with nothing shown to the user. This is +the same trap that silently killed the Firebase sync tick across these apps in +2026-08. Fixed once here rather than four times. `test/device_code_dialog_test.dart` +pins it with a mock that throws an `Error` rather than an `Exception`. + +## Commands + +```bash +flutter pub get +flutter test # 20 tests +flutter test --coverage # 100% of lines +flutter analyze # very_good_analysis, clean +``` diff --git a/github_device_auth/analysis_options.yaml b/github_device_auth/analysis_options.yaml new file mode 100644 index 0000000..286e24c --- /dev/null +++ b/github_device_auth/analysis_options.yaml @@ -0,0 +1,38 @@ +include: package:very_good_analysis/analysis_options.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + errors: + # Promote key lints to errors so they fail CI, not just warn. + missing_required_param: error + unnecessary_null_comparison: error + dead_code: error + invalid_annotation_target: error + exclude: + - build/** + - "**/*.g.dart" + - "**/*.freezed.dart" + - "test/**" + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** + +linter: + rules: + # very_good_analysis enables most rules; add extras it doesn't include. + always_use_package_imports: true + avoid_print: true + avoid_relative_lib_imports: true + cancel_subscriptions: true + close_sinks: true + comment_references: false # conflicts with typical Flutter API docs style + directives_ordering: true + lines_longer_than_80_chars: true + public_member_api_docs: true + unawaited_futures: true diff --git a/github_device_auth/lib/github_device_auth.dart b/github_device_auth/lib/github_device_auth.dart new file mode 100644 index 0000000..102bf83 --- /dev/null +++ b/github_device_auth/lib/github_device_auth.dart @@ -0,0 +1,22 @@ +/// GitHub OAuth **Device Flow** for kuhy's Flutter apps: the auth client and +/// the modal that drives it. +/// +/// Four apps (todo, home_inventory, diet-guard, wake-alarm) each carried a +/// copy of both halves — the todo and home_inventory clients were +/// byte-identical, as were the diet-guard and wake-alarm dialogs. This +/// package is that shared copy. +/// +/// Device flow needs only a public `client_id` (no client secret), which is +/// what makes it safe for a distributed app; the resulting access token is +/// then used exactly like a PAT. +/// +/// Deliberately **not** part of `sync_settings_ui`: that package is +/// Firebase-only by an explicit 2026-08-12 decision, and folding a GitHub +/// flow into it would reverse that. The GitHub mirror in these apps is +/// legacy-but-load-bearing code, so it gets its own home rather than being +/// merged into the Firebase path or deleted. +library; + +export 'src/device_auth.dart' + show DeviceAuthException, DeviceCodeResponse, GitHubDeviceAuth; +export 'src/device_code_dialog.dart' show DeviceCodeDialog; diff --git a/github_device_auth/lib/src/device_auth.dart b/github_device_auth/lib/src/device_auth.dart new file mode 100644 index 0000000..f22ca5c --- /dev/null +++ b/github_device_auth/lib/src/device_auth.dart @@ -0,0 +1,154 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +/// First-stage response of the GitHub OAuth Device Flow: the code the user +/// types on github.com and the URL to type it into. +class DeviceCodeResponse { + /// Creates a [DeviceCodeResponse] from its stored fields. + const DeviceCodeResponse({ + required this.deviceCode, + required this.userCode, + required this.verificationUri, + required this.interval, + required this.expiresIn, + }); + + /// Builds a [DeviceCodeResponse] from GitHub's device-code JSON response. + factory DeviceCodeResponse.fromJson(Map json) { + return DeviceCodeResponse( + deviceCode: json['device_code'] as String, + userCode: json['user_code'] as String, + verificationUri: json['verification_uri'] as String, + interval: (json['interval'] as int?) ?? 5, + expiresIn: (json['expires_in'] as int?) ?? 900, + ); + } + + /// Opaque code the client polls with (not shown to the user). + final String deviceCode; + + /// Short code the user enters on the verification page. + final String userCode; + + /// Page the user opens to enter [userCode] (github.com/login/device). + final String verificationUri; + + /// Minimum seconds to wait between polls. + final int interval; + + /// Seconds until [deviceCode] expires. + final int expiresIn; +} + +/// Raised when the device-flow authorization fails or is declined. +class DeviceAuthException implements Exception { + /// Creates a [DeviceAuthException] from GitHub's [code] and [message]. + DeviceAuthException(this.code, this.message); + + /// GitHub error code, e.g. `access_denied`, `expired_token`. + final String code; + + /// Human-readable description of [code]. + final String message; + + @override + String toString() => 'DeviceAuthException($code): $message'; +} + +/// Implements the GitHub OAuth **Device Flow** so the user can authorize the +/// app by visiting a URL and entering a short code — no token pasting. +/// +/// Device flow needs only a public `client_id` (no client secret), which +/// makes it safe for a distributed app. The resulting access token is then +/// used exactly like a PAT by [GitHubClient]. +/// +/// References: +/// - POST https://github.com/login/device/code +/// - POST https://github.com/login/oauth/access_token +class GitHubDeviceAuth { + /// Creates a [GitHubDeviceAuth] for the given OAuth App [clientId]. + GitHubDeviceAuth({ + required this.clientId, + this.scope = 'repo', + http.Client? httpClient, + Future Function(Duration)? delay, + }) : _http = httpClient ?? http.Client(), + // Indirection so tests can skip real waiting between polls. + _delay = delay ?? Future.delayed; + + /// The GitHub OAuth App's public client id. + final String clientId; + + /// OAuth scope requested. `repo` is required for private-repo contents. + final String scope; + + final http.Client _http; + final Future Function(Duration) _delay; + + static const _deviceCodeUrl = 'https://github.com/login/device/code'; + static const _tokenUrl = 'https://github.com/login/oauth/access_token'; + static const _grantType = 'urn:ietf:params:oauth:grant-type:device_code'; + + /// Step 1: ask GitHub for a device + user code. + Future requestDeviceCode() async { + final res = await _http.post( + Uri.parse(_deviceCodeUrl), + headers: const {'Accept': 'application/json'}, + body: {'client_id': clientId, 'scope': scope}, + ); + if (res.statusCode != 200) { + throw DeviceAuthException('http_${res.statusCode}', res.body); + } + return DeviceCodeResponse.fromJson( + jsonDecode(res.body) as Map, + ); + } + + /// Step 2: poll until the user authorizes, returning the access token. + /// + /// Honors GitHub's pacing protocol: `authorization_pending` keeps polling, + /// `slow_down` increases the interval, and terminal errors throw a + /// [DeviceAuthException]. + Future pollForToken(DeviceCodeResponse device) async { + var intervalSeconds = device.interval; + final deadline = DateTime.now().add(Duration(seconds: device.expiresIn)); + + while (DateTime.now().isBefore(deadline)) { + await _delay(Duration(seconds: intervalSeconds)); + final res = await _http.post( + Uri.parse(_tokenUrl), + headers: const {'Accept': 'application/json'}, + body: { + 'client_id': clientId, + 'device_code': device.deviceCode, + 'grant_type': _grantType, + }, + ); + final json = jsonDecode(res.body) as Map; + + final token = json['access_token'] as String?; + if (token != null) return token; + + switch (json['error'] as String?) { + case 'authorization_pending': + continue; // User has not finished authorizing yet. + case 'slow_down': + // GitHub asks us to back off; obey its new interval. + intervalSeconds = (json['interval'] as int?) ?? intervalSeconds + 5; + case final String error: + throw DeviceAuthException( + error, + (json['error_description'] as String?) ?? error, + ); + case null: + throw DeviceAuthException('unknown', 'Unexpected response: $json'); + } + } + throw DeviceAuthException('expired_token', 'Device code expired.'); + } + + /// Closes the underlying HTTP client. Call once when done polling. + void close() => _http.close(); +} diff --git a/github_device_auth/lib/src/device_code_dialog.dart b/github_device_auth/lib/src/device_code_dialog.dart new file mode 100644 index 0000000..63f05d3 --- /dev/null +++ b/github_device_auth/lib/src/device_code_dialog.dart @@ -0,0 +1,112 @@ +/// The GitHub device-flow dialog: shows the user code, opens the page, and +/// polls until the grant is approved. +library; + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:github_device_auth/src/device_auth.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// Dialog shown during the device flow: displays the user code, opens the +/// verification page, and polls until authorized — popping the token (or +/// null if cancelled / failed). +class DeviceCodeDialog extends StatefulWidget { + /// Creates the dialog for a pending device-flow grant. + const DeviceCodeDialog({ + required this.device, + required this.auth, + super.key, + }); + + /// The pending grant: user code, verification URL, poll interval. + final DeviceCodeResponse device; + + /// Auth client used to poll for the approved token. + final GitHubDeviceAuth auth; + + @override + State createState() => _DeviceCodeDialogState(); +} + +class _DeviceCodeDialogState extends State { + String? _error; + + @override + void initState() { + super.initState(); + unawaited(_poll()); + } + + Future _poll() async { + try { + final token = await widget.auth.pollForToken(widget.device); + if (mounted) Navigator.of(context).pop(token); + } + // `on Object`, not `on Exception`: an `Error` (notably `ArgumentError` + // from URI parsing) is not an `Exception`, so `on Exception` lets it + // escape and the dialog spins on "Waiting for authorization…" forever + // with nothing shown to the user. The same trap silently killed the + // Firebase sync tick in these apps -- see the 2026-08-11 fix. + on Object catch (e) { + if (mounted) setState(() => _error = '$e'); + } + } + + Future _openPage() async { + await Clipboard.setData(ClipboardData(text: widget.device.userCode)); + await launchUrl( + Uri.parse(widget.device.verificationUri), + mode: LaunchMode.externalApplication, + ); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Authorize on GitHub'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Enter this code on GitHub:'), + const SizedBox(height: 8), + SelectableText( + widget.device.userCode, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 16), + if (_error == null) + const Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 12), + Expanded(child: Text('Waiting for authorization…')), + ], + ) + else + Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton.icon( + onPressed: _openPage, + icon: const Icon(Icons.open_in_new), + label: const Text('Open GitHub & copy code'), + ), + ], + ); + } +} diff --git a/github_device_auth/pubspec.lock b/github_device_auth/pubspec.lock new file mode 100644 index 0000000..f7273e3 --- /dev/null +++ b/github_device_auth/pubspec.lock @@ -0,0 +1,306 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" + source: hosted + version: "1.19.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 + url: "https://pub.dev" + source: hosted + version: "6.3.32" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + very_good_analysis: + dependency: "direct dev" + description: + name: very_good_analysis + sha256: "481af67ab5877af20325251dc215a4ebac7666a1c8cf09198ffd457bc612b33d" + url: "https://pub.dev" + source: hosted + version: "10.3.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" +sdks: + dart: ">=3.12.2 <4.0.0" + flutter: ">=3.44.0" diff --git a/github_device_auth/pubspec.yaml b/github_device_auth/pubspec.yaml new file mode 100644 index 0000000..abba60c --- /dev/null +++ b/github_device_auth/pubspec.yaml @@ -0,0 +1,23 @@ +name: github_device_auth +description: >- + GitHub OAuth Device Flow for kuhy's Flutter apps: the auth client and the + modal that drives it. Extracted from four apps that each carried a copy, + two of them byte-identical. +publish_to: 'none' +version: 0.1.0 + +environment: + sdk: ^3.12.2 + +dependencies: + flutter: + sdk: flutter + http: ^1.2.0 + url_launcher: ^6.3.0 + +dev_dependencies: + flutter_test: + sdk: flutter + very_good_analysis: ^10.2.0 + +flutter: diff --git a/github_device_auth/scripts/check_coverage.sh b/github_device_auth/scripts/check_coverage.sh new file mode 100755 index 0000000..2c0ee6f --- /dev/null +++ b/github_device_auth/scripts/check_coverage.sh @@ -0,0 +1,63 @@ +#!/bin/bash + +# ============================================================================ +# Fail if lcov line coverage drops below a threshold. +# +# No Dart package in this repo has coverage enforced in CI yet -- crdt_sync_dart +# sits at 100% by discipline, not a gate. This script is that gate for +# sync_settings_ui, which ships as a 100%-covered shared UI package by +# requirement, not by convention. +# +# Usage: check_coverage.sh +# ============================================================================ + +set -euo pipefail + +SCRIPT_NAME="$(basename "$0")" +readonly SCRIPT_NAME + +usage() { + echo "Usage: $SCRIPT_NAME " + echo " Fails when line coverage in lcov.info is below min_percent." + exit 0 +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage +fi + +if [[ $# -ne 2 ]]; then + usage +fi + +lcov_file="$1" +min_percent="$2" + +if [[ ! -f "$lcov_file" ]]; then + echo "Error: $lcov_file not found. Run 'flutter test --coverage' first." >&2 + exit 1 +fi + +# Sum LH (lines hit) and LF (lines found) across every SF (source file) +# block rather than trusting a single top-level total -- lcov.info has no +# such total line, only per-file LH/LF pairs. +read -r hit found <<< "$(awk ' + /^LH:/ { hit += substr($0, 4) } + /^LF:/ { found += substr($0, 4) } + END { print hit, found } +' "$lcov_file")" + +if [[ "$found" -eq 0 ]]; then + echo "Error: $lcov_file reports zero coverable lines." >&2 + exit 1 +fi + +percent=$(awk -v h="$hit" -v f="$found" 'BEGIN { printf "%.2f", (h / f) * 100 }') +meets_threshold=$(awk -v p="$percent" -v m="$min_percent" 'BEGIN { print (p + 0 >= m + 0) ? 1 : 0 }') + +echo "Line coverage: $hit/$found ($percent%), threshold: $min_percent%" + +if [[ "$meets_threshold" -ne 1 ]]; then + echo "Error: coverage $percent% is below the required $min_percent%." >&2 + exit 1 +fi diff --git a/github_device_auth/test/device_auth_test.dart b/github_device_auth/test/device_auth_test.dart new file mode 100644 index 0000000..a5ed488 --- /dev/null +++ b/github_device_auth/test/device_auth_test.dart @@ -0,0 +1,231 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:github_device_auth/github_device_auth.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +/// Never actually waits: the poll loop's pacing is injected so a test that +/// exercises `slow_down` does not take 15 real seconds. +Future noDelay(Duration _) async {} + +GitHubDeviceAuth authWith( + MockClient client, { + String scope = 'repo', +}) => GitHubDeviceAuth( + clientId: 'cid', + scope: scope, + httpClient: client, + delay: noDelay, +); + +void main() { + group('DeviceCodeResponse.fromJson', () { + test('reads every field', () { + final r = DeviceCodeResponse.fromJson(const { + 'device_code': 'dc', + 'user_code': 'UC-1', + 'verification_uri': 'https://github.com/login/device', + 'interval': 7, + 'expires_in': 100, + }); + expect(r.deviceCode, 'dc'); + expect(r.userCode, 'UC-1'); + expect(r.verificationUri, 'https://github.com/login/device'); + expect(r.interval, 7); + expect(r.expiresIn, 100); + }); + + test('defaults interval and expiry when GitHub omits them', () { + final r = DeviceCodeResponse.fromJson(const { + 'device_code': 'dc', + 'user_code': 'UC', + 'verification_uri': 'u', + }); + expect(r.interval, 5); + expect(r.expiresIn, 900); + }); + }); + + test('DeviceAuthException stringifies code and message', () { + expect( + DeviceAuthException('access_denied', 'nope').toString(), + 'DeviceAuthException(access_denied): nope', + ); + }); + + group('requestDeviceCode', () { + test('posts the client id and scope, and parses the response', () async { + late http.Request seen; + final auth = authWith( + MockClient((req) async { + seen = req; + return http.Response( + jsonEncode({ + 'device_code': 'dc', + 'user_code': 'UC', + 'verification_uri': 'u', + }), + 200, + ); + }), + scope: 'repo,gist', + ); + + final device = await auth.requestDeviceCode(); + + expect(seen.url.toString(), 'https://github.com/login/device/code'); + expect(seen.bodyFields['client_id'], 'cid'); + expect(seen.bodyFields['scope'], 'repo,gist'); + expect(device.userCode, 'UC'); + }); + + test('throws with the status code on a non-200', () async { + final auth = authWith(MockClient((_) async => http.Response('bad', 503))); + await expectLater( + auth.requestDeviceCode(), + throwsA( + isA() + .having((e) => e.code, 'code', 'http_503') + .having((e) => e.message, 'message', 'bad'), + ), + ); + }); + }); + + group('pollForToken', () { + const device = DeviceCodeResponse( + deviceCode: 'dc', + userCode: 'UC', + verificationUri: 'u', + interval: 1, + expiresIn: 60, + ); + + test('returns the token once the user authorizes', () async { + var calls = 0; + final auth = authWith( + MockClient((_) async { + calls += 1; + // First poll pending, second succeeds -- the normal path. + return http.Response( + calls == 1 + ? jsonEncode({'error': 'authorization_pending'}) + : jsonEncode({'access_token': 'tok'}), + 200, + ); + }), + ); + expect(await auth.pollForToken(device), 'tok'); + expect(calls, 2); + }); + + test('obeys slow_down by taking GitHub s new interval', () async { + var calls = 0; + final auth = authWith( + MockClient((_) async { + calls += 1; + return http.Response( + calls == 1 + ? jsonEncode({'error': 'slow_down', 'interval': 30}) + : jsonEncode({'access_token': 'tok'}), + 200, + ); + }), + ); + expect(await auth.pollForToken(device), 'tok'); + }); + + test('slow_down without an interval still backs off', () async { + var calls = 0; + final auth = authWith( + MockClient((_) async { + calls += 1; + return http.Response( + calls == 1 + ? jsonEncode({'error': 'slow_down'}) + : jsonEncode({'access_token': 'tok'}), + 200, + ); + }), + ); + expect(await auth.pollForToken(device), 'tok'); + }); + + test('throws on a terminal error', () async { + final auth = authWith( + MockClient( + (_) async => http.Response( + jsonEncode({ + 'error': 'access_denied', + 'error_description': 'user said no', + }), + 200, + ), + ), + ); + await expectLater( + auth.pollForToken(device), + throwsA( + isA() + .having((e) => e.code, 'code', 'access_denied') + .having((e) => e.message, 'message', 'user said no'), + ), + ); + }); + + test('falls back to the code when no description is given', () async { + final auth = authWith( + MockClient( + (_) async => http.Response(jsonEncode({'error': 'expired'}), 200), + ), + ); + await expectLater( + auth.pollForToken(device), + throwsA( + isA().having((e) => e.message, 'msg', 'expired'), + ), + ); + }); + + test('throws on a response with neither a token nor an error', () async { + final auth = authWith( + MockClient((_) async => http.Response(jsonEncode({'x': 1}), 200)), + ); + await expectLater( + auth.pollForToken(device), + throwsA( + isA().having((e) => e.code, 'code', 'unknown'), + ), + ); + }); + + test('gives up once the device code has expired', () async { + const expired = DeviceCodeResponse( + deviceCode: 'dc', + userCode: 'UC', + verificationUri: 'u', + interval: 1, + // Already in the past, so the loop body never runs. + expiresIn: -1, + ); + final auth = authWith(MockClient((_) async => http.Response('{}', 200))); + await expectLater( + auth.pollForToken(expired), + throwsA( + isA() + .having((e) => e.code, 'code', 'expired_token'), + ), + ); + }); + }); + + test('close() is safe to call', () { + authWith(MockClient((_) async => http.Response('{}', 200))).close(); + }); + + test('constructs with a real http client and default scope', () { + final auth = GitHubDeviceAuth(clientId: 'cid')..close(); + expect(auth.scope, 'repo'); + }); +} diff --git a/github_device_auth/test/device_code_dialog_test.dart b/github_device_auth/test/device_code_dialog_test.dart new file mode 100644 index 0000000..0fee81d --- /dev/null +++ b/github_device_auth/test/device_code_dialog_test.dart @@ -0,0 +1,169 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:github_device_auth/github_device_auth.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +const _device = DeviceCodeResponse( + deviceCode: 'dc', + userCode: 'ABCD-1234', + verificationUri: 'https://github.com/login/device', + interval: 1, + expiresIn: 60, +); + +/// Completes immediately -- for tests whose mock resolves on the first poll. +Future _noDelay(Duration _) async {} + +/// Never completes, so the poll loop suspends on its first delay. +/// +/// The pending-state tests need the dialog to sit in "waiting" without +/// actually looping: with an instantly-completing delay and a mock that +/// always answers `authorization_pending`, `pollForToken` spins as fast as +/// the event loop allows and starves the test binding. +Future _neverDelay(Duration _) => Completer().future; + +GitHubDeviceAuth _auth( + MockClient client, { + Future Function(Duration) delay = _noDelay, +}) => GitHubDeviceAuth(clientId: 'cid', httpClient: client, delay: delay); + +/// An auth client that never gets past its first poll delay. +GitHubDeviceAuth _pendingAuth() => _auth( + MockClient( + (_) async => http.Response(jsonEncode({'error': 'authorization_pending'}), 200), + ), + delay: _neverDelay, +); + +/// Opens the dialog and returns a getter for whatever it eventually pops. +/// +/// The result is read through a closure rather than returned directly +/// because the dialog may still be polling when this returns -- a test that +/// cares about the popped value pumps first, then reads. +Future _showDialog( + WidgetTester tester, + GitHubDeviceAuth auth, +) async { + Object? popped; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => ElevatedButton( + onPressed: () async { + popped = await showDialog( + context: context, + builder: (_) => DeviceCodeDialog(device: _device, auth: auth), + ); + }, + child: const Text('open'), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pump(); + return () => popped; +} + +void main() { + testWidgets('shows the user code and a waiting state', (tester) async { + await _showDialog(tester, _pendingAuth()); + + expect(find.text('Authorize on GitHub'), findsOneWidget); + expect(find.text('ABCD-1234'), findsOneWidget); + expect(find.text('Waiting for authorization…'), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('pops the token once authorized', (tester) async { + final auth = _auth( + MockClient( + (_) async => http.Response(jsonEncode({'access_token': 'tok'}), 200), + ), + ); + + final popped = await _showDialog(tester, auth); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsNothing); + expect(popped(), 'tok'); + }); + + testWidgets('shows the error instead of spinning forever', (tester) async { + final auth = _auth( + MockClient( + (_) async => http.Response( + jsonEncode({ + 'error': 'access_denied', + 'error_description': 'user declined', + }), + 200, + ), + ), + ); + + await _showDialog(tester, auth); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.textContaining('access_denied'), findsOneWidget); + }); + + testWidgets('surfaces an Error, not just an Exception', (tester) async { + // The regression this dialog's `on Object catch` exists for: an + // `ArgumentError` is an Error, not an Exception, so `on Exception` would + // let it escape and leave the dialog spinning with nothing shown. + final auth = _auth( + MockClient((_) async => throw ArgumentError('No host specified in URI')), + ); + + await _showDialog(tester, auth); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.textContaining('No host specified'), findsOneWidget); + }); + + testWidgets('cancel pops without a token', (tester) async { + final popped = await _showDialog(tester, _pendingAuth()); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsNothing); + expect(popped(), isNull); + }); + + testWidgets('the action button copies the code to the clipboard', ( + tester, + ) async { + final auth = _pendingAuth(); + + String? copied; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + copied = + ((call.arguments as Map)['text'] as String?); + } + return null; + }, + ); + // url_launcher would otherwise hit a missing plugin on the test binding. + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/url_launcher'), + (_) async => true, + ); + + await _showDialog(tester, auth); + await tester.tap(find.text('Open GitHub & copy code')); + await tester.pump(); + + expect(copied, 'ABCD-1234'); + }); +}