mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 17:47:24 +01:00
github_device_auth: extract the GitHub device flow from four apps
todo, home_inventory, diet-guard and wake-alarm each carried the OAuth device-flow client and the modal that drives it -- ~650 lines, with two pairs byte-for-byte identical (todo/home_inventory's clients, and diet-guard/wake-alarm's dialogs). Deliberately its own package rather than part of `sync_settings_ui`. That package is Firebase-only by an explicit 2026-08-12 decision recorded as "do not re-add a GitHub section without kuhy asking again", so folding a GitHub flow into it would reverse a standing decision. The GitHub mirror in these apps is legacy-but-load-bearing code that was explicitly not authorised for deletion, so it gets its own home instead of being merged into the Firebase path or dropped. One behaviour change, made once here instead of four times: the dialog catches `on Object`, not `on Exception`. An `Error` is not an `Exception` in Dart, so `ArgumentError` -- notably from URI parsing -- escaped `_poll()` and left the dialog spinning on "Waiting for authorization…" forever with nothing shown. That is the same trap that silently killed the Firebase sync tick across these apps in 2026-08. A test pins it with a mock that throws an Error rather than an Exception, so it cannot regress back to `on Exception` unnoticed. 20 tests, 100% line coverage, clean under very_good_analysis, with the same CI gate the other Dart package holds. Consumers are migrated separately so each app's own suite gates its own swap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
This commit is contained in:
parent
41f014e7a7
commit
795a8eae62
36
.github/workflows/github-device-auth-tests.yml
vendored
Normal file
36
.github/workflows/github-device-auth-tests.yml
vendored
Normal file
@ -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
|
||||
3
github_device_auth/.gitignore
vendored
Normal file
3
github_device_auth/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
build/
|
||||
coverage/
|
||||
.dart_tool/
|
||||
69
github_device_auth/README.md
Normal file
69
github_device_auth/README.md
Normal file
@ -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: '<oauth app client id>');
|
||||
final device = await auth.requestDeviceCode();
|
||||
final token = await showDialog<String>(
|
||||
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
|
||||
```
|
||||
38
github_device_auth/analysis_options.yaml
Normal file
38
github_device_auth/analysis_options.yaml
Normal file
@ -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
|
||||
22
github_device_auth/lib/github_device_auth.dart
Normal file
22
github_device_auth/lib/github_device_auth.dart
Normal file
@ -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;
|
||||
154
github_device_auth/lib/src/device_auth.dart
Normal file
154
github_device_auth/lib/src/device_auth.dart
Normal file
@ -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<String, dynamic> 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<void> Function(Duration)? delay,
|
||||
}) : _http = httpClient ?? http.Client(),
|
||||
// Indirection so tests can skip real waiting between polls.
|
||||
_delay = delay ?? Future<void>.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<void> 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<DeviceCodeResponse> 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<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
/// 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<String> 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<String, dynamic>;
|
||||
|
||||
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();
|
||||
}
|
||||
112
github_device_auth/lib/src/device_code_dialog.dart
Normal file
112
github_device_auth/lib/src/device_code_dialog.dart
Normal file
@ -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<DeviceCodeDialog> createState() => _DeviceCodeDialogState();
|
||||
}
|
||||
|
||||
class _DeviceCodeDialogState extends State<DeviceCodeDialog> {
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_poll());
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
306
github_device_auth/pubspec.lock
Normal file
306
github_device_auth/pubspec.lock
Normal file
@ -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"
|
||||
23
github_device_auth/pubspec.yaml
Normal file
23
github_device_auth/pubspec.yaml
Normal file
@ -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:
|
||||
63
github_device_auth/scripts/check_coverage.sh
Executable file
63
github_device_auth/scripts/check_coverage.sh
Executable file
@ -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 <lcov.info> <min_percent>
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
readonly SCRIPT_NAME
|
||||
|
||||
usage() {
|
||||
echo "Usage: $SCRIPT_NAME <lcov.info> <min_percent>"
|
||||
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
|
||||
231
github_device_auth/test/device_auth_test.dart
Normal file
231
github_device_auth/test/device_auth_test.dart
Normal file
@ -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<void> 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<DeviceAuthException>()
|
||||
.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<DeviceAuthException>()
|
||||
.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<DeviceAuthException>().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<DeviceAuthException>().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<DeviceAuthException>()
|
||||
.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');
|
||||
});
|
||||
}
|
||||
169
github_device_auth/test/device_code_dialog_test.dart
Normal file
169
github_device_auth/test/device_code_dialog_test.dart
Normal file
@ -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<void> _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<void> _neverDelay(Duration _) => Completer<void>().future;
|
||||
|
||||
GitHubDeviceAuth _auth(
|
||||
MockClient client, {
|
||||
Future<void> 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<Object? Function()> _showDialog(
|
||||
WidgetTester tester,
|
||||
GitHubDeviceAuth auth,
|
||||
) async {
|
||||
Object? popped;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => ElevatedButton(
|
||||
onPressed: () async {
|
||||
popped = await showDialog<String>(
|
||||
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<Object?, Object?>)['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');
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user