diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index d30eb806..a7e81517 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -41,6 +41,13 @@ jobs: env: TEST_PRESET: all CHROME_FLAGS: --headless --no-sandbox --disable-dev-shm-usage --disable-gpu + # jose_plus's crypto-executor suites (keygen/sign/encrypt) take ~55min + # per browser under dart2js's ~200x slower BigInt (#371); while they + # run, other suites' loads starve. Exclude them (tagged slow-web-crypto, + # see packages/jose_plus/dart_test.yaml) from PR web runs only -- VM + # runs always execute every tag, and main-branch pushes keep full web + # crypto signal. + WEB_EXCLUDE_TAGS: ${{ github.event_name == 'pull_request' && 'slow-web-crypto' || '' }} steps: - name: Checkout Code uses: actions/checkout@v7 @@ -64,9 +71,36 @@ jobs: # melos run pana - name: "[Verify step] Test Dart packages [VM]" run: melos run test:vm + # PRs: browser-test only the packages with genuinely platform-divergent + # lib code (js-interop) -- everything else duplicates VM signal at the + # cost of one dart2js compile per test file (the old full sweep spent + # most of an hour per browser per leg compiling). Pushes to main run + # the full browser sweep. + # timeout-minutes on the browser steps is the backstop of last resort: + # package:test's --timeout and --suite-load-timeout only bound test + # execution and suite loads, and a browser wedge can strike OUTSIDE + # both (run 29148902320: a Firefox step hung 57+ min with the 4m load + # bound active -- so the hang was in launch/shutdown/orchestration, + # which no dart-level timer covers). The runner kills the whole + # process tree, so the step fails loudly and is rerunnable instead of + # idling to GitHub's 6h job kill. PR-scope steps finish in under a + # minute (15m = wide margin); full sweeps run ~55-66min per browser + # (150m = wide margin). + - name: "[Verify step] Test Dart packages [Chrome, PR scope]" + if: ${{ github.event_name == 'pull_request' }} + timeout-minutes: 15 + run: melos run test:web:pr:chrome + - name: "[Verify step] Test Dart packages [Firefox, PR scope]" + if: ${{ github.event_name == 'pull_request' }} + timeout-minutes: 15 + run: melos run test:web:pr:firefox - name: "[Verify step] Test Dart packages [Chrome]" + if: ${{ github.event_name != 'pull_request' }} + timeout-minutes: 150 run: melos run test:web:chrome - name: "[Verify step] Test Dart packages [Firefox]" + if: ${{ github.event_name != 'pull_request' }} + timeout-minutes: 150 run: melos run test:web:firefox - name: Remove dart_test.yaml Files run: melos run remove_dart_test_yaml @@ -217,7 +251,7 @@ jobs: patrol_plus test \ --coverage \ --coverage-ignore="**/*.g.dart" \ - --coverage-package oidc \ + --coverage-workspace \ -d "$EMU_ID" \ --dart-define=CI=true \ --dart-define=OIDC_CONFORMANCE_TOKEN=${OIDC_CONFORMANCE_TOKEN} @@ -364,7 +398,7 @@ jobs: patrol_plus test \ --coverage \ --coverage-ignore="**/*.g.dart" \ - --coverage-package oidc \ + --coverage-workspace \ -d "${SIMULATOR_UDID:?Simulator UDID was not exported}" \ --ios "${SIMULATOR_OS:?Simulator OS was not exported}" \ --verbose \ @@ -486,13 +520,13 @@ jobs: # Work around flaky desktop integration test runs when executing # multiple test entrypoints in a single `flutter test integration_test`. flutter test integration_test/app_test.dart \ - --coverage --branch-coverage --coverage-package "oidc*" \ + --coverage --branch-coverage --coverage-package "^(oidc|jose_plus|x509_plus|crypto_keys_plus)" \ -d macos --coverage-path coverage/macos-app-coverage.info \ --dart-define=CI=true --dart-define=OIDC_CONFORMANCE_TOKEN=${{env.OIDC_CONFORMANCE_TOKEN}} \ -r expanded flutter test integration_test/offline_mode_test.dart \ - --coverage --branch-coverage --coverage-package "oidc*" \ + --coverage --branch-coverage --coverage-package "^(oidc|jose_plus|x509_plus|crypto_keys_plus)" \ -d macos --coverage-path coverage/macos-offline-coverage.info \ --dart-define=CI=true --dart-define=OIDC_CONFORMANCE_TOKEN=${{env.OIDC_CONFORMANCE_TOKEN}} \ -r expanded @@ -548,7 +582,7 @@ jobs: xvfb-run --auto-servernum patrol_plus test \ --coverage \ --coverage-ignore="**/*.g.dart" \ - --coverage-package oidc \ + --coverage-workspace \ -d linux \ --dart-define=CI=true \ --dart-define=OIDC_CONFORMANCE_TOKEN=$OIDC_CONFORMANCE_TOKEN @@ -597,7 +631,7 @@ jobs: patrol_plus.bat test \ --coverage \ --coverage-ignore="**/*.g.dart" \ - --coverage-package oidc \ + --coverage-workspace \ -d windows \ --dart-define=CI=true \ --dart-define=OIDC_CONFORMANCE_TOKEN=${{ env.OIDC_CONFORMANCE_TOKEN }} @@ -669,6 +703,16 @@ jobs: find . -type f -name "*-coverage.info" dart pub global activate combine_coverage dart pub global run combine_coverage --repo-path="$REPO_PATH" + - name: Strip generated files from combined coverage + # Parity with the unit pipeline's coverage:combine: without this the + # final-coverage artifact and the Codecov upload include *.g.dart and + # the generated oidc_cli version.dart from integration runs. + run: | + set -e + dart pub global activate remove_from_coverage + dart pub global run remove_from_coverage:remove_from_coverage \ + -f coverage/lcov.info \ + -r '\.g\.dart$' -r 'oidc_cli/lib/src/version\.dart$' # Upload - name: Upload the final coverage as an artifact uses: actions/upload-artifact@v7 diff --git a/codecov.yml b/codecov.yml index b9a988d0..39215f54 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,4 +1,12 @@ coverage: status: patch: false - project: false \ No newline at end of file + project: false + +# Reporting-side noise exclusion. Generated code and the example app are not +# library surface; version.dart is generated (header: "Generated code. Do not +# modify.") but does not match the *.g.dart convention. +ignore: + - "**/*.g.dart" + - "**/example/**" + - "packages/oidc_cli/lib/src/version.dart" diff --git a/packages/crypto_keys_plus/test/library_surface_test.dart b/packages/crypto_keys_plus/test/library_surface_test.dart new file mode 100644 index 00000000..6f841dd8 --- /dev/null +++ b/packages/crypto_keys_plus/test/library_surface_test.dart @@ -0,0 +1,25 @@ +// Ensures the package-name-matching entrypoint (`crypto_keys_plus.dart`) is +// actually loaded by the test suite, so its re-exported surface stays +// visible to coverage tooling even though the rest of the suite only ever +// imports the legacy `crypto_keys.dart` entrypoint. +import 'package:crypto_keys_plus/crypto_keys_plus.dart'; +import 'package:test/test.dart'; + +void main() { + group('crypto_keys_plus library surface', () { + test( + 'algorithms exposes the documented digest identifiers', + () { + // `algorithms` is the top-level singleton re-exported from + // `crypto_keys.dart` via the package-name-matching barrel. Its + // identifier names are part of the documented public contract. + expect(algorithms.digest.sha256.name, 'digest/SHA-256'); + expect(algorithms.digest.sha512.name, 'digest/SHA-512'); + }, + ); + + test('curves exposes the documented P-256 curve identifier', () { + expect(curves.p256.name, 'curve/P-256'); + }); + }); +} diff --git a/packages/crypto_keys_plus/test/pointycastle_ext_test.dart b/packages/crypto_keys_plus/test/pointycastle_ext_test.dart new file mode 100644 index 00000000..0adec52c --- /dev/null +++ b/packages/crypto_keys_plus/test/pointycastle_ext_test.dart @@ -0,0 +1,168 @@ +// Targeted tests for `lib/src/pointycastle_ext.dart`. This file is combined +// (not `part of`) into the main `crypto_keys.dart` library and its classes +// are not re-exported from the package's public entrypoints, so it must be +// imported directly by its `src/` path to reach the branches below. +import 'dart:typed_data'; + +import 'package:crypto_keys_plus/src/pointycastle_ext.dart'; +import 'package:pointycastle/export.dart' as pc; +import 'package:test/test.dart'; + +/// A minimal concrete [BlockCipherWithAuthenticationTag] that does NOT +/// override [BlockCipherWithAuthenticationTag.processBlocks], so calling +/// [BlockCipherWithAuthenticationTag.process] exercises the base-class +/// `processBlocks` loop directly (the only production subclass, +/// `AesCbcAuthenticatedCipherWithHash`, overrides it). +class _IdentityTaggedCipher extends BlockCipherWithAuthenticationTag { + @override + String get algorithmName => 'identity-tagged'; + + @override + int get blockSize => 4; + + @override + int get tagLength => 2; + + @override + void initParameters(pc.CipherParameters? parameters) {} + + @override + Uint8List finalizeTag() => Uint8List.fromList([0xaa, 0xbb]); + + @override + int processBlock(Uint8List inp, int inpOff, Uint8List out, int outOff) { + // Identity transform: copy up to `blockSize` bytes (or whatever remains) + // from `inp` at `inpOff` into `out` at `outOff`. + var n = blockSize; + if (inpOff + n > inp.length) { + n = inp.length - inpOff; + } + for (var i = 0; i < n; i++) { + out[outOff + i] = inp[inpOff + i]; + } + return blockSize; + } + + @override + void reset() {} +} + +void main() { + group('toHex', () { + test('encodes bytes as lowercase two-digit hex, zero-padded', () { + expect(toHex([0, 255, 16, 1]), '00ff1001'); + }); + + test('empty input yields empty string', () { + expect(toHex(const []), ''); + }); + }); + + group('BlockCipherWithAuthenticationTag base processBlocks', () { + test('encrypting: appends the finalized tag after the ciphertext', () { + final cipher = _IdentityTaggedCipher(); + cipher.init( + true, + ParametersWithIVAndAad( + pc.KeyParameter(Uint8List(16)), + Uint8List(12), + Uint8List(0), + ), + ); + final out = cipher.process(Uint8List.fromList([1, 2, 3, 4, 5, 6, 7, 8])); + // 8 bytes of (identity) ciphertext + the 2-byte tag from finalizeTag(). + expect(out, [1, 2, 3, 4, 5, 6, 7, 8, 0xaa, 0xbb]); + }); + + test('decrypting: strips and verifies the trailing tag', () { + final cipher = _IdentityTaggedCipher(); + cipher.init( + false, + ParametersWithIVAndAad( + pc.KeyParameter(Uint8List(16)), + Uint8List(12), + Uint8List(0), + ), + ); + final combined = Uint8List.fromList([1, 2, 3, 4, 5, 6, 7, 8, 0xaa, 0xbb]); + expect(cipher.process(combined), [1, 2, 3, 4, 5, 6, 7, 8]); + }); + + test('decrypting: throws when the trailing tag does not match', () { + final cipher = _IdentityTaggedCipher(); + cipher.init( + false, + ParametersWithIVAndAad( + pc.KeyParameter(Uint8List(16)), + Uint8List(12), + Uint8List(0), + ), + ); + final tampered = Uint8List.fromList([1, 2, 3, 4, 5, 6, 7, 8, 0x00, 0x00]); + expect(() => cipher.process(tampered), throwsA(anything)); + }); + + test( + 'processBlocks pads the final partial block by reading past the ' + 'declared length', () { + // Exercises the `inputBlocks` ceil-division branch: 5 bytes over a + // block size of 4 requires 2 iterations. + final cipher = _IdentityTaggedCipher(); + cipher.init( + true, + ParametersWithIVAndAad( + pc.KeyParameter(Uint8List(16)), + Uint8List(12), + Uint8List(0), + ), + ); + final out = cipher.process(Uint8List.fromList([9, 8, 7, 6, 5])); + expect(out.sublist(0, 5), [9, 8, 7, 6, 5]); + expect(out.sublist(5), [0xaa, 0xbb]); + }); + }); + + group('AesCbcAuthenticatedCipherWithHash', () { + test('algorithmName combines the underlying cipher and MAC names', () { + final cipher = + AesCbcAuthenticatedCipherWithHash(pc.HMac(pc.SHA256Digest(), 64)); + expect(cipher.algorithmName, 'AES/CBC/PKCS7+SHA-256/HMAC'); + }); + + test('blockSize matches the underlying AES-CBC block size', () { + final cipher = + AesCbcAuthenticatedCipherWithHash(pc.HMac(pc.SHA256Digest(), 64)); + expect(cipher.blockSize, 16); + }); + + test('processBlock is defensively unimplemented', () { + final cipher = + AesCbcAuthenticatedCipherWithHash(pc.HMac(pc.SHA256Digest(), 64)); + expect( + () => cipher.processBlock(Uint8List(16), 0, Uint8List(16), 0), + throwsUnsupportedError, + ); + }); + + test('reset is defensively unimplemented', () { + final cipher = + AesCbcAuthenticatedCipherWithHash(pc.HMac(pc.SHA256Digest(), 64)); + expect(() => cipher.reset(), throwsUnsupportedError); + }); + }); + + group('AESKeyWrap', () { + test('processBlock is defensively unimplemented', () { + final wrap = AESKeyWrap(); + expect( + () => wrap.processBlock(Uint8List(16), 0, Uint8List(16), 0), + throwsUnsupportedError, + ); + }); + + test('reset is defensively unimplemented', () { + final wrap = AESKeyWrap(); + expect(() => wrap.reset(), throwsUnsupportedError); + }); + }); +} diff --git a/packages/crypto_keys_plus/test/unexpected_key_type_test.dart b/packages/crypto_keys_plus/test/unexpected_key_type_test.dart new file mode 100644 index 00000000..b6b44377 --- /dev/null +++ b/packages/crypto_keys_plus/test/unexpected_key_type_test.dart @@ -0,0 +1,111 @@ +// Targets the defensive "unexpected/unknown key type" fallback branches in +// `_AsymmetricOperator` (asymmetric_operator.dart) and `DefaultSecureRandom` +// (algorithms.dart), both of which are unreachable through the package's +// normal key-generation/JWK-parsing surface and require directly +// implementing the `Key`/`PrivateKey`/`PublicKey`/`EcKey` mixins to reach. +import 'dart:typed_data'; + +import 'package:crypto_keys_plus/crypto_keys.dart'; +// DefaultSecureRandom is intentionally not part of the public `show` list on +// the `crypto_keys.dart` export, so it must be reached via its `src/` path. +import 'package:crypto_keys_plus/src/algorithms.dart' show DefaultSecureRandom; +import 'package:pointycastle/export.dart' as pc; +import 'package:test/test.dart'; + +/// An `EcKey` that is deliberately neither an `EcPrivateKey` nor an +/// `EcPublicKey`. `EcKey` is a plain (non-sealed) abstract class, so this is +/// a legitimate way to reach `_AsymmetricOperator.keyParameter`'s final +/// `throw StateError('Unexpected key type $key')`: the getter always +/// computes `ecDomainParameters` (which only requires `key is EcKey`) before +/// checking for the two concrete EC subtypes. +class _EcKeyOfUnknownKind extends EcKey with PrivateKey { + @override + Identifier get curve => curves.p256; +} + +/// A `PublicKey` that is neither a `SymmetricKey`, an `OkpPublicKey`, nor an +/// `EcKey`/`RsaKey`. Reaches `_AsymmetricVerifier.verify`'s final +/// `throw UnsupportedError('Unknown key type $key')`, which — unlike the +/// signer's equivalent branch — is checked only inside the `RsaKey`/`EcKey` +/// `if` branches, so no cast happens first for a key of neither kind. +class _NeitherRsaNorEcPublicKey with Key, PublicKey {} + +void main() { + group('_AsymmetricOperator.keyParameter unexpected-key-type branch', () { + test( + 'signing with an EcKey that is neither EcPrivateKey nor EcPublicKey ' + 'throws StateError', () { + final key = _EcKeyOfUnknownKind(); + final signer = key.createSigner(algorithms.signing.ecdsa.sha256); + expect( + () => signer.sign(Uint8List.fromList([1, 2, 3])), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Unexpected key type'), + )), + ); + }); + }); + + group('_AsymmetricVerifier.verify unknown-key-type branch', () { + test( + 'verifying with a key that is neither RsaKey nor EcKey throws ' + 'UnsupportedError without ever computing keyParameter', () { + final key = _NeitherRsaNorEcPublicKey(); + final verifier = key.createVerifier(algorithms.signing.ecdsa.sha256); + expect( + () => verifier.verify( + Uint8List.fromList([1, 2, 3]), + Signature(Uint8List.fromList([0])), + ), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Unknown key type'), + )), + ); + }); + }); + + group('DefaultSecureRandom', () { + // Not exported from the public `crypto_keys.dart` barrel, but every + // asymmetric key-generation and encryption path in the library routes + // through it, so its full `pc.SecureRandom` surface (including the + // members pointycastle's own call sites never happen to use) is part of + // the documented contract via `implements pc.SecureRandom`. + test('algorithmName reports the underlying dart:math source', () { + expect( + DefaultSecureRandom().algorithmName, + 'dart.math.Random.secure()', + ); + }); + + test('nextUint16 stays within the 16-bit range', () { + final random = DefaultSecureRandom(); + for (var i = 0; i < 50; i++) { + final v = random.nextUint16(); + expect(v, inInclusiveRange(0, 0xffff)); + } + }); + + test('nextUint32 stays within the 32-bit range', () { + final random = DefaultSecureRandom(); + for (var i = 0; i < 50; i++) { + final v = random.nextUint32(); + expect(v, inInclusiveRange(0, 0xffffffff)); + } + }); + + test('seed is unsupported', () { + expect( + () => DefaultSecureRandom().seed(pc.KeyParameter(Uint8List(1))), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Seed not supported'), + )), + ); + }); + }); +} diff --git a/packages/jose_plus/dart_test.yaml b/packages/jose_plus/dart_test.yaml new file mode 100644 index 00000000..566774de --- /dev/null +++ b/packages/jose_plus/dart_test.yaml @@ -0,0 +1,2 @@ +tags: + slow-web-crypto: diff --git a/packages/jose_plus/lib/src/jose.dart b/packages/jose_plus/lib/src/jose.dart index ecdd0102..63b0c3b5 100644 --- a/packages/jose_plus/lib/src/jose.dart +++ b/packages/jose_plus/lib/src/jose.dart @@ -73,7 +73,7 @@ class JoseHeader extends JsonObject { /// Indicates that extensions to this specification and/or [JsonWebAlgorithm] /// are being used that MUST be understood and processed. - List? get critical => getTyped('crit'); + List? get critical => getTypedList('crit'); /// The content encryption algorithm used to perform authenticated encryption /// on the plaintext to produce the ciphertext and the Authentication Tag. diff --git a/packages/jose_plus/test/ecdh_error_test.dart b/packages/jose_plus/test/ecdh_error_test.dart index 5d48345f..601724ce 100644 --- a/packages/jose_plus/test/ecdh_error_test.dart +++ b/packages/jose_plus/test/ecdh_error_test.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:typed_data'; import 'package:jose_plus/jose.dart'; @@ -150,6 +151,141 @@ void main() { }); }); + group('ecdhEsDerive with a JWK whose `crv` is not in curvesByName', () { + test('throws UnsupportedError before any curve arithmetic runs', () { + // `JsonWebKey.fromKeyPair` (unlike `fromJson`) does not cross-check the + // JSON `crv` against the crypto_keys `EcPublicKey.curve` it is paired + // with, so this builds a JWK whose `cryptoKeyPair.publicKey` really is + // an `EcPublicKey` (passing `ecdhEsDerive`'s initial type check) but + // whose own `['crv']` is an unrecognized string — reaching + // `curveId == null` directly, one layer earlier than the P-256K case + // above (which resolves a curveId but then fails deeper). + final normal = JsonWebKey.generate('ES256'); + final weirdKey = JsonWebKey.fromKeyPair( + keyPair: normal.cryptoKeyPair, + json: { + 'kty': 'EC', + 'crv': 'BOGUS-CURVE', + 'x': normal['x'], + 'y': normal['y'], + }, + ); + expect( + () => ecdhEsDerive( + recipientPublicKey: weirdKey, + algorithmId: 'A128GCM', + keyDataLen: 128, + ), + throwsA(isA().having( + (e) => e.message, + 'message', + 'Unsupported curve: BOGUS-CURVE', + )), + ); + }); + }); + + group('ecdhEsDerive with a curve unsupported by ECDH-ES key agreement', () { + test( + 'P-256K passes the curvesByName lookup but fails ECDH parameter ' + 'construction', () { + // `curvesByName` (shared with EC signing) recognizes 'P-256K', so the + // initial `curveId == null` guard in `ecdhEsDerive` passes — but + // RFC 7518 §4.6 (and this file's local `_ECCurve` table) only defines + // ECDH-ES domain parameters for P-256/P-384/P-521, so curve-parameter + // resolution fails one layer deeper, inside `_ecdhAgreement`. + final recipient = _bareEcKey('ES256K'); + expect( + () => ecdhEsDerive( + recipientPublicKey: recipient, + algorithmId: 'A128GCM', + keyDataLen: 128, + ), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Unsupported curve'), + )), + ); + }); + }); + + group('ecdhEsDecrypt point-at-infinity guard', () { + test( + 'a zero private scalar produces a point at infinity and throws ' + 'StateError', () { + // Scalar multiplication by 0 short-circuits `_ECPoint.multiply`'s + // double-and-add loop, returning the infinity point directly — + // independent of which public key point it would otherwise have been + // multiplied against. + final recipient = _bareEcKey('ES256'); + final ephemeral = _bareEcKey('ES256'); + final zeroScalarJson = Map.from(recipient.toJson()) + ..['d'] = base64Url.encode([0]).replaceAll('=', ''); + final zeroScalarKey = JsonWebKey.fromJson(zeroScalarJson)!; + + expect( + () => ecdhEsDecrypt( + recipientPrivateKey: zeroScalarKey, + ephemeralPublicKey: ephemeral, + algorithmId: 'A128GCM', + keyDataLen: 128, + ), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('point at infinity'), + )), + ); + }); + }); + + group('_bigIntToBytes short-coordinate padding', () { + test('a shared secret shorter than the field size is left-zero-padded', () { + // `_ecdhAgreement`'s result.x is only guaranteed to be < curve.p, not + // exactly `fieldSize` bytes — its minimal big-endian encoding is one + // byte short whenever the top byte happens to be zero (~1/256 of + // random keys). Using random keys per-test-run would make this + // assertion flaky, so this pins a specific (private, harmless-to-leak) + // P-256 key pair, found by brute-force search, that deterministically + // reproduces a 31-byte (not 32-byte) shared secret. + final recipient = JsonWebKey.fromJson({ + 'kty': 'EC', + 'd': 'cgfsVBNkdlzuwDqmg8IwIpzQtkpCZ-kz2mzPnd913uA=', + 'x': 'rWk9DoplEjp_ljpMD_izHRMJ1z9fQJa27GBEKxyDq2c=', + 'y': '-yGzFUeWjY5ZbYdvvc5wQZZQcLBxGtYatrzd0rv6j68=', + 'crv': 'P-256', + })!; + final ephemeral = JsonWebKey.fromJson({ + 'kty': 'EC', + 'd': 'fUZk1M60Tgr_eHDvuWjxqriDvuikgNT1VGSdLeUUH8w=', + 'x': 'NFCvO3XvAkaqm1XI7dj1filojdyYKAUcv7UeTX34Z1c=', + 'y': 'lw_1QmVG-ovIpEzd0bDc5sKkr1_IJoH3AEmx7x-oRbw=', + 'crv': 'P-256', + })!; + + final result = ecdhEsDerive( + recipientPublicKey: recipient, + algorithmId: 'A128GCM', + keyDataLen: 128, + ephemeralKeyPair: ephemeral.cryptoKeyPair, + ); + // The derived key is still the full requested length regardless of the + // padding applied to the intermediate shared secret. + expect(result.derivedKey, hasLength(16)); + + // ECDH is symmetric: deriving from the other side with the same two + // points must reach the identical padding branch and agree on the key. + final recovered = ecdhEsDecrypt( + recipientPrivateKey: recipient, + ephemeralPublicKey: result.ephemeralPublicKey, + algorithmId: 'A128GCM', + keyDataLen: 128, + ); + expect(recovered, result.derivedKey); + }); + }); + group('AES key wrap helpers', () { test('ecdhEsWrapKey rejects a CEK whose length is not a multiple of 8', () { // 5-byte "key" -> not a multiple of 8. diff --git a/packages/jose_plus/test/ecdh_test.dart b/packages/jose_plus/test/ecdh_test.dart index 244c5019..3957b5d7 100644 --- a/packages/jose_plus/test/ecdh_test.dart +++ b/packages/jose_plus/test/ecdh_test.dart @@ -1,3 +1,6 @@ +@Tags(['slow-web-crypto']) +library; + import 'dart:convert'; import 'package:jose_plus/jose.dart'; diff --git a/packages/jose_plus/test/jose_test.dart b/packages/jose_plus/test/jose_test.dart index 2b4e8c64..e085beea 100644 --- a/packages/jose_plus/test/jose_test.dart +++ b/packages/jose_plus/test/jose_test.dart @@ -52,6 +52,21 @@ void main() { final header = JoseHeader.fromBase64EncodedString(encoded); expect(header.algorithm, 'HS256'); }); + + test('critical reads a `crit` header parameter as a string list', () { + final header = JoseHeader.fromJson({ + 'alg': 'RS256', + 'crit': ['exp'], + }); + + expect(header.critical, ['exp']); + }); + + test('critical is null when `crit` is absent', () { + final header = JoseHeader.fromJson({'alg': 'RS256'}); + + expect(header.critical, isNull); + }); }); group('JoseObject.fromJson', () { diff --git a/packages/jose_plus/test/jwa_error_test.dart b/packages/jose_plus/test/jwa_error_test.dart index d44a2287..57a74a43 100644 --- a/packages/jose_plus/test/jwa_error_test.dart +++ b/packages/jose_plus/test/jwa_error_test.dart @@ -1,6 +1,22 @@ +import 'package:crypto_keys_plus/crypto_keys.dart'; import 'package:jose_plus/jose.dart'; import 'package:test/test.dart'; +/// A [JsonWebAlgorithm] whose [jwkFromCryptoKeyPair] always returns `null`, +/// simulating a mismatch between [JsonWebAlgorithm.generateCryptoKeyPair] +/// (which always succeeds for the 4 known `type`s) and +/// [JsonWebAlgorithm.jwkFromCryptoKeyPair] (which the base implementation +/// also always populates for those same 4 types) — a "should never happen" +/// combination that [JsonWebAlgorithm.generateRandomKey] still guards +/// against. +class _AlwaysNullJwk extends JsonWebAlgorithm { + const _AlwaysNullJwk() + : super('X-NULL', type: 'oct', use: 'sig', minKeyBitLength: 8); + + @override + JsonWebKey? jwkFromCryptoKeyPair(KeyPair keyPair) => null; +} + void main() { group('JsonWebAlgorithm.getByName', () { test('returns the algorithm for a known name', () { @@ -73,4 +89,12 @@ void main() { expect(key.keyType, 'oct'); }); }); + + group('JsonWebAlgorithm.generateRandomKey', () { + test('throws UnimplementedError when jwkFromCryptoKeyPair returns null', + () { + const alg = _AlwaysNullJwk(); + expect(() => alg.generateRandomKey(), throwsA(isA())); + }); + }); } diff --git a/packages/jose_plus/test/jwa_test.dart b/packages/jose_plus/test/jwa_test.dart index 5b893021..76b697cc 100644 --- a/packages/jose_plus/test/jwa_test.dart +++ b/packages/jose_plus/test/jwa_test.dart @@ -1,3 +1,6 @@ +@Tags(['slow-web-crypto']) +library; + import 'dart:convert'; import 'package:crypto_keys_plus/crypto_keys.dart'; diff --git a/packages/jose_plus/test/jwe_error_test.dart b/packages/jose_plus/test/jwe_error_test.dart index 08364f2c..e082b766 100644 --- a/packages/jose_plus/test/jwe_error_test.dart +++ b/packages/jose_plus/test/jwe_error_test.dart @@ -3,6 +3,23 @@ import 'dart:convert'; import 'package:jose_plus/jose.dart'; import 'package:test/test.dart'; +// Re-encodes a compact JWE after mutating its protected header JSON. +// (Top-level so both the existing ECDH-ES group and the new decrypt-time +// header-tampering tests below can share it.) +String _withMutatedHeader( + String compact, + void Function(Map) mutate, +) { + final parts = compact.split('.'); + final header = + json.decode(utf8.decode(base64Url.decode(base64Url.normalize(parts[0])))) + as Map; + mutate(header); + parts[0] = + base64Url.encode(utf8.encode(json.encode(header))).replaceAll('=', ''); + return parts.join('.'); +} + void main() { final octKw = JsonWebKey.fromJson({ 'kty': 'oct', @@ -153,22 +170,6 @@ void main() { }); group('ECDH-ES decryption error paths', () { - // Re-encodes a compact JWE after mutating its protected header JSON. - String withMutatedHeader( - String compact, - void Function(Map) mutate, - ) { - final parts = compact.split('.'); - final header = json.decode( - utf8.decode(base64Url.decode(base64Url.normalize(parts[0])))) - as Map; - mutate(header); - parts[0] = base64Url - .encode(utf8.encode(json.encode(header))) - .replaceAll('=', ''); - return parts.join('.'); - } - test('fails when the ephemeral public key is missing from the header', () async { final ecKey = JsonWebKey.generate('ECDH-ES'); @@ -179,7 +180,7 @@ void main() { .build() .toCompactSerialization(); - final tampered = withMutatedHeader(compact, (h) => h.remove('epk')); + final tampered = _withMutatedHeader(compact, (h) => h.remove('epk')); final parsed = JsonWebEncryption.fromCompactSerialization(tampered); final keyStore = JsonWebKeyStore()..addKey(ecKey); // The missing-epk JoseException is swallowed per-recipient; the overall @@ -199,7 +200,7 @@ void main() { // Inject apu/apv that the sender did not use; derivation will run the // apu/apv branches and produce a different key, so decryption ultimately // fails. - final tampered = withMutatedHeader(compact, (h) { + final tampered = _withMutatedHeader(compact, (h) { h['apu'] = 'QWxpY2U'; // "Alice" h['apv'] = 'Qm9i'; // "Bob" }); @@ -209,6 +210,86 @@ void main() { }); }); + group('getPayloadFor decrypt-time header guards', () { + // These two guards inside `getPayloadFor` are unreachable through the + // normal `getPayload` -> `findJsonWebKeys` -> `getPayloadFor` pipeline: + // - `enc: "none"`: `JsonWebKeyStore._isValidKeyFor` calls + // `key.usableForAlgorithm(header.encryptionAlgorithm)` before any key + // reaches `getPayloadFor`; since no real key's own `alg` is ever + // "none", that call returns false and no key is ever yielded. + // - an unsupported `zip`: the JWE protected header is integrity-bound + // into the AEAD `aad`, so tampering the parsed compact serialization's + // header (as done for the ECDH-ES cases above) also changes the `aad` + // used to re-derive it, which fails authentication before the + // compression switch is ever reached — and the builder already + // rejects an unsupported `zip` at build() time, so a genuinely valid + // JWE with a bad `zip` can never be constructed either. + // + // `getPayloadFor` is `@protected` only as an analyzer hint (for + // subclassers), not a language-enforced restriction, so both guards are + // exercised directly with a hand-built [JoseHeader] — the only way to + // reach them without also breaking the AEAD tag. + test('fails when the protected header declares enc="none"', () { + final dirKey = JsonWebKey.generate('A128CBC-HS256'); + final jwe = (JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128CBC-HS256' + ..content = 'hi' + ..addRecipient(dirKey, algorithm: 'dir')) + .build(); + final header = JoseHeader.fromJson({'alg': 'dir', 'enc': 'none'}); + expect( + // ignore: invalid_use_of_protected_member + () => jwe.getPayloadFor(dirKey, header, jwe.recipients.first), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('cannot be `none`'), + )), + ); + }); + + test('fails when the protected header declares an unsupported zip alg', () { + final dirKey = JsonWebKey.generate('A128CBC-HS256'); + final jwe = (JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128CBC-HS256' + ..content = 'hi' + ..addRecipient(dirKey, algorithm: 'dir')) + .build(); + final header = JoseHeader.fromJson( + {'alg': 'dir', 'enc': 'A128CBC-HS256', 'zip': 'BOGUS'}, + ); + expect( + // ignore: invalid_use_of_protected_member + () => jwe.getPayloadFor(dirKey, header, jwe.recipients.first), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Unsupported compression algorithm BOGUS'), + )), + ); + }); + }); + + group('JsonWebEncryptionBuilder.build with a JWK of an unrecognized `kty`', + () { + test('throws UnimplementedError for direct encryption', () { + // `JsonWebKey.fromKeyPair` (unlike `fromJson`) does not validate `kty` + // against the known set, so this builds a JWK whose `toJson()` carries + // a `kty` that `KeyPair.fromJwk` cannot recognize when the builder + // re-parses it for the `dir` (direct encryption) code path. + final normal = JsonWebKey.generate('A128GCM'); + final weirdKey = JsonWebKey.fromKeyPair( + keyPair: normal.cryptoKeyPair, + json: {'kty': 'weird', 'k': normal['k']}, + ); + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128GCM' + ..content = 'hi' + ..addRecipient(weirdKey, algorithm: 'dir'); + expect(() => builder.build(), throwsA(isA())); + }); + }); + group('JsonWebEncryption with additional authenticated data', () { test('toJson exposes aad and the payload round-trips via JSON', () async { final dirKey = JsonWebKey.generate('A128CBC-HS256'); diff --git a/packages/jose_plus/test/jwe_test.dart b/packages/jose_plus/test/jwe_test.dart index a441a186..12eb4d18 100644 --- a/packages/jose_plus/test/jwe_test.dart +++ b/packages/jose_plus/test/jwe_test.dart @@ -1,3 +1,6 @@ +@Tags(['slow-web-crypto']) +library; + import 'dart:convert'; import 'package:jose_plus/jose.dart'; diff --git a/packages/jose_plus/test/jwk_error_test.dart b/packages/jose_plus/test/jwk_error_test.dart index f81b5be8..4208b9d2 100644 --- a/packages/jose_plus/test/jwk_error_test.dart +++ b/packages/jose_plus/test/jwk_error_test.dart @@ -141,6 +141,52 @@ void main() { }); }); + group('JsonWebKey.fromPem with an unsupported PEM block type', () { + test('throws UnsupportedError for a PKCS#10 certificate request', () { + // `x509.parsePem` decodes a "CERTIFICATE REQUEST" block into a + // `CertificationRequest`, which `fromPem` has no conversion for (only + // `PrivateKeyInfo`, `KeyPair`, `X509Certificate` and + // `SubjectPublicKeyInfo` are supported). + const csrPem = '-----BEGIN CERTIFICATE REQUEST-----\n' + 'MIICxDCCAawCAQAwfzELMAkGA1UEBhMCQkUxEzARBgNVBAgTClNvbWUtU3RhdGUx\n' + 'DjAMBgNVBAcTBUdoZW50MQ8wDQYDVQQKEwZBcHBzVXAxFDASBgNVBAMTC1JpayBC\n' + 'ZWxsZW5zMSQwIgYJKoZIhvcNAQkBFhVyaWsuYmVsbGVuc0BhcHBzdXAuYmUwggEi\n' + 'MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDIEQi/Jsu5D1d6G4tUBGFBfzBr\n' + 'Om72d0nPo2nitdw4mjnqdSn08xPeu8ILIUds3Mbti4AXyk/Ar+RsnxtG8j85RvH+\n' + 'sx8nIc41J4//9cIlYjVCScjAvuklz2JWIMCT50y2rJ1pf0NFrs/pghRQBX5yAP0z\n' + 'KTWjQsgojLp1V4UVLfyWKdBQRDA6W2dybc8WtvCc4n3cLrMDYRY3Kwl7zl5TmIr9\n' + 'cw+B50W/9Q+lv4x3Yzj7zxs2CHrjhY1x14es1hTf6VlNH07Bb9MM8HtONq73616S\n' + 'ojzo3Wd16Zps2Kl7COHu5pG2WSR95ddjpaJur9pbZLft8n2nLLMNd+c4u2YzAgMB\n' + 'AAGgADANBgkqhkiG9w0BAQsFAAOCAQEAJyv7nIC2g//naaXwMAiBML6JlSQcrPIb\n' + 'GoVvgAXdcT9GLTg7h4So2XRj3R/qq5yzSRjZ6tSjEHpufG2z/6ewZNb/kynQjdKp\n' + 'wQ8sumReuGgOcnHw6ggEadRxVBRjCvLI2Vdz1K8aVQc0bpeEJydop+aMjLaYNypo\n' + 'dQxDBcoDQqpn5ocxQCVLXUVtuWdJQLPwaN+EuSoeuqoFgsx2MCKjCpdOfRBONqQZ\n' + '3661iihx1kYj3BT3pUDuf8Ztbpf4th0iRX0HaQ86Cv23csvlGEggo332tuHE8nqt\n' + 'QRHW6F76DyflCMF+rSGKlf6PvU9bDjNccjxiYMum4HG+hoUqsnTW2g==\n' + '-----END CERTIFICATE REQUEST-----\n'; + expect( + () => JsonWebKey.fromPem(csrPem), + throwsA(isA()), + ); + }); + }); + + group('JsonWebKey._getAlgorithm', () { + test( + 'sign with algorithm "none" throws UnsupportedError ' + '(AlgorithmIdentifier.getByJwaName returns null only for "none")', () { + final key = JsonWebKey.fromJson({ + 'kty': 'oct', + 'k': 'AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75' + 'aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow', + })!; + expect( + () => key.sign([1, 2, 3], algorithm: 'none'), + throwsA(isA()), + ); + }); + }); + group('JsonWebKey cryptographic operation guards', () { test('sign throws StateError when the key cannot sign', () { final publicOnly = JsonWebKey.fromJson({ diff --git a/packages/jose_plus/test/jwk_test.dart b/packages/jose_plus/test/jwk_test.dart index 4b324e45..d7c2d0e6 100644 --- a/packages/jose_plus/test/jwk_test.dart +++ b/packages/jose_plus/test/jwk_test.dart @@ -1,3 +1,6 @@ +@Tags(['slow-web-crypto']) +library; + import 'dart:convert'; import 'dart:io'; diff --git a/packages/jose_plus/test/jws_test.dart b/packages/jose_plus/test/jws_test.dart index 900ee39a..dc203c7c 100644 --- a/packages/jose_plus/test/jws_test.dart +++ b/packages/jose_plus/test/jws_test.dart @@ -1,3 +1,6 @@ +@Tags(['slow-web-crypto']) +library; + import 'dart:convert'; import 'package:jose_plus/jose.dart'; diff --git a/packages/jose_plus/test/jwt_test.dart b/packages/jose_plus/test/jwt_test.dart index 85b15e65..fe025827 100644 --- a/packages/jose_plus/test/jwt_test.dart +++ b/packages/jose_plus/test/jwt_test.dart @@ -1,3 +1,6 @@ +@Tags(['slow-web-crypto']) +library; + import 'package:jose_plus/jose.dart'; import 'package:test/test.dart'; diff --git a/packages/jose_plus/test/library_surface_test.dart b/packages/jose_plus/test/library_surface_test.dart new file mode 100644 index 00000000..a8b6537c --- /dev/null +++ b/packages/jose_plus/test/library_surface_test.dart @@ -0,0 +1,27 @@ +// Ensures the package-name-matching entrypoint (`jose_plus.dart`) is +// actually loaded by the test suite, so its re-exported surface stays +// visible to coverage tooling even though the rest of the suite only ever +// imports the legacy `jose.dart` entrypoint. +import 'package:jose_plus/jose_plus.dart'; +import 'package:test/test.dart'; + +void main() { + group('jose_plus library surface', () { + test( + 'JsonWebAlgorithm.getByName resolves RS256 to its RFC 7518 metadata', + () { + final alg = JsonWebAlgorithm.getByName('RS256'); + expect(alg.type, 'RSA'); + expect(alg.use, 'sig'); + expect(alg.minKeyBitLength, 2048); + }, + ); + + test('JsonWebAlgorithm.getByName throws for an unsupported name', () { + expect( + () => JsonWebAlgorithm.getByName('not-a-real-alg'), + throwsA(isA()), + ); + }); + }); +} diff --git a/packages/jose_plus/test/pss_eddsa_jws_test.dart b/packages/jose_plus/test/pss_eddsa_jws_test.dart index d52ea677..e9ecbd80 100644 --- a/packages/jose_plus/test/pss_eddsa_jws_test.dart +++ b/packages/jose_plus/test/pss_eddsa_jws_test.dart @@ -1,3 +1,6 @@ +@Tags(['slow-web-crypto']) +library; + import 'package:jose_plus/jose.dart'; import 'package:test/test.dart'; diff --git a/packages/oidc/test/library_surface_test.dart b/packages/oidc/test/library_surface_test.dart new file mode 100644 index 00000000..c8751d5d --- /dev/null +++ b/packages/oidc/test/library_surface_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:oidc/oidc.dart'; + +// Every existing test in this suite that constructs `OidcUserManager` (both +// the eager and `.lazy` constructors) passes an explicit `id`, so +// `OidcUserManagerBase.id`'s documented "defaults to null when omitted" +// behavior is never actually asserted anywhere in this package -- even +// though `manager.id` is part of the public surface re-exported through +// this barrel. +void main() { + group('oidc library surface', () { + const clientCredentials = OidcClientAuthentication.none( + clientId: 'client-1', + ); + final settings = OidcUserManagerSettings( + redirectUri: Uri.parse('https://app.example.com/cb'), + ); + + test('OidcUserManager (eager constructor) leaves id null when omitted', () { + final manager = OidcUserManager( + discoveryDocument: OidcProviderMetadata.fromJson(const { + 'issuer': 'https://op.example.com', + 'authorization_endpoint': 'https://op.example.com/authorize', + 'token_endpoint': 'https://op.example.com/token', + }), + clientCredentials: clientCredentials, + store: OidcMemoryStore(), + settings: settings, + ); + expect(manager.id, isNull); + expect(manager.discoveryDocumentUri, isNull); + }); + + test('OidcUserManager.lazy leaves id null when omitted, and defers the ' + 'discovery document uri instead of the document itself', () { + final manager = OidcUserManager.lazy( + discoveryDocumentUri: Uri.parse( + 'https://op.example.com/.well-known/openid-configuration', + ), + clientCredentials: clientCredentials, + store: OidcMemoryStore(), + settings: settings, + ); + expect(manager.id, isNull); + expect( + manager.discoveryDocumentUri, + Uri.parse('https://op.example.com/.well-known/openid-configuration'), + ); + }); + }); +} diff --git a/packages/oidc_android/test/library_surface_test.dart b/packages/oidc_android/test/library_surface_test.dart new file mode 100644 index 00000000..4bca99f4 --- /dev/null +++ b/packages/oidc_android/test/library_surface_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:oidc_android/oidc_android.dart'; +import 'package:oidc_core/oidc_core.dart'; + +// `oidc_android_test.dart` exercises `getAuthorizationResponse` / +// `getEndSessionResponse` / `registerWith` extensively over the Pigeon +// channel, but never touches `prepareForRedirectFlow`, +// `listenToFrontChannelLogoutRequests`, or `monitorSessionStatus` -- these +// three lines are never executed by any existing test. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('oidc_android library surface', () { + test( + 'prepareForRedirectFlow is a no-op that returns an empty preparation ' + 'map (Chrome Custom Tabs are launched directly, no pre-navigation ' + 'step)', + () { + final result = OidcAndroid().prepareForRedirectFlow( + const OidcPlatformSpecificOptions(), + ); + expect(result, isEmpty); + }, + ); + + test( + 'listenToFrontChannelLogoutRequests returns an empty stream ' + '(front-channel logout listening is not implemented on Android)', + () async { + final events = await OidcAndroid() + .listenToFrontChannelLogoutRequests( + Uri.parse('com.example.app://logout'), + const OidcFrontChannelRequestListeningOptions(), + ) + .toList(); + expect(events, isEmpty); + }, + ); + + test( + 'monitorSessionStatus returns an empty stream ' + '(session-status polling is not supported on Android)', + () async { + final events = await OidcAndroid() + .monitorSessionStatus( + checkSessionIframe: Uri.parse('https://op.example.com/check'), + request: const OidcMonitorSessionStatusRequest( + clientId: 'client-1', + sessionState: 'state-1', + interval: Duration(seconds: 1), + ), + ) + .toList(); + expect(events, isEmpty); + }, + ); + }); +} diff --git a/packages/oidc_android/test/oidc_android_test.dart b/packages/oidc_android/test/oidc_android_test.dart index 12058af3..cef45c83 100644 --- a/packages/oidc_android/test/oidc_android_test.dart +++ b/packages/oidc_android/test/oidc_android_test.dart @@ -4,6 +4,25 @@ import 'package:oidc_android/oidc_android.dart'; import 'package:oidc_core/oidc_core.dart'; import 'package:oidc_platform_interface/oidc_platform_interface.dart'; +/// Injectable [OidcAndroidHostApi] stand-in that throws a raw +/// [MissingPluginException] directly (bypassing the Pigeon channel), used to +/// exercise `_guard`'s "no native plugin registered at all" branch. Pigeon's +/// generated `send()` always resolves under `TestDefaultBinaryMessengerBinding` +/// (an unregistered channel decodes to a `channel-error` `PlatformException`, +/// covered separately), so a real `MissingPluginException` can only be +/// produced this way in a unit test. +class _MissingPluginHostApi extends OidcAndroidHostApi { + @override + Future authorize( + String url, + String? redirectUri, + String? callbackScheme, + Map options, + ) { + throw MissingPluginException('no implementation found'); + } +} + OidcAuthorizeRequest _authRequest() => OidcAuthorizeRequest( clientId: 'client-1', redirectUri: Uri.parse('com.example.app://callback'), @@ -198,4 +217,60 @@ void main() { expect(resp, isNotNull); expect(resp!.state, 'logout-state'); }); + + test( + 'wraps a raw MissingPluginException (no plugin registered) as ' + 'OidcException', () async { + await expectLater( + OidcAndroid(hostApi: _MissingPluginHostApi()).getAuthorizationResponse( + metadata, + _authRequest(), + const OidcPlatformSpecificOptions(), + const {}, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('not available'), + ), + ), + ); + }); + + group('nativeBrowserEvents', () { + const eventChannel = EventChannel( + 'dev.flutter.pigeon.oidc_platform_interface.OidcNativeEventApi' + '.streamNativeEvents', + ); + + tearDown(() => messenger.setMockStreamHandler(eventChannel, null)); + + test( + 'maps native event maps into typed OidcNativeBrowserEvents, dropping ' + 'unrecognized event types', () async { + messenger.setMockStreamHandler( + eventChannel, + MockStreamHandler.inline( + onListen: (arguments, events) { + events + ..success({ + 'type': 'cancelled', + 'flowId': 'flow-1', + }) + // Forward-compatibility: an unrecognized type must be + // dropped, not surfaced or thrown. + ..success({'type': 'some-future-event-type'}) + ..endOfStream(); + }, + ), + ); + + final events = await OidcAndroid().nativeBrowserEvents().toList(); + + expect(events, hasLength(1)); + final event = events.single as OidcBrowserFlowCancelledEvent; + expect(event.flowId, 'flow-1'); + }); + }); } diff --git a/packages/oidc_cli/lib/src/cli_user_manager.dart b/packages/oidc_cli/lib/src/cli_user_manager.dart index 30bd33c7..9044c2ec 100644 --- a/packages/oidc_cli/lib/src/cli_user_manager.dart +++ b/packages/oidc_cli/lib/src/cli_user_manager.dart @@ -103,6 +103,16 @@ class CliUserManager extends OidcUserManagerBase { actualRedirectUriCompleter: redirectUriCompleter, printFunction: (uri) async { cliLogger.info('Please open the following link: $uri'); + // coverage:ignore-start + // Unconditionally launches the host OS's real default browser via a + // subprocess. Exercising this in a unit test would pop a real + // browser window as a side effect of running `dart test` (there is + // no injection seam for the OS-open call, unlike `oidc_desktop`'s + // `launchUrl` callback), so the whole login/logout suite + // deliberately never reaches this branch — see the note atop + // `login_interactive_command_test.dart` and + // `pub_proxy_command_test.dart` for the same convention applied to + // the analogous `dart pub token add` subprocess in `dart_pub.dart`. try { if (Platform.isWindows) { await Process.run('rundll32', [ @@ -117,6 +127,7 @@ class CliUserManager extends OidcUserManagerBase { } on Exception catch (_) { // ignore } + // coverage:ignore-end }, ); @@ -161,6 +172,10 @@ class CliUserManager extends OidcUserManagerBase { actualRedirectUriCompleter: redirectUriCompleter, printFunction: (uri) async { logger.info('Please open the following link: $uri'); + // coverage:ignore-start + // See the matching note in `getAuthorizationResponse` above: this + // unconditionally shells out to launch the host OS's real default + // browser, which unit tests deliberately never trigger. try { if (Platform.isWindows) { await Process.run('rundll32', [ @@ -175,6 +190,7 @@ class CliUserManager extends OidcUserManagerBase { } on Exception catch (_) { // ignore } + // coverage:ignore-end }, ); diff --git a/packages/oidc_cli/lib/src/commands/login/login_device_command.dart b/packages/oidc_cli/lib/src/commands/login/login_device_command.dart index 59fafad0..aed2d91e 100644 --- a/packages/oidc_cli/lib/src/commands/login/login_device_command.dart +++ b/packages/oidc_cli/lib/src/commands/login/login_device_command.dart @@ -113,9 +113,15 @@ class LoginDeviceCommand extends OidcBaseCommand { // Token output is intentionally plain text for easy scripting. logger.info(token); + // coverage:ignore-start + // See `dart_pub.dart`: `addToDartPub` shells out to the real `dart` + // executable and would mutate the developer machine's actual pub + // credentials store, so unit tests deliberately never let a login + // succeed while `--add-to-dart-pub` is set. if (hostedUrl != null && hostedUrl.trim().isNotEmpty) { await addToDartPub(logger: logger, hostedUrl: hostedUrl, token: token); } + // coverage:ignore-end return ExitCode.success.code; } finally { diff --git a/packages/oidc_cli/lib/src/commands/login/login_interactive_command.dart b/packages/oidc_cli/lib/src/commands/login/login_interactive_command.dart index d8e3c3e1..356a1f26 100644 --- a/packages/oidc_cli/lib/src/commands/login/login_interactive_command.dart +++ b/packages/oidc_cli/lib/src/commands/login/login_interactive_command.dart @@ -115,6 +115,11 @@ class LoginInteractiveCommand extends OidcBaseCommand { ..success('Authentication successful!') ..info('Access Token: ${user.token.accessToken}'); + // coverage:ignore-start + // See `dart_pub.dart`: `addToDartPub` shells out to the real `dart` + // executable and would mutate the developer machine's actual pub + // credentials store, so unit tests deliberately never let a login + // succeed while `--add-to-dart-pub` is set. if (hostedUrl != null && user.token.accessToken != null) { await addToDartPub( logger: logger, @@ -122,6 +127,7 @@ class LoginInteractiveCommand extends OidcBaseCommand { token: user.token.accessToken!, ); } + // coverage:ignore-end return ExitCode.success.code; } finally { diff --git a/packages/oidc_cli/lib/src/commands/login/login_password_command.dart b/packages/oidc_cli/lib/src/commands/login/login_password_command.dart index 2350a896..06790bbb 100644 --- a/packages/oidc_cli/lib/src/commands/login/login_password_command.dart +++ b/packages/oidc_cli/lib/src/commands/login/login_password_command.dart @@ -155,6 +155,11 @@ class LoginPasswordCommand extends OidcBaseCommand { ..success('Authentication successful!') ..info('Access Token: ${user.token.accessToken}'); + // coverage:ignore-start + // See `dart_pub.dart`: `addToDartPub` shells out to the real `dart` + // executable and would mutate the developer machine's actual pub + // credentials store, so unit tests deliberately never let a login + // succeed while `--add-to-dart-pub` is set. if (hostedUrl != null && user.token.accessToken != null) { await addToDartPub( logger: logger, @@ -162,6 +167,7 @@ class LoginPasswordCommand extends OidcBaseCommand { token: user.token.accessToken!, ); } + // coverage:ignore-end return ExitCode.success.code; } finally { diff --git a/packages/oidc_cli/lib/src/commands/proxy/pub_proxy_command.dart b/packages/oidc_cli/lib/src/commands/proxy/pub_proxy_command.dart index 130cd32d..93624b37 100644 --- a/packages/oidc_cli/lib/src/commands/proxy/pub_proxy_command.dart +++ b/packages/oidc_cli/lib/src/commands/proxy/pub_proxy_command.dart @@ -66,7 +66,13 @@ class PubProxyCommand extends OidcBaseCommand { return ExitCode.software.code; } + // coverage:ignore-start + // See `dart_pub.dart`: `addToDartPub` shells out to the real `dart` + // executable and would mutate the developer machine's actual pub + // credentials store, so unit tests deliberately never let this reach + // a real (non-blank) stored token. await addToDartPub(logger: logger, hostedUrl: hostedUrl, token: token); + // coverage:ignore-end } final process = await Process.start(executable, [ diff --git a/packages/oidc_cli/lib/src/utils/dart_pub.dart b/packages/oidc_cli/lib/src/utils/dart_pub.dart index 6bbc47a0..b27dc045 100644 --- a/packages/oidc_cli/lib/src/utils/dart_pub.dart +++ b/packages/oidc_cli/lib/src/utils/dart_pub.dart @@ -1,3 +1,13 @@ +// coverage:ignore-file +// This function unconditionally shells out to the real `dart` executable +// (`dart pub token add `) and pipes a token to its stdin, which +// mutates the *actual* developer machine's global pub credentials store +// (there is no injection seam for `Process.start`). Exercising it for real +// in a unit test would add a spurious/real credential entry to the host's +// pub token store — tests must not mutate the developer's pub +// configuration. Every call site that would reach this function (in the +// `login *`/`dart pub`/`flutter pub` proxy commands) is documented with the +// same rationale where it is skipped. import 'dart:convert'; import 'dart:io'; diff --git a/packages/oidc_cli/test/library_surface_test.dart b/packages/oidc_cli/test/library_surface_test.dart new file mode 100644 index 00000000..22981715 --- /dev/null +++ b/packages/oidc_cli/test/library_surface_test.dart @@ -0,0 +1,25 @@ +@TestOn('vm') +library; + +// `oidc_cli`'s public barrel (`lib/oidc_cli.dart`) carries only doc comments +// and has no `export` statements — this package ships an executable, not an +// importable library, so there is nothing to load from the barrel itself. +// This test instead exercises the real CLI-identity constants in +// `src/command_runner.dart`, which the rest of the suite covers only +// indirectly (through command_runner_test.dart's behavioral tests) without +// ever asserting them against their documented values. +import 'package:oidc_cli/src/command_runner.dart' as runner; +import 'package:test/test.dart'; + +void main() { + group('oidc_cli library surface', () { + test('the CLI identity constants match the published package name', () { + expect(runner.executableName, 'oidc'); + expect(runner.packageName, 'oidc_cli'); + expect( + runner.description, + 'A small provider-agnostic CLI for OpenID Connect (OIDC).', + ); + }); + }); +} diff --git a/packages/oidc_cli/test/src/cli_user_manager_test.dart b/packages/oidc_cli/test/src/cli_user_manager_test.dart new file mode 100644 index 00000000..1df8cf6f --- /dev/null +++ b/packages/oidc_cli/test/src/cli_user_manager_test.dart @@ -0,0 +1,311 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:oidc_cli/src/cli_user_manager.dart'; +import 'package:oidc_cli/src/file_oidc_store.dart'; +import 'package:oidc_core/oidc_core.dart'; +import 'package:test/test.dart'; + +class _MockLogger extends Mock implements Logger {} + +void main() { + // NOTE: `getAuthorizationResponse`/`getEndSessionResponse`'s success paths + // call an internal `printFunction` that unconditionally shells out to the + // OS's default browser opener (`rundll32`/`open`/`xdg-open`) via + // `Process.run`, exactly like `CliUserManager.getAuthorizationResponse`'s + // production implementation. The rest of this package's test suite + // (see login_interactive_command_test.dart and pub_proxy_command_test.dart) + // deliberately avoids ever triggering that side effect from an automated + // test, so this file follows the same convention: it exercises + // `startListenerAndGetUri` directly with a test-supplied `printFunction` + // (covering the generic loopback-listener wiring), and it exercises + // `getAuthorizationResponse`/`getEndSessionResponse` only up to the point + // where they would need to invoke the real OS browser opener. + late Directory tempDir; + late FileOidcStore store; + late Logger logger; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('cli_user_manager_test_'); + store = FileOidcStore.fromPath('${tempDir.path}/store.json'); + logger = _MockLogger(); + when(() => logger.info(any())).thenReturn(null); + }); + + tearDown(() { + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + CliUserManager buildLazyManager() => CliUserManager.lazy( + cliLogger: logger, + discoveryDocumentUri: Uri.parse('https://op.example.com/.well-known/x'), + clientCredentials: const OidcClientAuthentication.none( + clientId: 'client-1', + ), + store: store, + settings: OidcUserManagerSettings( + redirectUri: Uri.parse('http://127.0.0.1:0'), + ), + ); + + group('construction', () { + test('the eager constructor can be instantiated', () { + final metadata = OidcProviderMetadata.fromJson({ + 'issuer': 'https://op.example.com', + }); + final manager = CliUserManager( + cliLogger: logger, + discoveryDocument: metadata, + clientCredentials: const OidcClientAuthentication.none( + clientId: 'c1', + ), + store: store, + settings: OidcUserManagerSettings( + redirectUri: Uri.parse('http://127.0.0.1:0'), + ), + ); + expect(manager, isNotNull); + expect(manager.isWeb, isFalse); + }); + + test('the lazy constructor can be instantiated', () { + final manager = buildLazyManager(); + expect(manager, isNotNull); + expect(manager.isWeb, isFalse); + }); + }); + + group('startListenerAndGetUri', () { + test( + 'replaces the port in the redirect URI when the requested port ' + '(0 = any) differs from the bound one, and resolves with the ' + 'callback URI once a matching GET arrives', + () async { + final manager = buildLazyManager(); + final actualRedirectUriCompleter = Completer(); + Uri? printedUri; + + final result = await manager.startListenerAndGetUri( + originalRedirectUri: Uri( + scheme: 'http', + host: '127.0.0.1', + port: 0, + path: '/callback', + ), + redirectUriKey: 'redirect_uri', + endpoint: Uri.parse('https://op.example.com/authorize'), + requestParameters: {'response_type': 'code', 'client_id': 'c1'}, + logRequestDesc: 'authorization', + actualRedirectUriCompleter: actualRedirectUriCompleter, + printFunction: (uri) async { + printedUri = uri; + final redirectUriString = uri.queryParameters['redirect_uri']; + final redirectUri = Uri.parse(redirectUriString!); + final client = HttpClient(); + try { + final callbackUri = redirectUri.replace( + queryParameters: { + ...redirectUri.queryParameters, + 'code': 'auth-code-xyz', + }, + ); + final request = await client.getUrl(callbackUri); + await (await request.close()).drain(); + } finally { + client.close(force: true); + } + }, + ); + + expect(result, isNotNull); + expect(result!.queryParameters['code'], 'auth-code-xyz'); + + // The endpoint's own query parameters and the serialized request + // parameters were merged into the printed authorize URI. + expect(printedUri, isNotNull); + expect(printedUri!.queryParameters['response_type'], 'code'); + expect(printedUri!.queryParameters['client_id'], 'c1'); + + final actualRedirectUri = await actualRedirectUriCompleter.future; + // Port 0 means "any free port": the bound port can never actually be + // 0, so the port-replacement branch is always exercised here. + expect(actualRedirectUri.port, isNot(0)); + expect(actualRedirectUri.path, '/callback'); + }, + ); + + test( + 'keeps the original port untouched when the loopback listener binds ' + 'to exactly the requested (non-zero) port', + () async { + // Find a free port, then release it immediately so the listener + // below can bind to that exact same port. + final probe = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final freePort = probe.port; + await probe.close(force: true); + + final manager = buildLazyManager(); + final actualRedirectUriCompleter = Completer(); + + final result = await manager.startListenerAndGetUri( + originalRedirectUri: Uri( + scheme: 'http', + host: '127.0.0.1', + port: freePort, + path: '/callback', + ), + redirectUriKey: 'redirect_uri', + endpoint: Uri.parse('https://op.example.com/authorize'), + requestParameters: const {}, + logRequestDesc: 'authorization', + actualRedirectUriCompleter: actualRedirectUriCompleter, + printFunction: (uri) async { + final redirectUriString = uri.queryParameters['redirect_uri']; + final redirectUri = Uri.parse(redirectUriString!); + final client = HttpClient(); + try { + final callbackUri = redirectUri.replace( + queryParameters: { + ...redirectUri.queryParameters, + 'code': 'auth-code-same-port', + }, + ); + final request = await client.getUrl(callbackUri); + await (await request.close()).drain(); + } finally { + client.close(force: true); + } + }, + ); + + expect(result, isNotNull); + expect(result!.queryParameters['code'], 'auth-code-same-port'); + + final actualRedirectUri = await actualRedirectUriCompleter.future; + expect(actualRedirectUri.port, freePort); + }, + ); + }); + + group('getAuthorizationResponse', () { + test( + 'throws an OidcException when the provider has no ' + 'authorizationEndpoint', + () async { + final manager = buildLazyManager(); + final metadata = OidcProviderMetadata.fromJson({ + 'issuer': 'https://op.example.com', + }); + final request = OidcAuthorizeRequest( + responseType: const ['code'], + clientId: 'client-1', + redirectUri: Uri(scheme: 'http', host: '127.0.0.1', port: 0), + scope: const ['openid'], + ); + + await expectLater( + manager.getAuthorizationResponse( + metadata, + request, + const OidcPlatformSpecificOptions(), + const {}, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('authorizationEndpoint'), + ), + ), + ); + }, + ); + }); + + group('getEndSessionResponse', () { + test( + 'throws an OidcException when the provider has no endSessionEndpoint', + () async { + final manager = buildLazyManager(); + final metadata = OidcProviderMetadata.fromJson({ + 'issuer': 'https://op.example.com', + }); + const request = OidcEndSessionRequest(); + + await expectLater( + manager.getEndSessionResponse( + metadata, + request, + const OidcPlatformSpecificOptions(), + const {}, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('endSessionEndpoint'), + ), + ), + ); + }, + ); + + test( + 'returns null without starting a listener when there is no ' + 'postLogoutRedirectUri', + () async { + final manager = buildLazyManager(); + final metadata = OidcProviderMetadata.fromJson({ + 'issuer': 'https://op.example.com', + 'end_session_endpoint': 'https://op.example.com/end-session', + }); + const request = OidcEndSessionRequest(); + + final result = await manager.getEndSessionResponse( + metadata, + request, + const OidcPlatformSpecificOptions(), + const {}, + ); + + expect(result, isNull); + }, + ); + }); + + group('platform hooks not applicable to a CLI', () { + test('listenToFrontChannelLogoutRequests is an empty stream', () async { + final manager = buildLazyManager(); + final stream = manager.listenToFrontChannelLogoutRequests( + Uri.parse('http://127.0.0.1:0/front-channel'), + const OidcFrontChannelRequestListeningOptions(), + ); + expect(await stream.isEmpty, isTrue); + }); + + test('monitorSessionStatus is an empty stream', () async { + final manager = buildLazyManager(); + final stream = manager.monitorSessionStatus( + checkSessionIframe: Uri.parse('https://op.example.com/check-session'), + request: const OidcMonitorSessionStatusRequest( + clientId: 'client-1', + sessionState: 'session-state-1', + interval: Duration(seconds: 2), + ), + ); + expect(await stream.isEmpty, isTrue); + }); + + test('prepareForRedirectFlow returns an empty map', () { + final manager = buildLazyManager(); + final result = manager.prepareForRedirectFlow( + const OidcPlatformSpecificOptions(), + ); + expect(result, isEmpty); + }); + }); +} diff --git a/packages/oidc_cli/test/src/command_runner_test.dart b/packages/oidc_cli/test/src/command_runner_test.dart index 4039ff48..eb37700f 100644 --- a/packages/oidc_cli/test/src/command_runner_test.dart +++ b/packages/oidc_cli/test/src/command_runner_test.dart @@ -13,6 +13,8 @@ class _MockLogger extends Mock implements Logger {} class _MockPubUpdater extends Mock implements PubUpdater {} +class _MockProgress extends Mock implements Progress {} + void main() { group('OidcCliCommandRunner', () { late PubUpdater pubUpdater; @@ -128,5 +130,94 @@ void main() { } }); }); + + group('completion', () { + test('takes the fast track and returns success', () async { + final result = await commandRunner.run(['completion']); + expect(result, equals(ExitCode.success.code)); + }); + }); + + group('update checks (attached to a terminal)', () { + late OidcCliCommandRunner runnerWithTerminal; + + setUp(() { + runnerWithTerminal = OidcCliCommandRunner( + logger: logger, + pubUpdater: pubUpdater, + hasTerminal: () => true, + ); + }); + + test('shows a notice when a newer version is available', () async { + const latest = '99.0.0'; + when( + () => pubUpdater.getLatestVersion(any()), + ).thenAnswer((_) async => latest); + + final result = await runnerWithTerminal.run(['--version']); + + expect(result, equals(ExitCode.success.code)); + verify(() => pubUpdater.getLatestVersion(packageName)).called(1); + verify( + () => logger.info( + any(that: contains('Update available!')), + ), + ).called(1); + }); + + test( + 'does not show a notice when already on the latest version', + () async { + when( + () => pubUpdater.getLatestVersion(any()), + ).thenAnswer((_) async => packageVersion); + + final result = await runnerWithTerminal.run(['--version']); + + expect(result, equals(ExitCode.success.code)); + verifyNever( + () => logger.info(any(that: contains('Update available!'))), + ); + }, + ); + + test('reports an error when the update check throws', () async { + when( + () => pubUpdater.getLatestVersion(any()), + ).thenThrow(Exception('network down')); + + final result = await runnerWithTerminal.run(['--version']); + + expect(result, equals(ExitCode.success.code)); + verify(() => logger.err('Failed to check for updates.')).called(1); + }); + + test('skips the update check when running the update command', () async { + when( + () => pubUpdater.getLatestVersion(any()), + ).thenAnswer((_) async => packageVersion); + when( + () => pubUpdater.update( + packageName: any(named: 'packageName'), + versionConstraint: any(named: 'versionConstraint'), + ), + ).thenAnswer( + (_) async => ProcessResult(0, ExitCode.success.code, null, null), + ); + final progress = _MockProgress(); + when(() => logger.progress(any())).thenReturn(progress); + when(() => progress.complete(any())).thenReturn(null); + when(() => progress.fail(any())).thenReturn(null); + + final result = await runnerWithTerminal.run(['update']); + + expect(result, equals(ExitCode.success.code)); + // `getLatestVersion` is called once by `UpdateCommand.run()` itself, + // but `OidcCliCommandRunner._checkForUpdates` must not call it a + // second time when the command being run *is* `update`. + verify(() => pubUpdater.getLatestVersion(packageName)).called(1); + }); + }); }); } diff --git a/packages/oidc_cli/test/src/commands/login/login_device_command_test.dart b/packages/oidc_cli/test/src/commands/login/login_device_command_test.dart new file mode 100644 index 00000000..d95a3c89 --- /dev/null +++ b/packages/oidc_cli/test/src/commands/login/login_device_command_test.dart @@ -0,0 +1,290 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:jose_plus/jose.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:oidc_cli/src/command_runner.dart'; +import 'package:oidc_cli/src/file_oidc_store.dart'; +import 'package:pub_updater/pub_updater.dart'; +import 'package:test/test.dart'; + +class _MockLogger extends Mock implements Logger {} + +class _MockPubUpdater extends Mock implements PubUpdater {} + +Future _writeJson(HttpResponse response, Object json) async { + response.headers.contentType = ContentType.json; + response.write(jsonEncode(json)); + await response.close(); +} + +/// A minimal local OIDC provider that supports the device_authorization +/// grant, so tests can drive `oidc login device` end to end without any +/// mocking of the SDK's HTTP layer. +class _DeviceTestServer { + _DeviceTestServer._(this.server); + + final HttpServer server; + int tokenPollCount = 0; + + /// When true, the token endpoint immediately succeeds. When false, it + /// keeps returning `access_denied` until the poll gives up, so + /// `loginDeviceCodeFlow` resolves to `null` (device auth never completed). + bool succeeds = true; + + /// When false, the device endpoint omits `verification_uri_complete`. + bool includeVerificationUriComplete = true; + + Uri get issuer => Uri.parse('http://127.0.0.1:${server.port}'); + + static Future<_DeviceTestServer> start() async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final signingKey = JsonWebKey.generate('RS256'); + final testServer = _DeviceTestServer._(server); + + server.listen((request) async { + try { + final path = request.uri.path; + if (request.method == 'GET' && + path == '/.well-known/openid-configuration') { + await _writeJson(request.response, { + 'issuer': testServer.issuer.toString(), + 'token_endpoint': testServer.issuer + .replace(path: '/token') + .toString(), + 'device_authorization_endpoint': testServer.issuer + .replace(path: '/device') + .toString(), + 'jwks_uri': testServer.issuer.replace(path: '/jwks').toString(), + 'id_token_signing_alg_values_supported': ['RS256'], + }); + return; + } + if (request.method == 'GET' && path == '/jwks') { + await _writeJson(request.response, { + 'keys': [ + { + 'kty': signingKey['kty'], + 'n': signingKey['n'], + 'e': signingKey['e'], + 'alg': 'RS256', + 'use': 'sig', + }, + ], + }); + return; + } + if (request.method == 'POST' && path == '/device') { + final verification = testServer.issuer + .replace(path: '/verify') + .toString(); + await _writeJson(request.response, { + 'device_code': 'device-code-123', + 'user_code': 'USER-CODE', + 'verification_uri': verification, + if (testServer.includeVerificationUriComplete) + 'verification_uri_complete': '$verification?user_code=USER-CODE', + 'expires_in': testServer.succeeds ? 30 : 1, + 'interval': 0, + }); + return; + } + if (request.method == 'POST' && path == '/token') { + testServer.tokenPollCount += 1; + if (!testServer.succeeds) { + request.response.statusCode = HttpStatus.badRequest; + await _writeJson(request.response, {'error': 'access_denied'}); + return; + } + final nowSeconds = + DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; + final idToken = + (JsonWebSignatureBuilder() + ..jsonContent = { + 'iss': testServer.issuer.toString(), + 'aud': 'my-client', + 'sub': 'user-1', + 'iat': nowSeconds, + 'exp': nowSeconds + 300, + } + ..addRecipient(signingKey, algorithm: 'RS256')) + .build() + .toCompactSerialization(); + await _writeJson(request.response, { + 'access_token': 'device-access-token', + 'id_token': idToken, + 'token_type': 'Bearer', + 'expires_in': 300, + }); + return; + } + request.response.statusCode = HttpStatus.notFound; + await request.response.close(); + } on Object { + request.response.statusCode = HttpStatus.internalServerError; + await request.response.close(); + } + }); + + return testServer; + } + + Future close() => server.close(force: true); +} + +void main() { + group('oidc login device', () { + late Logger logger; + late List infoMessages; + late List errMessages; + late PubUpdater pubUpdater; + late OidcCliCommandRunner runner; + late Directory tempDir; + late String storePath; + + setUp(() { + infoMessages = []; + errMessages = []; + logger = _MockLogger(); + when(() => logger.info(any())).thenAnswer((invocation) { + final message = invocation.positionalArguments.first; + if (message is String) infoMessages.add(message); + }); + when(() => logger.err(any())).thenAnswer((invocation) { + final message = invocation.positionalArguments.first; + if (message is String) errMessages.add(message); + }); + pubUpdater = _MockPubUpdater(); + runner = OidcCliCommandRunner( + logger: logger, + pubUpdater: pubUpdater, + hasTerminal: () => false, + ); + tempDir = Directory.systemTemp.createTempSync( + 'oidc_cli_login_device_test_', + ); + storePath = File('${tempDir.path}/store.json').path; + }); + + tearDown(() { + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + test( + 'requires --issuer and --client-id when neither is given and no ' + 'config is saved', + () async { + final result = await runner.run([ + '--store', + storePath, + 'login', + 'device', + ]); + + expect(result, ExitCode.usage.code); + expect( + errMessages, + contains( + 'Error: --issuer and --client-id are required ' + '(or must exist in the saved config).', + ), + ); + }, + ); + + test( + 'falls back to --issuer/--client-id from a previously saved config', + () async { + final server = await _DeviceTestServer.start(); + addTearDown(server.close); + + final store = FileOidcStore.fromPath(storePath, logger: logger); + await store.setConfig({ + 'issuer': server.issuer.toString(), + 'clientId': 'my-client', + 'clientSecret': null, + 'scopes': ['openid'], + }); + + final result = await runner.run([ + '--store', + storePath, + 'login', + 'device', + ]); + + expect(result, ExitCode.success.code); + expect(infoMessages, contains('device-access-token')); + }, + ); + + test( + 'reports an incomplete device authorization without invoking ' + '`dart pub token add`, even when --add-to-dart-pub is set', + () async { + final server = await _DeviceTestServer.start() + ..succeeds = false; + addTearDown(server.close); + + final result = await runner.run([ + '--store', + storePath, + 'login', + 'device', + '--issuer', + server.issuer.toString(), + '--client-id', + 'my-client', + '--add-to-dart-pub', + 'https://pub.example.com', + ]); + + expect(result, ExitCode.software.code); + expect( + errMessages, + contains('Device authorization did not complete.'), + ); + + // The hostedUrl was still persisted to config (built before the + // login attempt), even though the login itself failed. + final store = FileOidcStore.fromPath(storePath, logger: logger); + final config = await store.getConfig(); + expect(config['hostedUrl'], 'https://pub.example.com'); + }, + ); + + test( + 'prints the user code when the provider does not supply a ' + 'verification_uri_complete', + () async { + final server = await _DeviceTestServer.start() + ..includeVerificationUriComplete = false; + addTearDown(server.close); + + final result = await runner.run([ + '--store', + storePath, + 'login', + 'device', + '--issuer', + server.issuer.toString(), + '--client-id', + 'my-client', + ]); + + expect(result, ExitCode.success.code); + expect( + infoMessages.any( + (m) => m.startsWith('Open this URL to authenticate: '), + ), + isTrue, + ); + expect(infoMessages, contains('User code: USER-CODE')); + expect(infoMessages, contains('device-access-token')); + }, + ); + }); +} diff --git a/packages/oidc_cli/test/src/commands/login/login_interactive_command_test.dart b/packages/oidc_cli/test/src/commands/login/login_interactive_command_test.dart index 187361e5..0ab82caf 100644 --- a/packages/oidc_cli/test/src/commands/login/login_interactive_command_test.dart +++ b/packages/oidc_cli/test/src/commands/login/login_interactive_command_test.dart @@ -3,9 +3,13 @@ import 'dart:io'; import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; import 'package:oidc_cli/src/command_runner.dart'; +import 'package:oidc_cli/src/file_oidc_store.dart'; +import 'package:oidc_core/oidc_core.dart'; import 'package:pub_updater/pub_updater.dart'; import 'package:test/test.dart'; +import '../../support/oidc_test_server.dart'; + class _MockLogger extends Mock implements Logger {} class _MockPubUpdater extends Mock implements PubUpdater {} @@ -15,7 +19,10 @@ void main() { // (via `rundll32`/`open`/`xdg-open`) and starts a real loopback HTTP // listener via `CliUserManager`, so it is intentionally not exercised // here; only the argument-validation paths (which run before any of that) - // are covered. + // are covered, plus the config-persistence/`getStore`/`getManager` wiring + // that runs *before* `manager.init()` would ever reach the browser-opening + // code (exercised below via an unreachable issuer, so `manager.init()` + // itself fails fast on a connection error instead). group('oidc login interactive', () { late Logger logger; late List errMessages; @@ -98,5 +105,80 @@ void main() { contains('Error: --issuer and --client-id are required.'), ); }); + + test( + 'persists config (including hostedUrl) before attempting to reach ' + 'the provider, even when the provider is unreachable', + () async { + // Bind then immediately close a loopback port, guaranteeing that + // subsequent connections to it are refused: this fails inside + // `manager.init()`, which happens *after* the config has already + // been persisted, but *before* the interactive (browser-opening) + // flow would ever be reached. + final probe = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final deadPort = probe.port; + await probe.close(force: true); + + await expectLater( + runner.run([ + '--store', + storePath, + 'login', + 'interactive', + '--issuer', + 'http://127.0.0.1:$deadPort', + '--client-id', + 'my-client', + '--add-to-dart-pub', + 'https://pub.example.com', + ]), + throwsA(anything), + ); + + final store = FileOidcStore.fromPath(storePath, logger: logger); + final config = await store.getConfig(); + expect(config['issuer'], 'http://127.0.0.1:$deadPort'); + expect(config['clientId'], 'my-client'); + expect(config['hostedUrl'], 'https://pub.example.com'); + }, + ); + + test( + 'starts logging in (past a successful manager.init()) and fails fast ' + 'without opening a browser when the provider does not advertise an ' + 'authorizationEndpoint', + () async { + // `TestOidcServer`'s discovery document never advertises an + // `authorization_endpoint`, so `manager.init()` succeeds (a real, + // reachable provider), but `loginAuthorizationCodeFlow()` fails + // fast with an `OidcException` *before* it would ever reach the + // browser-opening code in `CliUserManager.getAuthorizationResponse`. + late final TestOidcServer server; + server = await TestOidcServer.start( + onToken: (form, callCount) => server.tokenResponseJson(sub: 'user-1'), + ); + addTearDown(server.close); + + await expectLater( + runner.run([ + '--store', + storePath, + 'login', + 'interactive', + '--issuer', + server.issuer.toString(), + '--client-id', + 'my-client', + ]), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('authorizationEndpoint'), + ), + ), + ); + }, + ); }); } diff --git a/packages/oidc_cli/test/src/commands/login/login_password_command_test.dart b/packages/oidc_cli/test/src/commands/login/login_password_command_test.dart index ce66c809..480f3515 100644 --- a/packages/oidc_cli/test/src/commands/login/login_password_command_test.dart +++ b/packages/oidc_cli/test/src/commands/login/login_password_command_test.dart @@ -300,5 +300,131 @@ void main() { ); }, ); + + test( + 'authenticates with a confidential client (--client-secret) using ' + 'client_secret_post', + () async { + late final TestOidcServer server; + server = await TestOidcServer.start( + onToken: (form, callCount) { + expect(form['client_secret'], 'shh-its-a-secret'); + return server.tokenResponseJson(sub: 'user-1', expiresIn: 3600); + }, + ); + addTearDown(server.close); + + final result = await runner.run([ + '--store', + storePath, + 'login', + 'password', + '--username', + 'alice', + '--password', + 'secret', + '--issuer', + server.issuer.toString(), + '--client-id', + 'my-client', + '--client-secret', + 'shh-its-a-secret', + ]); + + expect(result, ExitCode.success.code); + expect(infoMessages, contains('Authentication successful!')); + }, + ); + + test( + 'persists --add-to-dart-pub as hostedUrl even when the subsequent ' + 'login attempt fails (so `dart pub token add` is never invoked)', + () async { + late final TestOidcServer server; + server = await TestOidcServer.start( + onToken: (form, callCount) { + // Fail the password grant with a network-level error so + // `loginPassword` throws before ever reaching hostedUrl/pub + // logic; the config write (including hostedUrl) already + // happened earlier in `run()`. + throw Exception('simulated token endpoint outage'); + }, + ); + addTearDown(server.close); + + await expectLater( + runner.run([ + '--store', + storePath, + 'login', + 'password', + '--username', + 'alice', + '--password', + 'secret', + '--issuer', + server.issuer.toString(), + '--client-id', + 'my-client', + '--add-to-dart-pub', + 'https://pub.example.com', + ]), + throwsA(anything), + ); + + final store = FileOidcStore.fromPath(storePath, logger: logger); + final config = await store.getConfig(); + expect(config['hostedUrl'], 'https://pub.example.com'); + }, + ); + + // NOTE: there is intentionally no test for the "Login failed." / + // `user == null` branch below: `OidcUserManagerBase.loginPassword` + // never returns `null` in this SDK -- on any failure (network error, + // missing id_token, invalid signature, ...) `createUserFromToken` / + // `OidcUser.fromIdToken` throw rather than returning `null` (verified + // empirically: a token response with no id_token throws + // `OidcException: Server didn't return the id_token.` instead of + // resolving to `null`). The `if (user == null)` check is defensive + // dead code given the current SDK behavior; see `bugsFound` in this + // package's coverage report. + + test( + 'reports "Refresh failed." when the initial token has no ' + 'refresh_token and the access token is expiring soon', + () async { + late final TestOidcServer server; + server = await TestOidcServer.start( + onToken: (form, callCount) => server.tokenResponseJson( + sub: 'user-1', + expiresIn: 10, + refreshToken: null, + ), + ); + addTearDown(server.close); + + final result = await runner.run([ + '--store', + storePath, + 'login', + 'password', + '--username', + 'alice', + '--password', + 'secret', + '--issuer', + server.issuer.toString(), + '--client-id', + 'my-client', + ]); + + expect(result, ExitCode.software.code); + expect( + infoMessages, + contains('Token expired or expiring soon. Refreshing...'), + ); + expect(errMessages, contains('Refresh failed.')); + }, + ); }); } diff --git a/packages/oidc_cli/test/src/commands/logout_command_test.dart b/packages/oidc_cli/test/src/commands/logout_command_test.dart index be42bb36..0f387888 100644 --- a/packages/oidc_cli/test/src/commands/logout_command_test.dart +++ b/packages/oidc_cli/test/src/commands/logout_command_test.dart @@ -134,5 +134,56 @@ void main() { expect(infoMessages, contains('Logged out successfully.')); }, ); + + test( + 'still reports success (after warning) when remote revocation fails', + () async { + late final TestOidcServer server; + server = await TestOidcServer.start( + advertiseRevocationEndpoint: true, + onToken: (form, callCount) => + server.tokenResponseJson(sub: 'user-1', expiresIn: 3600), + ); + + final loginResult = await runner.run([ + '--store', + storePath, + 'login', + 'password', + '--username', + 'alice', + '--password', + 'secret', + '--issuer', + server.issuer.toString(), + '--client-id', + 'my-client', + ]); + expect(loginResult, ExitCode.success.code); + infoMessages.clear(); + + final warnMessages = []; + when(() => logger.warn(any())).thenAnswer((invocation) { + final message = invocation.positionalArguments.first; + if (message is String) warnMessages.add(message); + }); + + // Take the provider offline so revoking the tokens against the + // (now advertised) revocation_endpoint fails with a connection + // error. + await server.close(); + + final result = await runner.run(['--store', storePath, 'logout']); + + expect(result, ExitCode.success.code); + expect(infoMessages, contains('Logged out successfully.')); + expect( + warnMessages.any( + (m) => m.startsWith('Failed to perform remote logout/revocation: '), + ), + isTrue, + ); + }, + ); }); } diff --git a/packages/oidc_cli/test/src/commands/oidc_base_command_test.dart b/packages/oidc_cli/test/src/commands/oidc_base_command_test.dart new file mode 100644 index 00000000..4dd40cd8 --- /dev/null +++ b/packages/oidc_cli/test/src/commands/oidc_base_command_test.dart @@ -0,0 +1,175 @@ +import 'dart:io'; + +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:oidc_cli/src/commands/oidc_base_command.dart'; +import 'package:oidc_cli/src/file_oidc_store.dart'; +import 'package:test/test.dart'; + +import '../support/oidc_test_server.dart'; + +class _MockLogger extends Mock implements Logger {} + +/// A minimal concrete [OidcBaseCommand] used to directly exercise its +/// protected helper methods without going through a full CLI invocation +/// (and, in particular, without going through any command that would call +/// `addToDartPub`, which shells out to the real `dart` executable). +class _TestCommand extends OidcBaseCommand { + _TestCommand({super.logger}); + + @override + final String name = 'test'; + + @override + final String description = 'test command'; + + @override + Future run() async => 0; +} + +void main() { + late Logger logger; + late Directory tempDir; + late String storePath; + + setUp(() { + logger = _MockLogger(); + when(() => logger.info(any())).thenReturn(null); + when(() => logger.err(any())).thenReturn(null); + tempDir = Directory.systemTemp.createTempSync('oidc_base_command_test_'); + storePath = File('${tempDir.path}/store.json').path; + }); + + tearDown(() { + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + group('getStore', () { + test( + 'falls back to the user-home store path when there is no --store ' + 'override and no OIDC_CLI_STORE env var set', + () async { + // This test only passes when OIDC_CLI_STORE happens to be unset in + // the environment `dart test` runs under (the common case, and true + // for this repo's CI/local setup); `Platform.environment` has no + // `IOOverrides` test seam in dart:io, so the env-var branch + // (`OIDC_CLI_STORE` set) cannot be exercised in the same test run + // as this one without cross-contaminating other tests that assume + // the default (unset) environment. + final envOverride = Platform.environment['OIDC_CLI_STORE']; + + final command = _TestCommand(logger: logger); + final store = await command.getStore(); + + if (envOverride != null && envOverride.trim().isNotEmpty) { + expect(store.file.path, envOverride); + } else { + final expectedHome = + Platform.environment['HOME'] ?? + Platform.environment['USERPROFILE'] ?? + '.'; + expect( + store.file.path, + FileOidcStore.userHome(logger: logger).file.path, + ); + expect(store.file.path, contains(expectedHome)); + expect(store.file.path, contains('.oidc_cli')); + expect(store.file.path, endsWith('store.json')); + } + }, + ); + }); + + group('getManager', () { + test('returns null when the config is empty', () async { + final command = _TestCommand(logger: logger); + final store = FileOidcStore.fromPath(storePath, logger: logger); + + final manager = await command.getManager(store: store); + + expect(manager, isNull); + }); + + test( + 'builds a confidential (client_secret_post) manager when clientSecret ' + 'is present in the config', + () async { + final command = _TestCommand(logger: logger); + final store = FileOidcStore.fromPath(storePath, logger: logger); + + final manager = await command.getManager( + store: store, + configOverride: { + 'issuer': 'https://op.example.com', + 'clientId': 'confidential-client', + 'clientSecret': 'shh-its-a-secret', + 'scopes': ['openid'], + 'port': 3000, + }, + ); + + expect(manager, isNotNull); + expect(manager!.isWeb, isFalse); + }, + ); + }); + + group('getAccessTokenFromStoredSession', () { + test('returns null when there is no saved config', () async { + final command = _TestCommand(logger: logger); + final store = FileOidcStore.fromPath(storePath, logger: logger); + + final token = await command.getAccessTokenFromStoredSession( + store: store, + ); + + expect(token, isNull); + }); + + test( + 'auto-refreshes an expiring stored token and returns the refreshed ' + 'access token', + () async { + late final TestOidcServer server; + server = await TestOidcServer.start( + onToken: (form, callCount) { + if (form['grant_type'] == 'refresh_token') { + return server.tokenResponseJson( + sub: 'user-1', + expiresIn: 3600, + accessToken: 'refreshed-via-base-command', + ); + } + return server.tokenResponseJson(sub: 'user-1', expiresIn: 10); + }, + ); + addTearDown(server.close); + + final command = _TestCommand(logger: logger); + final store = FileOidcStore.fromPath(storePath, logger: logger); + await store.setConfig({ + 'issuer': server.issuer.toString(), + 'clientId': 'my-client', + 'clientSecret': null, + 'scopes': ['openid'], + 'port': 3000, + }); + + // Log in directly through the manager (bypassing any CLI command) + // so a `currentUser` with a near-expiry token exists in the store. + final loginManager = await command.getManager(store: store); + await loginManager!.init(); + await loginManager.loginPassword(username: 'alice', password: 'x'); + await loginManager.dispose(); + + final token = await command.getAccessTokenFromStoredSession( + store: store, + ); + + expect(token, 'refreshed-via-base-command'); + }, + ); + }); +} diff --git a/packages/oidc_cli/test/src/commands/proxy/pub_proxy_command_test.dart b/packages/oidc_cli/test/src/commands/proxy/pub_proxy_command_test.dart index d6f6d67a..c7790264 100644 --- a/packages/oidc_cli/test/src/commands/proxy/pub_proxy_command_test.dart +++ b/packages/oidc_cli/test/src/commands/proxy/pub_proxy_command_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; import 'package:oidc_cli/src/command_runner.dart'; +import 'package:oidc_cli/src/commands/proxy/pub_proxy_command.dart'; import 'package:oidc_cli/src/file_oidc_store.dart'; import 'package:pub_updater/pub_updater.dart'; import 'package:test/test.dart'; @@ -17,10 +18,12 @@ void main() { // NOTE: none of these tests result in a stored access token being found, // so the command never reaches `addToDartPub` (which shells out to the // real `dart pub token add` and would mutate the machine's actual pub - // credentials). `cache list` forwarded to the real `dart` binary below is - // read-only (deliberately NOT `--version`: that string collides with the - // CLI's own global `-v, --version` flag, which short-circuits the whole - // command runner before the subcommand even runs). The issuer always + // credentials). The one test that reaches the real `dart` binary forwards + // `token list`: read-only, and its runtime does not scale with the + // machine's global pub cache the way `cache list` does (which timed out on + // a large dev cache). Deliberately NOT `--version`: that string collides + // with the CLI's own global `-v, --version` flag, which short-circuits the + // whole command runner before the subcommand even runs. The issuer always // points at a local fake OIDC server rather than a real host, so // discovery resolution is deterministic and offline-safe. group('oidc dart pub (proxy)', () { @@ -57,6 +60,11 @@ void main() { } }); + test('description explains what the command proxies', () { + final command = PubProxyCommand(executable: 'dart', logger: logger); + expect(command.description, contains('Proxy commands to ` pub')); + }); + test( 'forwards to `dart pub` and returns its exit code when no ' 'hostedUrl is configured', @@ -66,7 +74,7 @@ void main() { storePath, 'dart', 'pub', - 'cache', + 'token', 'list', ]); @@ -154,5 +162,60 @@ void main() { ); }, ); + + test( + 'errors when the stored session has a blank (non-null) access token', + () async { + late final TestOidcServer server; + server = await TestOidcServer.start( + onToken: (form, callCount) => + server.tokenResponseJson(sub: 'user-1', accessToken: ''), + ); + addTearDown(server.close); + + final store = FileOidcStore.fromPath(storePath, logger: logger); + await store.setConfig({ + 'issuer': server.issuer.toString(), + 'clientId': 'my-client', + 'clientSecret': null, + 'scopes': ['openid'], + 'port': 3000, + 'hostedUrl': 'https://pub.example.com', + }); + + // Log in directly through the CLI's manager wiring so a + // `currentUser` with a blank access token exists in the store. + final loginResult = await runner.run([ + '--store', + storePath, + 'login', + 'password', + '--username', + 'alice', + '--password', + 'secret', + ]); + expect(loginResult, ExitCode.success.code); + errMessages.clear(); + + final result = await runner.run([ + '--store', + storePath, + 'dart', + 'pub', + 'cache', + 'list', + ]); + + expect(result, ExitCode.software.code); + expect( + errMessages, + contains( + 'No active session/token found for pub. ' + 'Run `oidc login --add-to-dart-pub ` first.', + ), + ); + }, + ); }); } diff --git a/packages/oidc_cli/test/src/commands/token/token_commands_test.dart b/packages/oidc_cli/test/src/commands/token/token_commands_test.dart index f352ced2..a7a13294 100644 --- a/packages/oidc_cli/test/src/commands/token/token_commands_test.dart +++ b/packages/oidc_cli/test/src/commands/token/token_commands_test.dart @@ -270,5 +270,109 @@ void main() { expect(infoMessages, contains('refreshed-access-token')); }); }); + + test( + 'refresh: config present but never logged in -> no active session', + () async { + late final TestOidcServer server; + server = await TestOidcServer.start( + onToken: (form, callCount) => server.tokenResponseJson(sub: 'user-1'), + ); + addTearDown(server.close); + + final store = FileOidcStore.fromPath(storePath, logger: logger); + await store.setConfig({ + 'issuer': server.issuer.toString(), + 'clientId': 'my-client', + 'clientSecret': null, + 'scopes': ['openid'], + 'port': 3000, + }); + + final result = await runner.run([ + '--store', + storePath, + 'token', + 'refresh', + ]); + + expect(result, ExitCode.software.code); + expect( + errMessages, + contains('No active session. Please login first.'), + ); + }, + ); + + group('with a session that has no refresh_token', () { + late TestOidcServer server; + + setUp(() async { + server = await TestOidcServer.start( + onToken: (form, callCount) => server.tokenResponseJson( + sub: 'user-1', + expiresIn: 10, + refreshToken: null, + ), + ); + + final loginResult = await runner.run([ + '--store', + storePath, + 'login', + 'password', + '--username', + 'alice', + '--password', + 'secret', + '--issuer', + server.issuer.toString(), + '--client-id', + 'my-client', + '--no-auto-refresh', + ]); + expect(loginResult, ExitCode.success.code); + infoMessages.clear(); + errMessages.clear(); + }); + + tearDown(() => server.close()); + + test( + 'get: reports "No access token available." when auto-refresh ' + 'cannot find a refresh_token', + () async { + final result = await runner.run([ + '--store', + storePath, + 'token', + 'get', + ]); + + expect(result, ExitCode.software.code); + expect( + infoMessages, + contains('Token expired or expiring soon. Refreshing...'), + ); + expect(errMessages, contains('No access token available.')); + }, + ); + + test( + 'refresh: reports "No access token available." when there is no ' + 'refresh_token to use', + () async { + final result = await runner.run([ + '--store', + storePath, + 'token', + 'refresh', + ]); + + expect(result, ExitCode.software.code); + expect(errMessages, contains('No access token available.')); + }, + ); + }); }); } diff --git a/packages/oidc_cli/test/src/commands/update_command_test.dart b/packages/oidc_cli/test/src/commands/update_command_test.dart index d4da01bf..aa72f962 100644 --- a/packages/oidc_cli/test/src/commands/update_command_test.dart +++ b/packages/oidc_cli/test/src/commands/update_command_test.dart @@ -61,6 +61,12 @@ void main() { expect(command, isNotNull); }); + test('can be instantiated with default logger and pubUpdater', () { + final command = UpdateCommand(); + expect(command, isNotNull); + expect(command.name, 'update'); + }); + test('handles pub latest version query errors', () async { when( () => pubUpdater.getLatestVersion(any()), diff --git a/packages/oidc_cli/test/src/file_oidc_store_test.dart b/packages/oidc_cli/test/src/file_oidc_store_test.dart index 6630963d..d04df485 100644 --- a/packages/oidc_cli/test/src/file_oidc_store_test.dart +++ b/packages/oidc_cli/test/src/file_oidc_store_test.dart @@ -35,6 +35,50 @@ void main() { expect(config, isEmpty); }); + test('can be instantiated with a default (non-injected) logger', () { + final defaultStore = FileOidcStore(file: storeFile); + expect(defaultStore, isNotNull); + }); + + test( + 'userHome() builds a path under HOME/USERPROFILE/.oidc_cli/store.json', + () { + final home = + Platform.environment['HOME'] ?? + Platform.environment['USERPROFILE'] ?? + '.'; + final userHomeStore = FileOidcStore.userHome(logger: logger); + expect(userHomeStore.file.path, contains(home)); + expect(userHomeStore.file.path, contains('.oidc_cli')); + expect(userHomeStore.file.path, endsWith('store.json')); + }, + ); + + test('getConfig returns empty map when the store file is empty', () async { + storeFile + ..createSync(recursive: true) + ..writeAsStringSync(''); + + final config = await store.getConfig(); + expect(config, isEmpty); + }); + + test( + 'setConfig creates missing parent directories before writing', + () async { + final nestedFile = File( + '${tempDir.path}/nested/does/not/exist/store.json', + ); + final nestedStore = FileOidcStore(file: nestedFile, logger: logger); + + await nestedStore.setConfig({'issuer': 'https://example.com'}); + + expect(nestedFile.existsSync(), isTrue); + final config = await nestedStore.getConfig(); + expect(config['issuer'], 'https://example.com'); + }, + ); + test('setConfig persists and getConfig reads it back', () async { await store.setConfig({'issuer': 'https://example.com'}); diff --git a/packages/oidc_cli/test/src/support/oidc_test_server.dart b/packages/oidc_cli/test/src/support/oidc_test_server.dart index bc908950..fc75fdb2 100644 --- a/packages/oidc_cli/test/src/support/oidc_test_server.dart +++ b/packages/oidc_cli/test/src/support/oidc_test_server.dart @@ -41,7 +41,9 @@ class TestOidcServer { String clientId = 'my-client', int expiresIn = 300, String accessToken = 'access-token-abc', - String refreshToken = 'refresh-token-1', + // `null` omits `refresh_token` from the response entirely, so callers + // can exercise the "no refresh_token available" branch of `refreshToken`. + String? refreshToken = 'refresh-token-1', }) { final nowSeconds = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; final idToken = signIdToken({ @@ -56,7 +58,7 @@ class TestOidcServer { 'id_token': idToken, 'token_type': 'Bearer', 'expires_in': expiresIn, - 'refresh_token': refreshToken, + 'refresh_token': ?refreshToken, }; } @@ -71,6 +73,11 @@ class TestOidcServer { int callCount, ) onToken, + // When true, discovery advertises a `revocation_endpoint` (pointing at + // this same server's `/revoke` path, which always answers 200), so + // callers can exercise `revokeAccessToken`/`revokeRefreshToken` actually + // making a network call instead of the endpoint-not-advertised no-op. + bool advertiseRevocationEndpoint = false, }) async { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); final signingKey = JsonWebKey.generate('RS256'); @@ -89,9 +96,18 @@ class TestOidcServer { .toString(), 'jwks_uri': testServer.issuer.replace(path: '/jwks').toString(), 'id_token_signing_alg_values_supported': ['RS256'], + if (advertiseRevocationEndpoint) + 'revocation_endpoint': testServer.issuer + .replace(path: '/revoke') + .toString(), }); return; } + if (request.method == 'POST' && path == '/revoke') { + request.response.statusCode = HttpStatus.ok; + await request.response.close(); + return; + } if (request.method == 'GET' && path == '/jwks') { await _writeJson(request.response, { 'keys': [testServer._publicJwk()], diff --git a/packages/oidc_core/lib/src/utils.dart b/packages/oidc_core/lib/src/utils.dart index 36bc5ea9..950f147b 100644 --- a/packages/oidc_core/lib/src/utils.dart +++ b/packages/oidc_core/lib/src/utils.dart @@ -214,9 +214,12 @@ class OidcUtils { /// from by dropping the trailing `['.well-known','openid-configuration']` /// path segments (and clearing query/fragment). /// - /// Returns `null` when [wellKnown] does not end with those two segments (e.g. - /// an RFC 8414 insert-layout URL or an Entra `?appid=` query URL that cannot - /// be inverted), so callers can detect that the issuer could not be derived. + /// Returns `null` when [wellKnown] does not end with those two segments + /// (e.g. an RFC 8414 insert-layout URL), so callers can detect that the + /// issuer could not be derived. A query-carrying well-known URL with the + /// standard path layout (e.g. an Entra `?appid=` form) IS inverted: the + /// query and fragment are dropped from the derived issuer, and any + /// downstream mismatch is caught by [issuersAreIdentical]. static Uri? getIssuerFromOpenIdConfigWellKnownUri(Uri wellKnown) { final segments = wellKnown.pathSegments; if (segments.length < 2 || @@ -224,10 +227,17 @@ class OidcUtils { segments[segments.length - 1] != 'openid-configuration') { return null; } - return wellKnown.replace( + // NOTE: `wellKnown.replace(query: null, fragment: null)` would NOT clear + // an existing query/fragment — `Uri.replace`'s `null` means "keep the + // current value", not "clear it" (and `query: ''` would instead leave a + // dangling `?`). Building a fresh [Uri] from components is the only way + // to genuinely omit them. + return Uri( + scheme: wellKnown.scheme, + userInfo: wellKnown.userInfo.isEmpty ? null : wellKnown.userInfo, + host: wellKnown.host, + port: wellKnown.hasPort ? wellKnown.port : null, pathSegments: segments.sublist(0, segments.length - 2), - query: null, - fragment: null, ); } diff --git a/packages/oidc_core/test/internal_utils_test.dart b/packages/oidc_core/test/internal_utils_test.dart index 7369d237..89951a2b 100644 --- a/packages/oidc_core/test/internal_utils_test.dart +++ b/packages/oidc_core/test/internal_utils_test.dart @@ -239,5 +239,58 @@ void main() { isNull, ); }); + + test( + 'strips a query and fragment from the well-known URL ' + '(doc contract: "and clearing query/fragment")', + () { + final issuer = OidcUtils.getIssuerFromOpenIdConfigWellKnownUri( + Uri.parse( + 'https://op.example.com/.well-known/openid-configuration' + '?x=1#y', + ), + ); + expect(issuer, isNotNull); + expect(issuer!.hasQuery, isFalse); + expect(issuer.hasFragment, isFalse); + expect(issuer, Uri.parse('https://op.example.com')); + // Also assert byte-identical serialization: no leftover `?`/`#` + // (a naive `Uri.replace(query: '')` fix would leave a dangling `?`). + expect(issuer.toString(), 'https://op.example.com'); + }, + ); + + test( + 'recovers a tenant issuer from an Entra-style `?appid=` well-known URL ' + 'with the query cleared', + () { + final issuer = OidcUtils.getIssuerFromOpenIdConfigWellKnownUri( + Uri.parse( + 'https://login.microsoftonline.com/common/v2.0' + '/.well-known/openid-configuration' + '?appid=6731de76-14a6-49ae-97bc-6eba6914391e', + ), + ); + expect( + issuer, + Uri.parse('https://login.microsoftonline.com/common/v2.0'), + ); + expect(issuer!.hasQuery, isFalse); + }, + ); + + test('preserves userInfo and a non-default port', () { + final issuer = OidcUtils.getIssuerFromOpenIdConfigWellKnownUri( + Uri.parse( + 'https://user:pass@op.example.com:8443/realm' + '/.well-known/openid-configuration?x=1', + ), + ); + expect( + issuer, + Uri.parse('https://user:pass@op.example.com:8443/realm'), + ); + expect(issuer!.hasQuery, isFalse); + }); }); } diff --git a/packages/oidc_core/test/library_surface_test.dart b/packages/oidc_core/test/library_surface_test.dart new file mode 100644 index 00000000..52441805 --- /dev/null +++ b/packages/oidc_core/test/library_surface_test.dart @@ -0,0 +1,42 @@ +// Ensures every part of the public barrel (`oidc_core.dart`) is actually +// exercised at least once, so coverage tooling loads it even for symbols +// that no other test happens to touch. `OidcPushedAuthorizationRequest` in +// particular is a marker type with no fields, so nothing in the rest of the +// suite ever constructs it even though the barrel exports it transitively. +import 'package:oidc_core/oidc_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('oidc_core library surface', () { + test( + 'OidcPushedAuthorizationRequest is a constructible, non-canonicalized ' + 'marker type', + () { + // See https://datatracker.ietf.org/doc/html/rfc9126 - this type + // documents the PAR request shape; today it carries no fields, but + // constructing it proves the barrel export resolves to a real, + // instantiable class (not an unresolved forward reference), and + // since the constructor isn't `const`, two separate calls must + // allocate two distinct instances rather than one canonicalized + // value. + final a = OidcPushedAuthorizationRequest(); + final b = OidcPushedAuthorizationRequest(); + expect(a.runtimeType, OidcPushedAuthorizationRequest); + expect(identical(a, b), isFalse); + }, + ); + + test( + 'OidcUserInfoAccessTokenLocations has the two RFC-documented values', + () { + expect( + OidcUserInfoAccessTokenLocations.values, + const [ + OidcUserInfoAccessTokenLocations.authorizationHeader, + OidcUserInfoAccessTokenLocations.formParameter, + ], + ); + }, + ); + }); +} diff --git a/packages/oidc_core/test/pure_models_coverage_test.dart b/packages/oidc_core/test/pure_models_coverage_test.dart new file mode 100644 index 00000000..df192e58 --- /dev/null +++ b/packages/oidc_core/test/pure_models_coverage_test.dart @@ -0,0 +1,548 @@ +@TestOn('vm') +library; + +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:jose_plus/jose.dart'; +import 'package:oidc_core/oidc_core.dart'; +import 'package:oidc_core/src/models/json_based_object.dart'; +import 'package:test/test.dart'; + +void main() { + group('OidcMonitorSessionResult subtypes', () { + test('error result reports isError only', () { + const r = OidcErrorMonitorSessionResult(); + expect(r.isError(), isTrue); + expect(r.isValidResult(), isFalse); + expect(r.isChanged(), isFalse); + expect(r.getUnknownResult(), isNull); + }); + + test('valid unchanged result', () { + const r = OidcValidMonitorSessionResult(changed: false); + expect(r.isError(), isFalse); + expect(r.isValidResult(), isTrue); + expect(r.isChanged(), isFalse); + expect(r.getUnknownResult(), isNull); + }); + + test('valid changed result reports isChanged', () { + const r = OidcValidMonitorSessionResult(changed: true); + expect(r.isValidResult(), isTrue); + expect(r.isChanged(), isTrue); + }); + + test('unknown result exposes its raw data', () { + const r = OidcUnknownMonitorSessionResult(data: 'raw-value'); + expect(r.isError(), isFalse); + expect(r.isValidResult(), isFalse); + expect(r.isChanged(), isFalse); + expect(r.getUnknownResult(), 'raw-value'); + }); + + test('base result answers all predicates negatively', () { + const r = OidcMonitorSessionResult(); + expect(r.isError(), isFalse); + expect(r.isValidResult(), isFalse); + expect(r.isChanged(), isFalse); + expect(r.getUnknownResult(), isNull); + }); + }); + + group('OidcMonitorSessionStatusRequest', () { + test('carries all fields', () { + const req = OidcMonitorSessionStatusRequest( + clientId: 'client-1', + sessionState: 'sess-1', + interval: Duration(seconds: 3), + ); + expect(req.clientId, 'client-1'); + expect(req.sessionState, 'sess-1'); + expect(req.interval, const Duration(seconds: 3)); + }); + }); + + group('OidcIntrospectionResponse getters', () { + test('reads every typed member from a rich response', () { + final resp = OidcIntrospectionResponse.fromJson({ + 'active': true, + 'scope': 'openid profile', + 'client_id': 'client-42', + 'username': 'alice', + 'token_type': 'Bearer', + 'sub': 'subject-1', + 'iss': 'https://op.example.com', + 'jti': 'jwt-id-1', + 'aud': ['a', 'b'], + 'exp': 1000, + 'iat': 900, + 'nbf': 950, + 'custom': 'x', + }); + expect(resp.active, isTrue); + expect(resp.scope, 'openid profile'); + expect(resp.clientId, 'client-42'); + expect(resp.username, 'alice'); + expect(resp.tokenType, 'Bearer'); + expect(resp.subject, 'subject-1'); + expect(resp.issuer, 'https://op.example.com'); + expect(resp.jwtId, 'jwt-id-1'); + expect(resp.audience, ['a', 'b']); + expect( + resp.expiry, + DateTime.fromMillisecondsSinceEpoch(1000 * 1000, isUtc: true), + ); + expect( + resp.issuedAt, + DateTime.fromMillisecondsSinceEpoch(900 * 1000, isUtc: true), + ); + expect( + resp.notBefore, + DateTime.fromMillisecondsSinceEpoch(950 * 1000, isUtc: true), + ); + // Non-standard members remain reachable via the source map operator. + expect(resp['custom'], 'x'); + }); + + test('single-string aud is wrapped in a list', () { + final resp = OidcIntrospectionResponse.fromJson({ + 'active': true, + 'aud': 'only-one', + }); + expect(resp.audience, ['only-one']); + }); + + test('absent aud and numeric dates return null', () { + final resp = OidcIntrospectionResponse.fromJson({'active': false}); + expect(resp.active, isFalse); + expect(resp.audience, isNull); + expect(resp.expiry, isNull); + expect(resp.issuedAt, isNull); + expect(resp.notBefore, isNull); + expect(resp.scope, isNull); + }); + }); + + group('OidcStepUpChallenge.toString', () { + test('includes scheme, error, acrValues and maxAge', () { + final challenge = OidcStepUpChallenge.parse( + 'Bearer error="insufficient_user_authentication", ' + 'acr_values="urn:acr:1 urn:acr:2", max_age=120', + ); + expect(challenge, isNotNull); + expect(challenge!.isInsufficientUserAuthentication, isTrue); + final text = challenge.toString(); + expect(text, contains('OidcStepUpChallenge')); + expect(text, contains('Bearer')); + expect( + text, + contains(OidcStepUpChallenge.insufficientUserAuthenticationError), + ); + expect(text, contains('urn:acr:1')); + expect(text, contains('0:02:00')); + }); + }); + + group('JsonBasedRequest / JsonBasedResponse base behaviour', () { + test('operator []= writes into extra and toMap surfaces it', () { + final req = OidcTokenRequest.clientCredentials( + clientId: 'c1', + extra: {}, + )..['custom_field'] = 'v'; + expect(req.extra['custom_field'], 'v'); + expect(req.toMap()['custom_field'], 'v'); + }); + + test('response operator [] and toString reflect the source map', () { + final resp = OidcIntrospectionResponse.fromJson({ + 'active': true, + 'anything': 42, + }); + expect(resp['anything'], 42); + expect(resp.toString(), contains('anything')); + }); + + test('readSrcMap returns the whole source map', () { + final src = {'active': true, 'k': 'v'}; + expect(readSrcMap(src, 'ignored-key'), same(src)); + }); + }); + + group('OidcOfflineAuthErrorHandler remaining branches', () { + test('categorizeOidcError maps service_unavailable to serverError', () { + final type = OidcOfflineAuthErrorHandler.categorizeOidcError( + OidcErrorResponse.fromJson({'error': 'service_unavailable'}), + ); + expect(type, OfflineAuthErrorType.serverError); + }); + + test('categorizeOidcError maps temporarily_unavailable to serverError', () { + final type = OidcOfflineAuthErrorHandler.categorizeOidcError( + OidcErrorResponse.fromJson({'error': 'temporarily_unavailable'}), + ); + expect(type, OfflineAuthErrorType.serverError); + }); + + test('categorizeOidcError maps an unknown error code to clientError', () { + final type = OidcOfflineAuthErrorHandler.categorizeOidcError( + OidcErrorResponse.fromJson({'error': 'some_unmapped_error'}), + ); + expect(type, OfflineAuthErrorType.clientError); + }); + + test('an unclassifiable error is unknown and allowed to continue', () { + final type = OidcOfflineAuthErrorHandler.categorizeError(Object()); + expect(type, OfflineAuthErrorType.unknown); + expect( + OidcOfflineAuthErrorHandler.shouldContinueInOfflineMode( + error: Object(), + supportOfflineAuth: true, + ), + isTrue, + reason: + 'unknown errors are treated conservatively (stay online-cached)', + ); + }); + + test('getOfflineModeReason covers every error type', () { + final reasons = { + for (final t in OfflineAuthErrorType.values) + t: OidcOfflineAuthErrorHandler.getOfflineModeReason(t), + }; + expect( + reasons[OfflineAuthErrorType.networkUnavailable], + OfflineModeReason.networkUnavailable, + ); + expect( + reasons[OfflineAuthErrorType.networkTimeout], + OfflineModeReason.networkUnavailable, + ); + expect( + reasons[OfflineAuthErrorType.serverError], + OfflineModeReason.serverUnavailable, + ); + expect( + reasons[OfflineAuthErrorType.sslError], + OfflineModeReason.serverUnavailable, + ); + expect( + reasons[OfflineAuthErrorType.authenticationError], + OfflineModeReason.serverUnavailable, + ); + expect( + reasons[OfflineAuthErrorType.clientError], + OfflineModeReason.serverUnavailable, + ); + expect( + reasons[OfflineAuthErrorType.unknown], + OfflineModeReason.serverUnavailable, + ); + }); + + test('getErrorMessage produces a distinct message per type', () { + final messages = { + for (final t in OfflineAuthErrorType.values) + OidcOfflineAuthErrorHandler.getErrorMessage(t), + }; + expect(messages, hasLength(OfflineAuthErrorType.values.length)); + }); + }); + + group('OidcUserManagerHooks execute* run the default execution', () { + final metadata = OidcProviderMetadata.fromJson({ + 'issuer': 'https://op.example.com', + 'token_endpoint': 'https://op.example.com/token', + }); + final hooks = OidcUserManagerHooks(); + + test('executeToken runs default when no token hook is set', () async { + final response = OidcTokenResponse.fromJson({'access_token': 'at'}); + final out = await hooks.executeToken( + request: OidcTokenHookRequest( + metadata: metadata, + tokenEndpoint: metadata.tokenEndpoint!, + request: OidcTokenRequest.clientCredentials(clientId: 'c1'), + credentials: const OidcClientAuthentication.none(clientId: 'c1'), + headers: const {}, + options: const OidcPlatformSpecificOptions(), + client: null, + ), + defaultExecution: (_) async => response, + ); + expect(out, same(response)); + }); + + test('executeAuthorization runs default when no hook is set', () async { + final response = OidcAuthorizeResponse.fromJson({'code': 'c'}); + final out = await hooks.executeAuthorization( + request: OidcAuthorizationHookRequest( + metadata: metadata, + request: OidcAuthorizeRequest( + clientId: 'c1', + redirectUri: Uri.parse('com.example://cb'), + responseType: const ['code'], + scope: const ['openid'], + ), + options: const OidcPlatformSpecificOptions(), + preparationResult: const {}, + ), + defaultExecution: (_) async => response, + ); + expect(out, same(response)); + }); + + test('executeRevocation runs default when no hook is set', () async { + final response = OidcRevocationResponse.fromJson(const {}); + final out = await hooks.executeRevocation( + request: OidcRevocationHookRequest( + metadata: metadata, + revocationEndpoint: Uri.parse('https://op.example.com/revoke'), + request: OidcRevocationRequest(token: 't'), + options: const OidcPlatformSpecificOptions(), + client: null, + credentials: const OidcClientAuthentication.none(clientId: 'c1'), + headers: const {}, + ), + defaultExecution: (_) async => response, + ); + expect(out, same(response)); + }); + }); + + group('OidcHookGroup.modifyExecution without an execution hook', () { + test('falls back to the default execution', () async { + final group = OidcHookGroup(hooks: []); + final result = await group.modifyExecution( + 'in', + (req) async => 'out:$req', + ); + expect(result, 'out:in'); + }); + }); + + group('defaultOfflineRefreshRetryDelay backoff', () { + test('first failure yields the 30s base delay', () { + expect(defaultOfflineRefreshRetryDelay(1), const Duration(seconds: 30)); + }); + test('second failure doubles to 1 minute', () { + expect(defaultOfflineRefreshRetryDelay(2), const Duration(minutes: 1)); + }); + test('large failure counts are capped at 5 minutes', () { + expect(defaultOfflineRefreshRetryDelay(10), const Duration(minutes: 5)); + }); + }); + + group('OidcTokenResponse.isOidc', () { + test('true when an id_token is present', () { + expect( + OidcTokenResponse.fromJson({'id_token': 'x'}).isOidc, + isTrue, + ); + }); + test('false when the id_token is empty or missing', () { + expect(OidcTokenResponse.fromJson({'id_token': ''}).isOidc, isFalse); + expect(OidcTokenResponse.fromJson(const {}).isOidc, isFalse); + }); + }); + + group('OidcToken.fromResponse override plumbing', () { + test('applies overrideExpiresIn and sessionState', () { + final token = OidcToken.fromResponse( + OidcTokenResponse.fromJson({ + 'access_token': 'at', + 'token_type': 'Bearer', + }), + overrideExpiresIn: const Duration(minutes: 42), + sessionState: 'sess-xyz', + ); + expect(token.accessToken, 'at'); + expect(token.expiresIn, const Duration(minutes: 42)); + expect(token.sessionState, 'sess-xyz'); + }); + }); + + group('OidcTokenRequest secondary constructors', () { + test('password constructor sets the password grant', () { + final req = OidcTokenRequest.password( + username: 'u', + password: 'p', + scope: const ['openid'], + clientId: 'c1', + ); + expect(req.grantType, OidcConstants_GrantType.password); + expect(req.username, 'u'); + expect(req.password, 'p'); + expect(req.refreshToken, isNull); + }); + + test('saml2 constructor sets the assertion and grant', () { + final req = OidcTokenRequest.saml2( + assertion: 'the-assertion', + scope: const ['openid'], + clientId: 'c1', + ); + expect(req.grantType, OidcConstants_GrantType.saml2Bearer); + expect(req.assertion, 'the-assertion'); + }); + + test('primary constructor is usable directly', () { + final req = OidcTokenRequest( + grantType: OidcConstants_GrantType.authorizationCode, + clientId: 'c1', + code: 'the-code', + redirectUri: Uri.parse('com.example://cb'), + ); + expect(req.grantType, OidcConstants_GrantType.authorizationCode); + expect(req.code, 'the-code'); + }); + }); + + group('Event const constructors preserve their payload', () { + final at = DateTime.utc(2026, 3, 4); + final token = OidcToken( + accessToken: 'at', + tokenType: 'Bearer', + creationTime: at, + ); + + test('OidcTokenExpiredEvent', () { + final e = OidcTokenExpiredEvent(currentToken: token, at: at); + expect(e.currentToken, same(token)); + expect(e.at, at); + }); + test('OidcTokenExpiringEvent', () { + final e = OidcTokenExpiringEvent(currentToken: token, at: at); + expect(e.currentToken, same(token)); + expect(e.at, at); + }); + test('OidcOfflineModeEnteredEvent', () { + final e = OidcOfflineModeEnteredEvent( + reason: OfflineModeReason.networkUnavailable, + at: at, + currentToken: token, + ); + expect(e.reason, OfflineModeReason.networkUnavailable); + expect(e.currentToken, same(token)); + }); + test('OidcOfflineModeExitedEvent', () { + final e = OidcOfflineModeExitedEvent(networkRestored: true, at: at); + expect(e.networkRestored, isTrue); + expect(e.at, at); + }); + test('OidcOfflineAuthWarningEvent', () { + final e = OidcOfflineAuthWarningEvent( + warningType: OfflineAuthWarningType.usingExpiredToken, + message: 'hi', + at: at, + ); + expect(e.warningType, OfflineAuthWarningType.usingExpiredToken); + expect(e.message, 'hi'); + }); + }); + + group('OidcRequestObjectSettings / OidcIdTokenVerificationOptions', () { + test('request object settings expose their configuration', () { + final key = JsonWebKey.generate('RS256'); + final settings = OidcRequestObjectSettings( + signingKey: key, + algorithm: 'RS256', + ); + expect(settings.signingKey, same(key)); + expect(settings.algorithm, 'RS256'); + expect(settings.lifetime, const Duration(minutes: 5)); + expect(settings.clockSkew, Duration.zero); + }); + + test('id token verification options default to non-validating', () { + const options = OidcIdTokenVerificationOptions(); + expect(options.validateAudience, isFalse); + expect(options.validateIssuer, isFalse); + expect(options.expiryTolerance, Duration.zero); + expect(options.keyStore, isNull); + }); + }); + + group('OidcInternalUtilities.sendWithClient', () { + test('uses a provided client without disposing it', () async { + final client = MockClient( + (req) async => http.Response('hello', 200), + ); + final res = await OidcInternalUtilities.sendWithClient( + client: client, + request: http.Request('GET', Uri.parse('https://op.example.com/x')), + ); + expect(res.statusCode, 200); + expect(res.body, 'hello'); + // The client was borrowed, so it is still usable afterwards. + final again = await client.get(Uri.parse('https://op.example.com/y')); + expect(again.statusCode, 200); + }); + + test( + 'creates and disposes its own client when none is provided', + () async { + // A null client makes the helper allocate (and, in its finally block, + // dispose) a real http.Client. Point it at a closed local port so the + // send fails immediately without any external network dependency. + await expectLater( + OidcInternalUtilities.sendWithClient( + client: null, + request: http.Request( + 'GET', + Uri.parse('http://127.0.0.1:1/never'), + ), + ), + throwsA(isA()), + ); + }, + ); + }); + + group('OidcValueStream', () { + test('caches its value and reports closed state', () async { + final stream = OidcValueStream(1); + expect(stream.value, 1); + expect(stream.isClosed, isFalse); + stream.add(2); + expect(stream.value, 2); + await stream.close(); + expect(stream.isClosed, isTrue); + // A no-op emit after close keeps the last value and does not throw. + stream.add(3); + expect(stream.value, 3); + }); + }); + + group('OidcPreLogoutEvent', () { + test('const constructor stores the current user', () async { + final key = JsonWebKey.generate('RS256'); + final now = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; + final idToken = + (JsonWebSignatureBuilder() + ..jsonContent = { + 'iss': 'https://op.example.com', + 'sub': 'user-1', + 'aud': 'client-1', + 'iat': now, + 'exp': now + 3600, + } + ..addRecipient(key, algorithm: 'RS256')) + .build() + .toCompactSerialization(); + final user = await OidcUser.fromIdToken( + token: OidcToken( + creationTime: DateTime.utc(2026, 3, 4), + idToken: idToken, + accessToken: 'at', + tokenType: 'Bearer', + ), + keystore: JsonWebKeyStore()..addKey(key), + ); + final event = OidcPreLogoutEvent( + currentUser: user, + at: DateTime.utc(2026, 3, 4), + ); + expect(event.currentUser, same(user)); + }); + }); +} diff --git a/packages/oidc_core/test/user_manager_flows_coverage_test.dart b/packages/oidc_core/test/user_manager_flows_coverage_test.dart new file mode 100644 index 00000000..d78df09f --- /dev/null +++ b/packages/oidc_core/test/user_manager_flows_coverage_test.dart @@ -0,0 +1,680 @@ +@TestOn('vm') +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:clock/clock.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:jose_plus/jose.dart'; +import 'package:oidc_core/oidc_core.dart'; +import 'package:test/test.dart'; + +const _issuer = 'https://op.example.com'; +final _signingKey = JsonWebKey.generate('RS256'); + +String _signIdToken({ + String subject = 'user-1', + String? nonce, + Duration expiresIn = const Duration(hours: 1), + String issuer = _issuer, +}) { + final now = clock.now().millisecondsSinceEpoch ~/ 1000; + return (JsonWebSignatureBuilder() + ..jsonContent = { + 'iss': issuer, + 'sub': subject, + 'aud': 'client-1', + 'exp': now + expiresIn.inSeconds, + 'iat': now, + 'nonce': ?nonce, + } + ..addRecipient(_signingKey, algorithm: 'RS256')) + .build() + .toCompactSerialization(); +} + +/// A concrete manager whose platform-channel methods are supplied as closures +/// so a single harness can drive code / implicit / logout flows. +class _FlowManager extends OidcUserManagerBase { + _FlowManager({ + required super.discoveryDocument, + required super.clientCredentials, + required super.store, + required super.settings, + super.httpClient, + super.keyStore, + this.onAuthorize, + this.onEndSession, + }); + + Future Function(OidcAuthorizeRequest request)? + onAuthorize; + Future Function(OidcEndSessionRequest request)? + onEndSession; + + void seed(OidcUser user) => userSubject.add(user); + + // Thin public wrappers so the test can drive the @protected surface. + Future exchangeTokenTest() => exchangeToken(); + Future saveUserTest(OidcUser user) => saveUser(user); + + @override + bool get isWeb => false; + + @override + Future getAuthorizationResponse( + OidcProviderMetadata metadata, + OidcAuthorizeRequest request, + OidcPlatformSpecificOptions options, + Map preparationResult, + ) async => onAuthorize == null ? null : onAuthorize!(request); + + @override + Future getEndSessionResponse( + OidcProviderMetadata metadata, + OidcEndSessionRequest request, + OidcPlatformSpecificOptions options, + Map preparationResult, + ) async => onEndSession == null ? null : onEndSession!(request); + + @override + Map prepareForRedirectFlow( + OidcPlatformSpecificOptions options, + ) => const {}; + + @override + Stream + listenToFrontChannelLogoutRequests( + Uri listenOn, + OidcFrontChannelRequestListeningOptions options, + ) => const Stream.empty(); + + @override + Stream monitorSessionStatus({ + required Uri checkSessionIframe, + required OidcMonitorSessionStatusRequest request, + }) => const Stream.empty(); +} + +OidcProviderMetadata _metadata({ + Map extra = const {}, +}) => OidcProviderMetadata.fromJson({ + 'issuer': _issuer, + 'authorization_endpoint': '$_issuer/authorize', + 'token_endpoint': '$_issuer/token', + 'userinfo_endpoint': '$_issuer/userinfo', + 'revocation_endpoint': '$_issuer/revoke', + 'introspection_endpoint': '$_issuer/introspect', + ...extra, +}); + +Future<_FlowManager> _build({ + required http.Client client, + OidcProviderMetadata? metadata, + OidcUserManagerSettings? settings, + Future Function(OidcAuthorizeRequest request)? + onAuthorize, + Future Function(OidcEndSessionRequest request)? + onEndSession, + OidcStore? store, +}) async { + final manager = _FlowManager( + discoveryDocument: metadata ?? _metadata(), + clientCredentials: const OidcClientAuthentication.none( + clientId: 'client-1', + ), + store: store ?? OidcMemoryStore(), + httpClient: client, + keyStore: JsonWebKeyStore()..addKey(_signingKey), + onAuthorize: onAuthorize, + onEndSession: onEndSession, + settings: + settings ?? + OidcUserManagerSettings( + redirectUri: Uri.parse('com.example.app://cb'), + userInfoSettings: const OidcUserInfoSettings( + sendUserInfoRequest: false, + ), + ), + ); + await manager.init(); + return manager; +} + +void main() { + group('loginPassword', () { + test('creates a verified user from the token response', () async { + final client = MockClient((req) async { + if (req.url.path.endsWith('/token')) { + final body = Uri.splitQueryString(req.body); + expect(body['grant_type'], OidcConstants_GrantType.password); + return http.Response( + jsonEncode({ + 'access_token': 'at-pw', + 'token_type': 'Bearer', + 'expires_in': 3600, + 'id_token': _signIdToken(), + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 404); + }); + final manager = await _build(client: client); + + final user = await manager.loginPassword( + username: 'alice', + password: 'secret', + ); + + expect(user, isNotNull); + expect(user!.token.accessToken, 'at-pw'); + expect(user.parsedIdToken.isVerified, isTrue); + expect(manager.currentUser?.uid, user.uid); + await manager.dispose(); + }); + }); + + group('loginImplicitFlow (deprecated)', () { + test('builds a user from a front-channel implicit response', () async { + final client = MockClient((req) async => http.Response('{}', 404)); + final manager = await _build( + client: client, + metadata: OidcProviderMetadata.fromJson({ + 'issuer': _issuer, + 'authorization_endpoint': '$_issuer/authorize', + 'token_endpoint': '$_issuer/token', + }), + onAuthorize: (request) async => OidcAuthorizeResponse.fromJson({ + 'state': request.state, + 'access_token': 'at-implicit', + 'token_type': 'Bearer', + 'id_token': _signIdToken(nonce: request.nonce), + 'expires_in': '3600', + }), + ); + + // ignore: deprecated_member_use_from_same_package + final user = await manager.loginImplicitFlow( + responseType: const ['id_token', 'token'], + ); + + expect(user, isNotNull); + expect(user!.token.accessToken, 'at-implicit'); + expect(user.parsedIdToken.isVerified, isTrue); + await manager.dispose(); + }); + }); + + group('loginAuthorizationCodeFlow (full success)', () { + test('exchanges the code and builds a verified user', () async { + String? capturedNonce; + final client = MockClient((req) async { + if (req.url.path.endsWith('/token')) { + return http.Response( + jsonEncode({ + 'access_token': 'at-code', + 'token_type': 'Bearer', + 'expires_in': 3600, + 'refresh_token': 'rt-code', + 'id_token': _signIdToken(nonce: capturedNonce), + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 404); + }); + final manager = await _build( + client: client, + onAuthorize: (request) async { + capturedNonce = request.nonce; + return OidcAuthorizeResponse.fromJson({ + 'code': 'auth-code-1', + 'state': request.state, + }); + }, + ); + + final user = await manager.loginAuthorizationCodeFlow(); + expect(user, isNotNull); + expect(user!.token.accessToken, 'at-code'); + expect(user.token.refreshToken, 'rt-code'); + expect(manager.currentUser, isNotNull); + await manager.dispose(); + }); + + test( + 'a cancelled authorization (null response) leaves no current user', + () async { + final client = MockClient((req) async => http.Response('{}', 404)); + final manager = await _build( + client: client, + onAuthorize: (request) async => null, + ); + final user = await manager.loginAuthorizationCodeFlow(); + expect(user, isNull); + expect(manager.currentUser, isNull); + await manager.dispose(); + }, + ); + }); + + group('logout', () { + test( + 'with no post_logout_redirect_uri forgets the user immediately', + () async { + final client = MockClient((req) async => http.Response('{}', 200)); + final manager = await _build(client: client); + manager.seed(await _seedUser()); + expect(manager.currentUser, isNotNull); + + await manager.logout(); + expect(manager.currentUser, isNull); + await manager.dispose(); + }, + ); + + test( + 'with a redirect uri round-trips end-session state and forgets the user', + () async { + final client = MockClient((req) async => http.Response('{}', 200)); + final manager = await _build( + client: client, + settings: OidcUserManagerSettings( + redirectUri: Uri.parse('com.example.app://cb'), + postLogoutRedirectUri: Uri.parse('com.example.app://logout'), + userInfoSettings: const OidcUserInfoSettings( + sendUserInfoRequest: false, + ), + ), + onEndSession: (request) async => OidcEndSessionResponse.fromJson({ + 'state': request.state, + }), + ); + manager.seed(await _seedUser()); + + await manager.logout(); + expect(manager.currentUser, isNull); + await manager.dispose(); + }, + ); + + test( + 'a null end-session result still forgets the user (native platform)', + () async { + final client = MockClient((req) async => http.Response('{}', 200)); + final manager = await _build( + client: client, + settings: OidcUserManagerSettings( + redirectUri: Uri.parse('com.example.app://cb'), + postLogoutRedirectUri: Uri.parse('com.example.app://logout'), + userInfoSettings: const OidcUserInfoSettings( + sendUserInfoRequest: false, + ), + ), + onEndSession: (request) async => null, + ); + manager.seed(await _seedUser()); + + await manager.logout(); + expect(manager.currentUser, isNull); + await manager.dispose(); + }, + ); + + test('is a no-op when there is no current user', () async { + final client = MockClient((req) async => http.Response('{}', 200)); + final manager = await _build(client: client); + await manager.logout(); + expect(manager.currentUser, isNull); + await manager.dispose(); + }); + + test( + 'revokeTokensOnLogout best-effort revokes before ending the session', + () async { + final revoked = []; + final client = MockClient((req) async { + if (req.url.path.endsWith('/revoke')) { + revoked.add(Uri.splitQueryString(req.body)['token'] ?? ''); + return http.Response('', 200); + } + return http.Response('{}', 200); + }); + final manager = await _build( + client: client, + settings: OidcUserManagerSettings( + redirectUri: Uri.parse('com.example.app://cb'), + userInfoSettings: const OidcUserInfoSettings( + sendUserInfoRequest: false, + ), + ), + ); + manager.seed(await _seedUser()); + + await manager.logout(); + expect(revoked, containsAll(['rt-seed', 'at-seed'])); + expect(manager.currentUser, isNull); + await manager.dispose(); + }, + ); + }); + + group('revokeAccessToken / revokeRefreshToken', () { + test('revoking the access token forgets the user on success', () async { + final client = MockClient((req) async { + if (req.url.path.endsWith('/revoke')) return http.Response('', 200); + return http.Response('{}', 404); + }); + final manager = await _build(client: client); + manager.seed(await _seedUser()); + await manager.revokeAccessToken(); + expect(manager.currentUser, isNull); + await manager.dispose(); + }); + + test('revoking the refresh token forgets the user on success', () async { + final client = MockClient((req) async { + if (req.url.path.endsWith('/revoke')) return http.Response('', 200); + return http.Response('{}', 404); + }); + final manager = await _build(client: client); + manager.seed(await _seedUser()); + await manager.revokeRefreshToken(); + expect(manager.currentUser, isNull); + await manager.dispose(); + }); + + test('no revocation endpoint is a no-op that keeps the user', () async { + final client = MockClient((req) async => http.Response('{}', 404)); + final manager = await _build( + client: client, + metadata: OidcProviderMetadata.fromJson({ + 'issuer': _issuer, + 'token_endpoint': '$_issuer/token', + }), + ); + manager.seed(await _seedUser()); + await manager.revokeAccessToken(); + await manager.revokeRefreshToken(); + expect(manager.currentUser, isNotNull); + await manager.dispose(); + }); + + test('revocation is a no-op when there is no current user', () async { + final client = MockClient((req) async => http.Response('', 200)); + final manager = await _build(client: client); + await manager.revokeAccessToken(); + await manager.revokeRefreshToken(); + expect(manager.currentUser, isNull); + await manager.dispose(); + }); + }); + + group('introspectToken', () { + test('returns the parsed introspection response', () async { + final client = MockClient((req) async { + if (req.url.path.endsWith('/introspect')) { + return http.Response( + jsonEncode({'active': true, 'scope': 'openid'}), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 404); + }); + final manager = await _build(client: client); + manager.seed(await _seedUser()); + + final resp = await manager.introspectToken(); + expect(resp.active, isTrue); + expect(resp.scope, 'openid'); + await manager.dispose(); + }); + + test('throws when the provider has no introspection endpoint', () async { + final client = MockClient((req) async => http.Response('{}', 404)); + final manager = await _build( + client: client, + metadata: OidcProviderMetadata.fromJson({ + 'issuer': _issuer, + 'token_endpoint': '$_issuer/token', + }), + ); + manager.seed(await _seedUser()); + await expectLater( + manager.introspectToken(), + throwsA(isA()), + ); + await manager.dispose(); + }); + + test('throws when there is no token to introspect', () async { + final client = MockClient((req) async => http.Response('{}', 404)); + final manager = await _build(client: client); + await expectLater( + manager.introspectToken(), + throwsA(isA()), + ); + await manager.dispose(); + }); + }); + + group('exchangeToken (RFC 8693)', () { + test('exchanges the current access token for a new one', () async { + final client = MockClient((req) async { + if (req.url.path.endsWith('/token')) { + final body = Uri.splitQueryString(req.body); + expect(body['grant_type'], OidcConstants_GrantType.tokenExchange); + expect(body['subject_token'], 'at-seed'); + return http.Response( + jsonEncode({'access_token': 'exchanged', 'token_type': 'Bearer'}), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 404); + }); + final manager = await _build(client: client); + manager.seed(await _seedUser()); + + final resp = await manager.exchangeTokenTest(); + expect(resp.accessToken, 'exchanged'); + // Exchange does not change the logged-in user. + expect(manager.currentUser?.token.accessToken, 'at-seed'); + await manager.dispose(); + }); + + test( + 'throws when neither a subject token nor a user is available', + () async { + final client = MockClient((req) async => http.Response('{}', 404)); + final manager = await _build(client: client); + await expectLater( + manager.exchangeTokenTest(), + throwsA(isA()), + ); + await manager.dispose(); + }, + ); + }); + + group('refreshToken offline handling', () { + test( + 'a network failure with supportOfflineAuth keeps the cached user and ' + 'enters offline mode', + () async { + final client = MockClient((req) async { + if (req.url.path.endsWith('/token')) { + throw const SocketException('offline'); + } + return http.Response('{}', 404); + }); + final manager = await _build( + client: client, + settings: OidcUserManagerSettings( + redirectUri: Uri.parse('com.example.app://cb'), + supportOfflineAuth: true, + userInfoSettings: const OidcUserInfoSettings( + sendUserInfoRequest: false, + ), + ), + ); + final seeded = await _seedUser(); + manager.seed(seeded); + + final events = []; + final sub = manager.events().listen(events.add); + + final result = await manager.refreshToken(); + await Future.delayed(Duration.zero); + + expect(result?.token.accessToken, seeded.token.accessToken); + expect(manager.isInOfflineMode, isTrue); + expect( + events.whereType(), + isNotEmpty, + reason: 'a recoverable network error must announce offline mode', + ); + await sub.cancel(); + await manager.dispose(); + }, + ); + + test( + 'a network failure without offline support rethrows the error', + () async { + final client = MockClient((req) async { + if (req.url.path.endsWith('/token')) { + throw const SocketException('offline'); + } + return http.Response('{}', 404); + }); + final manager = await _build(client: client); + manager.seed(await _seedUser()); + await expectLater( + manager.refreshToken(), + throwsA(isA()), + ); + await manager.dispose(); + }, + ); + + test('returns null when there is no refresh token', () async { + final client = MockClient((req) async => http.Response('{}', 404)); + final manager = await _build(client: client); + // No user seeded -> no refresh token. + expect(await manager.refreshToken(), isNull); + await manager.dispose(); + }); + }); + + group('loadCachedTokens on re-init', () { + test( + 'a persisted valid token is restored into a new manager instance', + () async { + final store = OidcMemoryStore(); + final client = MockClient((req) async => http.Response('{}', 404)); + + final first = await _build(client: client, store: store); + // Persist a user via the manager save path. + final user = await _seedUser(); + await first.saveUserTest(user); + await first.dispose(); + + // A fresh manager over the same store must reload the cached user. + final second = await _build(client: client, store: store); + expect(second.currentUser, isNotNull); + expect(second.currentUser?.token.accessToken, 'at-seed'); + await second.dispose(); + }, + ); + + test( + 'a persisted expired token with a refresh token is refreshed on load', + () async { + final store = OidcMemoryStore(); + String tokenBody() => jsonEncode({ + 'access_token': 'refreshed-at', + 'token_type': 'Bearer', + 'expires_in': 3600, + 'refresh_token': 'rt-seed', + 'id_token': _signIdToken(), + }); + final client = MockClient((req) async { + if (req.url.path.endsWith('/token')) { + return http.Response( + tokenBody(), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 404); + }); + + final first = await _build(client: client, store: store); + await first.saveUserTest(await _seedUser(expired: true)); + await first.dispose(); + + final second = await _build(client: client, store: store); + expect(second.currentUser, isNotNull); + expect(second.currentUser?.token.accessToken, 'refreshed-at'); + await second.dispose(); + }, + ); + }); + + group('loginDeviceCodeFlow endpoint guards', () { + test('throws when the provider has no token endpoint', () async { + final client = MockClient((req) async => http.Response('{}', 404)); + final manager = await _build( + client: client, + metadata: OidcProviderMetadata.fromJson({'issuer': _issuer}), + ); + await expectLater( + manager.loginDeviceCodeFlow(), + throwsA(isA()), + ); + await manager.dispose(); + }); + + test( + 'throws when the provider has no device_authorization_endpoint', + () async { + final client = MockClient((req) async => http.Response('{}', 404)); + final manager = await _build( + client: client, + metadata: OidcProviderMetadata.fromJson({ + 'issuer': _issuer, + 'token_endpoint': '$_issuer/token', + }), + ); + await expectLater( + manager.loginDeviceCodeFlow(), + throwsA(isA()), + ); + await manager.dispose(); + }, + ); + }); +} + +Future _seedUser({bool expired = false}) => OidcUser.fromIdToken( + token: OidcToken( + creationTime: expired + ? clock.now().subtract(const Duration(hours: 2)) + : clock.now(), + idToken: _signIdToken( + expiresIn: expired ? const Duration(hours: -1) : const Duration(hours: 1), + ), + accessToken: 'at-seed', + refreshToken: 'rt-seed', + tokenType: 'Bearer', + expiresIn: const Duration(hours: 1), + ), +); diff --git a/packages/oidc_core/test/user_manager_internals_coverage_test.dart b/packages/oidc_core/test/user_manager_internals_coverage_test.dart new file mode 100644 index 00000000..00072314 --- /dev/null +++ b/packages/oidc_core/test/user_manager_internals_coverage_test.dart @@ -0,0 +1,1154 @@ +@TestOn('vm') +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:clock/clock.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:jose_plus/jose.dart'; +import 'package:oidc_core/oidc_core.dart'; +import 'package:test/test.dart'; + +const _issuer = 'https://op.example.com'; +final _signingKey = JsonWebKey.generate('RS256'); + +String _signIdToken({ + String subject = 'user-1', + String? nonce, + Duration expiresIn = const Duration(hours: 1), + String issuer = _issuer, +}) { + final now = clock.now().millisecondsSinceEpoch ~/ 1000; + return (JsonWebSignatureBuilder() + ..jsonContent = { + 'iss': issuer, + 'sub': subject, + 'aud': 'client-1', + 'exp': now + expiresIn.inSeconds, + 'iat': now, + 'nonce': ?nonce, + } + ..addRecipient(_signingKey, algorithm: 'RS256')) + .build() + .toCompactSerialization(); +} + +String _rawIdToken(Map claims) => + (JsonWebSignatureBuilder() + ..jsonContent = claims + ..addRecipient(_signingKey, algorithm: 'RS256')) + .build() + .toCompactSerialization(); + +class _M extends OidcUserManagerBase { + _M({ + required super.discoveryDocument, + required super.clientCredentials, + required super.store, + required super.settings, + super.httpClient, + super.keyStore, + this.onAuthorize, + this.monitorStream, + }); + + _M.lazy({ + required super.discoveryDocumentUri, + required super.clientCredentials, + required super.store, + required super.settings, + super.httpClient, + super.keyStore, + }) : onAuthorize = null, + monitorStream = null, + super.lazy(); + + Future Function(OidcAuthorizeRequest request)? + onAuthorize; + Future Function(OidcEndSessionRequest request)? + onEndSessionOverride; + Stream? monitorStream; + + void seed(OidcUser user) => userSubject.add(user); + Future saveUserRaw(OidcUser user) => saveUser(user); + + // Public wrappers over the @protected extension surface. + Future validateAndSaveUserTest(OidcUser user) => + validateAndSaveUser(user: user, metadata: discoveryDocument); + Future handleTokenExpiringTest(OidcToken t) => handleTokenExpiring(t); + void handleTokenExpiredTest(OidcToken t) => handleTokenExpired(t); + void emitWarningTest() => emitOfflineAuthWarning( + warningType: OfflineAuthWarningType.usingExpiredToken, + message: 'test', + ); + bool offlineExcessiveTest() => isOfflineDurationExcessive(); + Future reAuthorizeUserTest() => reAuthorizeUser(); + + @override + bool get isWeb => false; + + @override + Future getAuthorizationResponse( + OidcProviderMetadata metadata, + OidcAuthorizeRequest request, + OidcPlatformSpecificOptions options, + Map preparationResult, + ) async => onAuthorize == null ? null : onAuthorize!(request); + + @override + Future getEndSessionResponse( + OidcProviderMetadata metadata, + OidcEndSessionRequest request, + OidcPlatformSpecificOptions options, + Map preparationResult, + ) async => + onEndSessionOverride == null ? null : onEndSessionOverride!(request); + + @override + Map prepareForRedirectFlow( + OidcPlatformSpecificOptions options, + ) => const {}; + + @override + Stream + listenToFrontChannelLogoutRequests( + Uri listenOn, + OidcFrontChannelRequestListeningOptions options, + ) => const Stream.empty(); + + @override + Stream monitorSessionStatus({ + required Uri checkSessionIframe, + required OidcMonitorSessionStatusRequest request, + }) => monitorStream ?? const Stream.empty(); +} + +OidcProviderMetadata _metadata({Map extra = const {}}) => + OidcProviderMetadata.fromJson({ + 'issuer': _issuer, + 'authorization_endpoint': '$_issuer/authorize', + 'token_endpoint': '$_issuer/token', + 'userinfo_endpoint': '$_issuer/userinfo', + ...extra, + }); + +Future _seedUser({ + bool expired = false, + String subject = 'user-1', + String? sessionState, +}) => OidcUser.fromIdToken( + token: OidcToken( + creationTime: expired + ? clock.now().subtract(const Duration(hours: 2)) + : clock.now(), + idToken: _signIdToken( + subject: subject, + expiresIn: expired ? const Duration(hours: -1) : const Duration(hours: 1), + ), + accessToken: 'at-seed', + refreshToken: 'rt-seed', + tokenType: 'Bearer', + expiresIn: const Duration(hours: 1), + sessionState: sessionState, + ), +); + +_M _make({ + required http.Client client, + OidcProviderMetadata? metadata, + OidcUserManagerSettings? settings, + Future Function(OidcAuthorizeRequest request)? + onAuthorize, + Future Function(OidcEndSessionRequest request)? + onEndSession, + Stream? monitorStream, + OidcStore? store, +}) => _M( + discoveryDocument: metadata ?? _metadata(), + clientCredentials: const OidcClientAuthentication.none(clientId: 'client-1'), + store: store ?? OidcMemoryStore(), + httpClient: client, + keyStore: JsonWebKeyStore()..addKey(_signingKey), + onAuthorize: onAuthorize, + monitorStream: monitorStream, + settings: + settings ?? + OidcUserManagerSettings(redirectUri: Uri.parse('com.example.app://cb')), +)..onEndSessionOverride = onEndSession; + +void main() { + group('validateAndSaveUser + UserInfo', () { + test('a matching-sub UserInfo response is merged into the user', () async { + final client = MockClient((req) async { + if (req.url.path.endsWith('/token')) { + return http.Response( + jsonEncode({ + 'access_token': 'at', + 'token_type': 'Bearer', + 'expires_in': 3600, + 'id_token': _signIdToken(), + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + if (req.url.path.endsWith('/userinfo')) { + return http.Response( + jsonEncode({'sub': 'user-1', 'email': 'a@b.com'}), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 404); + }); + final manager = _make(client: client); + await manager.init(); + + final user = await manager.loginPassword(username: 'u', password: 'p'); + expect(user, isNotNull); + expect(user!.userInfo['email'], 'a@b.com'); + expect(manager.lastSuccessfulServerContact, isNotNull); + await manager.dispose(); + }); + + test('a mismatched-sub UserInfo response fails validation', () async { + final client = MockClient((req) async { + if (req.url.path.endsWith('/token')) { + return http.Response( + jsonEncode({ + 'access_token': 'at', + 'token_type': 'Bearer', + 'id_token': _signIdToken(), + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + if (req.url.path.endsWith('/userinfo')) { + return http.Response( + jsonEncode({'sub': 'someone-else'}), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 404); + }); + final manager = _make(client: client); + await manager.init(); + + final user = await manager.loginPassword(username: 'u', password: 'p'); + expect(user, isNull, reason: 'sub mismatch must reject the login'); + await manager.dispose(); + }); + + test( + 'a UserInfo network error enters offline mode and keeps the user', + () async { + final client = MockClient((req) async { + if (req.url.path.endsWith('/token')) { + return http.Response( + jsonEncode({ + 'access_token': 'at', + 'token_type': 'Bearer', + 'id_token': _signIdToken(), + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + if (req.url.path.endsWith('/userinfo')) { + throw const SocketException('offline'); + } + return http.Response('{}', 404); + }); + final manager = _make( + client: client, + settings: OidcUserManagerSettings( + redirectUri: Uri.parse('com.example.app://cb'), + supportOfflineAuth: true, + ), + ); + await manager.init(); + final events = []; + final sub = manager.events().listen(events.add); + + final user = await manager.loginPassword(username: 'u', password: 'p'); + await Future.delayed(Duration.zero); + + expect( + user, + isNotNull, + reason: 'cached identity survives userinfo loss', + ); + expect(manager.isInOfflineMode, isTrue); + expect(events.whereType(), isNotEmpty); + await sub.cancel(); + await manager.dispose(); + }, + ); + }); + + group('createUserFromToken account-swap guard', () { + test('a refreshed id_token with a different sub is rejected', () async { + final client = MockClient((req) async { + if (req.url.path.endsWith('/token')) { + return http.Response( + jsonEncode({ + 'access_token': 'at2', + 'token_type': 'Bearer', + 'refresh_token': 'rt2', + 'id_token': _signIdToken(subject: 'attacker'), + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 404); + }); + final manager = _make( + client: client, + settings: OidcUserManagerSettings( + redirectUri: Uri.parse('com.example.app://cb'), + userInfoSettings: const OidcUserInfoSettings( + sendUserInfoRequest: false, + ), + ), + ); + await manager.init(); + manager.seed(await _seedUser()); + + await expectLater( + manager.refreshToken(), + throwsA( + predicate( + (e) => e is OidcException && e.toString().contains('account swap'), + ), + ), + ); + await manager.dispose(); + }); + }); + + group('offline / expiry protected handlers', () { + test( + 'handleTokenExpired forgets the user without offline support', + () async { + final client = MockClient((req) async => http.Response('{}', 404)); + final manager = _make( + client: client, + settings: OidcUserManagerSettings( + redirectUri: Uri.parse('com.example.app://cb'), + userInfoSettings: const OidcUserInfoSettings( + sendUserInfoRequest: false, + ), + ), + ); + await manager.init(); + final user = await _seedUser(); + manager.seed(user); + final events = []; + final sub = manager.events().listen(events.add); + + manager.handleTokenExpiredTest(user.token); + await Future.delayed(Duration.zero); + + expect(events.whereType(), isNotEmpty); + expect(manager.currentUser, isNull); + await sub.cancel(); + await manager.dispose(); + }, + ); + + test( + 'handleTokenExpired in offline mode warns instead of logging out', + () async { + final client = MockClient((req) async { + if (req.url.path.endsWith('/token')) { + throw const SocketException('offline'); + } + return http.Response('{}', 404); + }); + final manager = _make( + client: client, + settings: OidcUserManagerSettings( + redirectUri: Uri.parse('com.example.app://cb'), + supportOfflineAuth: true, + userInfoSettings: const OidcUserInfoSettings( + sendUserInfoRequest: false, + ), + ), + ); + await manager.init(); + final user = await _seedUser(expired: true); + manager.seed(user); + // Enter offline mode via a failed manual refresh. + await manager.refreshToken(); + expect(manager.isInOfflineMode, isTrue); + + final events = []; + final sub = manager.events().listen(events.add); + manager.handleTokenExpiredTest(user.token); + await Future.delayed(Duration.zero); + + expect( + events.whereType(), + isNotEmpty, + reason: 'expired token in offline mode should warn, not log out', + ); + expect(manager.currentUser, isNotNull); + await sub.cancel(); + await manager.dispose(); + }, + ); + + test('handleTokenExpiring refreshes the token on success', () async { + final client = MockClient((req) async { + if (req.url.path.endsWith('/token')) { + return http.Response( + jsonEncode({ + 'access_token': 'refreshed', + 'token_type': 'Bearer', + 'expires_in': 3600, + 'refresh_token': 'rt-new', + 'id_token': _signIdToken(), + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 404); + }); + final manager = _make( + client: client, + settings: OidcUserManagerSettings( + redirectUri: Uri.parse('com.example.app://cb'), + userInfoSettings: const OidcUserInfoSettings( + sendUserInfoRequest: false, + ), + ), + ); + await manager.init(); + final user = await _seedUser(); + manager.seed(user); + final events = []; + final sub = manager.events().listen(events.add); + + await manager.handleTokenExpiringTest(user.token); + + expect(events.whereType(), isNotEmpty); + expect(manager.currentUser?.token.accessToken, 'refreshed'); + await sub.cancel(); + await manager.dispose(); + }); + + test( + 'handleTokenExpiring is a no-op when there is no refresh token', + () async { + final client = MockClient((req) async => http.Response('{}', 404)); + final manager = _make( + client: client, + settings: OidcUserManagerSettings( + redirectUri: Uri.parse('com.example.app://cb'), + userInfoSettings: const OidcUserInfoSettings( + sendUserInfoRequest: false, + ), + ), + ); + await manager.init(); + final noRefresh = OidcToken( + accessToken: 'at', + tokenType: 'Bearer', + creationTime: clock.now(), + expiresIn: const Duration(hours: 1), + ); + final events = []; + final sub = manager.events().listen(events.add); + await manager.handleTokenExpiringTest(noRefresh); + expect(events.whereType(), isNotEmpty); + await sub.cancel(); + await manager.dispose(); + }, + ); + + test('emitOfflineAuthWarning surfaces a warning event', () async { + final client = MockClient((req) async => http.Response('{}', 404)); + final manager = _make(client: client); + await manager.init(); + final events = []; + final sub = manager.events().listen(events.add); + manager.emitWarningTest(); + await Future.delayed(Duration.zero); + expect(events.whereType(), isNotEmpty); + expect(manager.offlineExcessiveTest(), isFalse); + await sub.cancel(); + await manager.dispose(); + }); + }); + + group('session monitoring', () { + Future<_M> build(Stream stream) async { + String? capturedNonce; + final client = MockClient((req) async { + if (req.url.path.endsWith('/token')) { + return http.Response( + jsonEncode({ + 'access_token': 'reauth-at', + 'token_type': 'Bearer', + 'id_token': _signIdToken(nonce: capturedNonce), + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 404); + }); + final manager = _make( + client: client, + metadata: _metadata( + extra: {'check_session_iframe': '$_issuer/checksession'}, + ), + monitorStream: stream, + onAuthorize: (request) async { + capturedNonce = request.nonce; + return OidcAuthorizeResponse.fromJson({ + 'code': 'reauth-code', + 'state': request.state, + }); + }, + settings: OidcUserManagerSettings( + redirectUri: Uri.parse('com.example.app://cb'), + sessionManagementSettings: const OidcSessionManagementSettings( + enabled: true, + ), + userInfoSettings: const OidcUserInfoSettings( + sendUserInfoRequest: false, + ), + ), + ); + await manager.init(); + return manager; + } + + test('a changed session triggers re-authorization', () async { + final controller = StreamController.broadcast(); + final manager = await build(controller.stream); + manager.seed(await _seedUser(sessionState: 'sess-1')); + await Future.delayed(Duration.zero); + + controller.add(const OidcValidMonitorSessionResult(changed: true)); + await Future.delayed(const Duration(milliseconds: 50)); + + // Re-authorization ran the code flow with the reauth token. + expect(manager.currentUser?.token.accessToken, 'reauth-at'); + await controller.close(); + await manager.dispose(); + }); + + test('an unchanged/unknown session result is a no-op', () async { + final controller = StreamController.broadcast(); + final manager = await build(controller.stream); + manager.seed(await _seedUser(sessionState: 'sess-1')); + await Future.delayed(Duration.zero); + + controller + ..add(const OidcValidMonitorSessionResult(changed: false)) + ..add(const OidcUnknownMonitorSessionResult(data: 'x')); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(manager.currentUser?.token.accessToken, 'at-seed'); + await controller.close(); + await manager.dispose(); + }); + + test('a session error result stops monitoring', () async { + final controller = StreamController.broadcast(); + final manager = await build(controller.stream); + manager.seed(await _seedUser(sessionState: 'sess-1')); + await Future.delayed(Duration.zero); + + controller.add(const OidcErrorMonitorSessionResult()); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(manager.currentUser?.token.accessToken, 'at-seed'); + await controller.close(); + await manager.dispose(); + }); + }); + + group('ensureDiscoveryDocument (lazy)', () { + OidcProviderMetadata docJson() => OidcProviderMetadata.fromJson({ + 'issuer': _issuer, + 'authorization_endpoint': '$_issuer/authorize', + 'token_endpoint': '$_issuer/token', + }); + + final wellKnown = OidcUtils.getOpenIdConfigWellKnownUri( + Uri.parse(_issuer), + ); + + test( + 'fetches, validates and caches the document from the network', + () async { + final store = OidcMemoryStore(); + final client = MockClient((req) async { + if (req.url.path.contains('openid-configuration')) { + return http.Response( + jsonEncode(docJson().src), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 404); + }); + final manager = _M.lazy( + discoveryDocumentUri: wellKnown, + clientCredentials: const OidcClientAuthentication.none( + clientId: 'client-1', + ), + store: store, + httpClient: client, + keyStore: JsonWebKeyStore()..addKey(_signingKey), + settings: OidcUserManagerSettings( + redirectUri: Uri.parse('com.example.app://cb'), + userInfoSettings: const OidcUserInfoSettings( + sendUserInfoRequest: false, + ), + ), + ); + await manager.init(); + expect(manager.discoveryDocument.issuer.toString(), _issuer); + + // Second lazy manager over the same store loads it from cache. + final cached = await store.get( + OidcStoreNamespace.discoveryDocument, + key: wellKnown.toString(), + ); + expect(cached, isNotNull); + await manager.dispose(); + }, + ); + + test( + 'throws when the document cannot be fetched and is not cached', + () async { + final client = MockClient((req) async { + throw const SocketException('offline'); + }); + final manager = _M.lazy( + discoveryDocumentUri: wellKnown, + clientCredentials: const OidcClientAuthentication.none( + clientId: 'client-1', + ), + store: OidcMemoryStore(), + httpClient: client, + settings: OidcUserManagerSettings( + redirectUri: Uri.parse('com.example.app://cb'), + ), + ); + await expectLater(manager.init(), throwsA(isA())); + }, + ); + }); + + group('loadStateResult (redirect return)', () { + test( + 'a stored authorize state + response is processed on the next init', + () async { + final store = OidcMemoryStore(); + String? capturedNonce; + String? capturedState; + final client = MockClient((req) async { + if (req.url.path.endsWith('/token')) { + return http.Response( + jsonEncode({ + 'access_token': 'redirect-at', + 'token_type': 'Bearer', + 'expires_in': 3600, + 'id_token': _signIdToken(nonce: capturedNonce), + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 404); + }); + + // First manager prepares (and stores) the authorize state, then a + // redirect "response" is injected while the flow is abandoned — exactly + // the web same-page redirect-away shape that init() resumes. + final first = _make( + client: client, + store: store, + settings: OidcUserManagerSettings( + redirectUri: Uri.parse('com.example.app://cb'), + userInfoSettings: const OidcUserInfoSettings( + sendUserInfoRequest: false, + ), + ), + onAuthorize: (request) async { + capturedNonce = request.nonce; + capturedState = request.state; + await store.setStateResponseData( + state: request.state!, + stateData: Uri.parse('com.example.app://cb') + .replace( + queryParameters: { + 'code': 'redirect-code', + 'state': request.state, + }, + ) + .toString(), + ); + return null; + }, + ); + await first.init(); + final abandoned = await first.loginAuthorizationCodeFlow(); + expect(abandoned, isNull); + expect(capturedState, isNotNull); + await first.dispose(); + + // A fresh manager over the same store resumes the pending response. + final second = _make( + client: client, + store: store, + settings: OidcUserManagerSettings( + redirectUri: Uri.parse('com.example.app://cb'), + userInfoSettings: const OidcUserInfoSettings( + sendUserInfoRequest: false, + ), + ), + ); + await second.init(); + expect(second.currentUser, isNotNull); + expect(second.currentUser?.token.accessToken, 'redirect-at'); + await second.dispose(); + }, + ); + }); + + group('loadLogoutRequests', () { + test('a stored front-channel logout request is consumed on init', () async { + final store = OidcMemoryStore(); + final requestUri = Uri.parse('com.example.app://cb').replace( + queryParameters: { + OidcConstants_Store.requestType: + OidcConstants_Store.frontChannelLogout, + }, + ); + await store.set( + OidcStoreNamespace.request, + key: OidcConstants_Store.frontChannelLogout, + value: requestUri.toString(), + ); + final client = MockClient((req) async => http.Response('{}', 404)); + final manager = _make( + client: client, + store: store, + settings: OidcUserManagerSettings( + redirectUri: Uri.parse('com.example.app://cb'), + userInfoSettings: const OidcUserInfoSettings( + sendUserInfoRequest: false, + ), + ), + ); + await manager.init(); + // The request was consumed (removed) during init. + final leftover = await store.getCurrentFrontChannelLogoutRequest(); + expect(leftover, isNull); + await manager.dispose(); + }); + }); + + group('offline mode exit', () { + test('a successful refresh after a failure exits offline mode', () async { + var failing = true; + final client = MockClient((req) async { + if (req.url.path.endsWith('/token')) { + if (failing) throw const SocketException('offline'); + return http.Response( + jsonEncode({ + 'access_token': 'recovered', + 'token_type': 'Bearer', + 'expires_in': 3600, + 'refresh_token': 'rt-new', + 'id_token': _signIdToken(), + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 404); + }); + final manager = _make( + client: client, + settings: OidcUserManagerSettings( + redirectUri: Uri.parse('com.example.app://cb'), + supportOfflineAuth: true, + userInfoSettings: const OidcUserInfoSettings( + sendUserInfoRequest: false, + ), + ), + ); + await manager.init(); + manager.seed(await _seedUser()); + + await manager.refreshToken(); + expect(manager.isInOfflineMode, isTrue); + + final events = []; + final sub = manager.events().listen(events.add); + failing = false; + final recovered = await manager.refreshToken(); + + expect(recovered?.token.accessToken, 'recovered'); + expect(manager.isInOfflineMode, isFalse); + expect(events.whereType(), isNotEmpty); + await sub.cancel(); + await manager.dispose(); + }); + }); + + group('reAuthorizeUser error handling', () { + test( + 'an authorization error without offline support forgets the user', + () async { + final client = MockClient((req) async => http.Response('{}', 404)); + final manager = _make( + client: client, + settings: OidcUserManagerSettings( + redirectUri: Uri.parse('com.example.app://cb'), + userInfoSettings: const OidcUserInfoSettings( + sendUserInfoRequest: false, + ), + ), + onAuthorize: (request) async => throw OidcException.serverError( + errorResponse: OidcErrorResponse.fromJson({ + 'error': 'login_required', + 'state': ?request.state, + }), + ), + ); + await manager.init(); + manager.seed(await _seedUser()); + + final result = await manager.reAuthorizeUserTest(); + expect(result, isNull); + expect(manager.currentUser, isNull); + await manager.dispose(); + }, + ); + }); + + group('createUserFromToken issuer-swap guard', () { + test('a refreshed id_token with a different issuer is rejected', () async { + final client = MockClient((req) async { + if (req.url.path.endsWith('/token')) { + return http.Response( + jsonEncode({ + 'access_token': 'at2', + 'token_type': 'Bearer', + 'refresh_token': 'rt2', + 'id_token': _signIdToken(issuer: 'https://evil.example.com'), + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 404); + }); + final manager = _make( + client: client, + settings: OidcUserManagerSettings( + redirectUri: Uri.parse('com.example.app://cb'), + userInfoSettings: const OidcUserInfoSettings( + sendUserInfoRequest: false, + ), + ), + ); + await manager.init(); + manager.seed(await _seedUser()); + + await expectLater( + manager.refreshToken(), + throwsA( + predicate( + (e) => + e is OidcException && e.toString().contains('does not match'), + ), + ), + ); + await manager.dispose(); + }); + }); + + group('validateUser id_token edge cases (rejected logins)', () { + Future loginWith(Map claims) async { + final client = MockClient((req) async { + if (req.url.path.endsWith('/token')) { + return http.Response( + jsonEncode({ + 'access_token': 'at', + 'token_type': 'Bearer', + 'id_token': _rawIdToken(claims), + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 404); + }); + final manager = _make( + client: client, + settings: OidcUserManagerSettings( + redirectUri: Uri.parse('com.example.app://cb'), + userInfoSettings: const OidcUserInfoSettings( + sendUserInfoRequest: false, + ), + ), + ); + await manager.init(); + final user = await manager.loginPassword(username: 'u', password: 'p'); + await manager.dispose(); + return user; + } + + final now = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; + + test('an azp that is not the client_id is rejected', () async { + expect( + await loginWith({ + 'iss': _issuer, + 'sub': 'user-1', + 'aud': 'client-1', + 'iat': now, + 'exp': now + 3600, + 'azp': 'not-the-client', + }), + isNull, + ); + }); + + test('multiple audiences without azp is rejected', () async { + expect( + await loginWith({ + 'iss': _issuer, + 'sub': 'user-1', + 'aud': ['client-1', 'another'], + 'iat': now, + 'exp': now + 3600, + }), + isNull, + ); + }); + + test('a not-yet-valid (future nbf) id_token is rejected', () async { + expect( + await loginWith({ + 'iss': _issuer, + 'sub': 'user-1', + 'aud': 'client-1', + 'iat': now, + 'exp': now + 3600, + 'nbf': now + 3600, + }), + isNull, + ); + }); + + test('an id_token missing the sub claim is rejected', () async { + expect( + await loginWith({ + 'iss': _issuer, + 'aud': 'client-1', + 'iat': now, + 'exp': now + 3600, + }), + isNull, + ); + }); + + test('an id_token missing the iat claim is rejected', () async { + expect( + await loginWith({ + 'iss': _issuer, + 'sub': 'user-1', + 'aud': 'client-1', + 'exp': now + 3600, + }), + isNull, + ); + }); + }); + + group('handleSuccessfulAuthResponse guards', () { + Future expectThrows( + OidcProviderMetadata metadata, + Map Function(String? state) response, + ) async { + final client = MockClient((req) async => http.Response('{}', 404)); + final manager = _make( + client: client, + metadata: metadata, + settings: OidcUserManagerSettings( + redirectUri: Uri.parse('com.example.app://cb'), + userInfoSettings: const OidcUserInfoSettings( + sendUserInfoRequest: false, + ), + ), + onAuthorize: (request) async => + OidcAuthorizeResponse.fromJson(response(request.state)), + ); + await manager.init(); + await expectLater( + manager.loginAuthorizationCodeFlow(), + throwsA(isA()), + ); + await manager.dispose(); + } + + test('a response without a state parameter is rejected', () async { + await expectThrows(_metadata(), (_) => {'code': 'c'}); + }); + + test('a code-flow response without a code is rejected', () async { + await expectThrows(_metadata(), (state) => {'state': ?state}); + }); + + test('a response with no token endpoint is rejected', () async { + await expectThrows( + OidcProviderMetadata.fromJson({ + 'issuer': _issuer, + 'authorization_endpoint': '$_issuer/authorize', + }), + (state) => {'code': 'c', 'state': ?state}, + ); + }); + }); + + group('loadStateResult end-session branch', () { + test('a stored end-session state + response forgets the user', () async { + final store = OidcMemoryStore(); + final client = MockClient((req) async => http.Response('{}', 404)); + String? capturedState; + final first = _make( + client: client, + store: store, + settings: OidcUserManagerSettings( + redirectUri: Uri.parse('com.example.app://cb'), + postLogoutRedirectUri: Uri.parse('com.example.app://logout'), + userInfoSettings: const OidcUserInfoSettings( + sendUserInfoRequest: false, + ), + ), + // Custom end-session handler that abandons the flow after persisting a + // redirect response, mirroring a web same-page logout redirect. + onEndSession: (request) async { + capturedState = request.state; + await store.setStateResponseData( + state: request.state!, + stateData: Uri.parse('com.example.app://logout') + .replace( + queryParameters: {'state': request.state}, + ) + .toString(), + ); + return null; + }, + ); + await first.init(); + first.seed(await _seedUser()); + await first.logout(); + expect(capturedState, isNotNull); + await first.dispose(); + + final second = _make( + client: client, + store: store, + settings: OidcUserManagerSettings( + redirectUri: Uri.parse('com.example.app://cb'), + userInfoSettings: const OidcUserInfoSettings( + sendUserInfoRequest: false, + ), + ), + ); + // Persist a user so init has something to forget when the end-session + // response resolves. + await second.saveUserRaw(await _seedUser()); + await second.init(); + expect(second.currentUser, isNull); + await second.dispose(); + }); + }); + + group('ensureDiscoveryDocument bad cache', () { + test('an unparseable cached document is discarded and refetched', () async { + final store = OidcMemoryStore(); + final wellKnown = OidcUtils.getOpenIdConfigWellKnownUri( + Uri.parse(_issuer), + ); + await store.set( + OidcStoreNamespace.discoveryDocument, + key: wellKnown.toString(), + value: 'not-valid-json{', + ); + final client = MockClient((req) async { + if (req.url.path.contains('openid-configuration')) { + return http.Response( + jsonEncode({ + 'issuer': _issuer, + 'authorization_endpoint': '$_issuer/authorize', + 'token_endpoint': '$_issuer/token', + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 404); + }); + final manager = _M.lazy( + discoveryDocumentUri: wellKnown, + clientCredentials: const OidcClientAuthentication.none( + clientId: 'client-1', + ), + store: store, + httpClient: client, + keyStore: JsonWebKeyStore()..addKey(_signingKey), + settings: OidcUserManagerSettings( + redirectUri: Uri.parse('com.example.app://cb'), + userInfoSettings: const OidcUserInfoSettings( + sendUserInfoRequest: false, + ), + ), + ); + await manager.init(); + expect(manager.discoveryDocument.issuer.toString(), _issuer); + await manager.dispose(); + }); + }); + + group('front-channel logout listener wiring', () { + test( + 'init subscribes when a frontChannelLogoutUri is configured', + () async { + final client = MockClient((req) async => http.Response('{}', 404)); + final manager = _make( + client: client, + settings: OidcUserManagerSettings( + redirectUri: Uri.parse('com.example.app://cb'), + frontChannelLogoutUri: Uri.parse('com.example.app://fclo'), + userInfoSettings: const OidcUserInfoSettings( + sendUserInfoRequest: false, + ), + ), + ); + await manager.init(); + expect(manager.didInit, isTrue); + await manager.dispose(); + }, + ); + }); +} diff --git a/packages/oidc_darwin/test/library_surface_test.dart b/packages/oidc_darwin/test/library_surface_test.dart new file mode 100644 index 00000000..7b995d66 --- /dev/null +++ b/packages/oidc_darwin/test/library_surface_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:oidc_core/oidc_core.dart'; +import 'package:oidc_darwin/oidc_darwin.dart'; + +// `oidc_darwin_test.dart` exercises `getAuthorizationResponse` / +// `getEndSessionResponse` / `registerWith` extensively over the Pigeon +// channel, but never touches `prepareForRedirectFlow`, +// `listenToFrontChannelLogoutRequests`, or `monitorSessionStatus` -- these +// three lines are never executed by any existing test. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('oidc_darwin library surface', () { + test('prepareForRedirectFlow is a no-op that returns an empty preparation ' + 'map (ASWebAuthenticationSession is launched directly, no ' + 'pre-navigation step)', () { + final result = OidcDarwin().prepareForRedirectFlow( + const OidcPlatformSpecificOptions(), + ); + expect(result, isEmpty); + }); + + test( + 'listenToFrontChannelLogoutRequests returns an empty stream ' + '(front-channel logout listening is not implemented on iOS/macOS)', + () async { + final events = await OidcDarwin() + .listenToFrontChannelLogoutRequests( + Uri.parse('com.example.app://logout'), + const OidcFrontChannelRequestListeningOptions(), + ) + .toList(); + expect(events, isEmpty); + }, + ); + + test('monitorSessionStatus returns an empty stream ' + '(session-status polling is not supported on iOS/macOS)', () async { + final events = await OidcDarwin() + .monitorSessionStatus( + checkSessionIframe: Uri.parse('https://op.example.com/check'), + request: const OidcMonitorSessionStatusRequest( + clientId: 'client-1', + sessionState: 'state-1', + interval: Duration(seconds: 1), + ), + ) + .toList(); + expect(events, isEmpty); + }); + }); +} diff --git a/packages/oidc_darwin/test/oidc_darwin_test.dart b/packages/oidc_darwin/test/oidc_darwin_test.dart index 5851f421..0f1e7e5d 100644 --- a/packages/oidc_darwin/test/oidc_darwin_test.dart +++ b/packages/oidc_darwin/test/oidc_darwin_test.dart @@ -5,6 +5,26 @@ import 'package:oidc_core/oidc_core.dart'; import 'package:oidc_darwin/oidc_darwin.dart'; import 'package:oidc_platform_interface/oidc_platform_interface.dart'; +/// Injectable [OidcAppleHostApi] stand-in that throws a raw +/// [MissingPluginException] directly (bypassing the Pigeon channel), used to +/// exercise `_guard`'s "no native plugin registered at all" branch. Pigeon's +/// generated `send()` always resolves under `TestDefaultBinaryMessengerBinding` +/// (an unregistered channel decodes to a `channel-error` `PlatformException`, +/// covered separately), so a real `MissingPluginException` can only be +/// produced this way in a unit test. +class _MissingPluginHostApi extends OidcAppleHostApi { + @override + Future authorizeApple( + String url, + String? redirectUri, + String? callbackScheme, + bool preferEphemeral, + Map options, + ) { + throw MissingPluginException('no implementation found'); + } +} + OidcAuthorizeRequest _authRequest() => OidcAuthorizeRequest( clientId: 'client-1', redirectUri: Uri.parse('com.example.app://callback'), @@ -258,4 +278,93 @@ void main() { ); expect(resp, isNull); }); + + test('wraps a raw MissingPluginException (no plugin registered) as ' + 'OidcException', () async { + await expectLater( + OidcDarwin(hostApi: _MissingPluginHostApi()).getAuthorizationResponse( + metadata, + _authRequest(), + const OidcPlatformSpecificOptions(), + const {}, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('not available on this platform'), + ), + ), + ); + }); + + test( + 'rethrows any other PlatformException code wrapped as OidcException', + () async { + mockHostApi('authorizeApple', (args) async { + throw PlatformException(code: 'SOME_OTHER_ERROR', message: 'boom'); + }); + + await expectLater( + OidcDarwin().getAuthorizationResponse( + metadata, + _authRequest(), + const OidcPlatformSpecificOptions(), + const {}, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf(contains('SOME_OTHER_ERROR'), contains('boom')), + ), + ), + ); + }, + ); + + group('nativeBrowserEvents', () { + const eventChannel = EventChannel( + 'dev.flutter.pigeon.oidc_platform_interface.OidcNativeEventApi' + '.streamNativeEvents', + ); + + tearDown(() => messenger.setMockStreamHandler(eventChannel, null)); + + test('maps native event maps into typed OidcNativeBrowserEvents, dropping ' + 'unrecognized event types', () async { + messenger.setMockStreamHandler( + eventChannel, + MockStreamHandler.inline( + onListen: (arguments, events) { + events + ..success({ + 'type': 'redirectReceived', + 'flowId': 'flow-1', + 'scheme': 'com.example.app', + 'host': 'callback', + 'hasCode': true, + 'hasState': true, + 'hasError': false, + }) + // Forward-compatibility: an unrecognized type must be + // dropped, not surfaced or thrown. + ..success({'type': 'some-future-event-type'}) + ..endOfStream(); + }, + ), + ); + + final events = await OidcDarwin().nativeBrowserEvents().toList(); + + expect(events, hasLength(1)); + final event = events.single as OidcBrowserRedirectReceivedEvent; + expect(event.flowId, 'flow-1'); + expect(event.scheme, 'com.example.app'); + expect(event.host, 'callback'); + expect(event.hasCode, isTrue); + expect(event.hasState, isTrue); + expect(event.hasError, isFalse); + }); + }); } diff --git a/packages/oidc_default_store/test/library_surface_test.dart b/packages/oidc_default_store/test/library_surface_test.dart new file mode 100644 index 00000000..54e5f670 --- /dev/null +++ b/packages/oidc_default_store/test/library_surface_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:oidc_default_store/oidc_default_store.dart'; + +// The rest of this package's suite constructs `OidcDefaultStore` with +// explicit overrides (or exercises behavior through `init`/`setMany`/etc.), +// but never asserts the documented DEFAULT values of `storagePrefix` or +// `recommendedAndroidOptions` directly. Both are part of the public, +// doc-commented contract of the barrel this test imports. +void main() { + group('oidc_default_store library surface', () { + test( + "storagePrefix defaults to 'oidc' when not overridden", + () { + final store = OidcDefaultStore(); + expect(store.storagePrefix, 'oidc'); + }, + ); + + test( + 'recommendedAndroidOptions is the flutter_secure_storage v10 default ' + '(no deprecated encryptedSharedPreferences)', + () { + expect( + OidcDefaultStore.recommendedAndroidOptions, + AndroidOptions.defaultOptions, + ); + }, + ); + }); +} diff --git a/packages/oidc_desktop/test/library_surface_test.dart b/packages/oidc_desktop/test/library_surface_test.dart new file mode 100644 index 00000000..9b73992b --- /dev/null +++ b/packages/oidc_desktop/test/library_surface_test.dart @@ -0,0 +1,77 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter_test/flutter_test.dart'; +import 'package:logging/logging.dart'; +import 'package:oidc_core/oidc_core.dart'; +import 'package:oidc_desktop/oidc_desktop.dart'; +import 'package:oidc_platform_interface/oidc_platform_interface.dart'; + +// `oidc_desktop_test.dart` exercises `OidcDesktop`'s redirect-flow methods +// (`getAuthorizationResponse` / `getEndSessionResponse`) extensively, but +// never touches `prepareForRedirectFlow`, `listenToFrontChannelLogoutRequests`, +// or `monitorSessionStatus` -- these three lines are never executed by any +// existing test, even though the barrel that declares them is already +// loaded. This test closes that gap with real assertions against their +// documented (empty/no-op) desktop behavior. +class _MinimalDesktopImpl extends OidcPlatform with OidcDesktop { + @override + OidcPlatformSpecificOptions_Native getNativeOptions( + OidcPlatformSpecificOptions options, + ) => + const OidcPlatformSpecificOptions_Native(); + + @override + Logger get logger => Logger('Oidc.MinimalDesktop'); +} + +void main() { + group('oidc_desktop library surface', () { + late _MinimalDesktopImpl oidc; + + setUp(() { + oidc = _MinimalDesktopImpl(); + }); + + test( + 'prepareForRedirectFlow is a no-op that returns an empty preparation ' + 'map (desktop launches the browser directly, no pre-navigation step)', + () { + final result = + oidc.prepareForRedirectFlow(const OidcPlatformSpecificOptions()); + expect(result, isEmpty); + }, + ); + + test( + 'listenToFrontChannelLogoutRequests returns an empty stream ' + '(not yet implemented on desktop)', + () async { + final events = await oidc + .listenToFrontChannelLogoutRequests( + Uri.parse('http://localhost:0'), + const OidcFrontChannelRequestListeningOptions(), + ) + .toList(); + expect(events, isEmpty); + }, + ); + + test( + 'monitorSessionStatus returns an empty stream ' + '(session-status polling is not supported on desktop)', + () async { + final events = await oidc + .monitorSessionStatus( + checkSessionIframe: Uri.parse('https://op.example.com/check'), + request: const OidcMonitorSessionStatusRequest( + clientId: 'client-1', + sessionState: 'state-1', + interval: Duration(seconds: 1), + ), + ) + .toList(); + expect(events, isEmpty); + }, + ); + }); +} diff --git a/packages/oidc_desktop/test/oidc_desktop_test.dart b/packages/oidc_desktop/test/oidc_desktop_test.dart index c7144712..723affcf 100644 --- a/packages/oidc_desktop/test/oidc_desktop_test.dart +++ b/packages/oidc_desktop/test/oidc_desktop_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:logging/src/logger.dart'; import 'package:oidc_core/oidc_core.dart'; @@ -371,5 +372,193 @@ void main() { expect(response!.code, 'auth-code-fallback'); }, ); + + group('launchAuthUrl fallback to package:url_launcher', () { + // `platformOpts.launchUrl` overrides the launch mechanism entirely + // (already covered above). When no override is configured, the mixin + // falls back to the real `canLaunchUrl`/`launchUrl` free functions, + // which delegate to `UrlLauncherPlatform.instance` (a method-channel + // implementation by default). We drive that fallback branch by + // mocking the platform's own method channel directly, without + // depending on `url_launcher_platform_interface`. + const channel = MethodChannel('plugins.flutter.io/url_launcher'); + + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + // `ensureInitialized` installs an `HttpOverrides` that fakes every + // `HttpClient` request (returning 400 without touching the + // network). We only need the binding for + // `TestDefaultBinaryMessengerBinding`, not for HTTP faking -- other + // tests in this file spin up a real loopback HttpServer/HttpClient + // pair and would hang/fail if that override stayed active. + HttpOverrides.global = null; + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test( + 'warns (but still attempts to launch) when canLaunchUrl reports ' + 'false, and returns false when the launch itself also fails', + () async { + final calledMethods = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calledMethods.add(call.method); + if (call.method == 'canLaunch') { + return false; + } + if (call.method == 'launch') { + return false; + } + return null; + }); + + final oidc = MockDesktopImpl(); + final result = await oidc.launchAuthUrl( + Uri.parse('https://op.example.com/authorize'), + logRequestDesc: 'authorization', + platformOpts: oidc.getNativeOptions( + const OidcPlatformSpecificOptions(), + ), + ); + + expect(result, isFalse); + expect(calledMethods, containsAll(['canLaunch', 'launch'])); + }, + ); + + test( + 'returns true when canLaunchUrl and launchUrl both report success', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'canLaunch') { + return true; + } + if (call.method == 'launch') { + return true; + } + return null; + }); + + final oidc = MockDesktopImpl(); + final result = await oidc.launchAuthUrl( + Uri.parse('https://op.example.com/authorize'), + logRequestDesc: 'authorization', + platformOpts: oidc.getNativeOptions( + const OidcPlatformSpecificOptions(), + ), + ); + + expect(result, isTrue); + }, + ); + }); + + group('getEndSessionResponse', () { + test( + 'throws an OidcException when the discovery document has no ' + 'end_session_endpoint', + () async { + final oidc = MockDesktopImpl(); + final metadata = OidcProviderMetadata.fromJson({ + 'issuer': 'https://op.example.com', + }); + const request = OidcEndSessionRequest(); + + await expectLater( + oidc.getEndSessionResponse( + metadata, + request, + const OidcPlatformSpecificOptions(), + const {}, + ), + throwsA(isA()), + ); + }, + ); + + test( + 'returns null without starting a listener when the request has no ' + 'postLogoutRedirectUri', + () async { + var launchAttempted = false; + final oidc = MockDesktopImpl( + launchUrl: (uri) async { + launchAttempted = true; + return true; + }, + ); + final metadata = OidcProviderMetadata.fromJson({ + 'issuer': 'https://op.example.com', + 'end_session_endpoint': 'https://op.example.com/end-session', + }); + const request = OidcEndSessionRequest(); + + final response = await oidc.getEndSessionResponse( + metadata, + request, + const OidcPlatformSpecificOptions(), + const {}, + ); + + expect(response, isNull); + expect(launchAttempted, isFalse); + }, + ); + + test( + 'completes the flow and parses the state query parameter from the ' + 'loopback redirect', + () async { + final oidc = MockDesktopImpl( + launchUrl: (uri) async { + final redirectUri = + Uri.parse(uri.queryParameters['post_logout_redirect_uri']!); + final client = HttpClient(); + try { + final callbackUri = redirectUri.replace( + queryParameters: { + ...redirectUri.queryParameters, + 'state': 'logout-state-1', + }, + ); + final getRequest = await client.getUrl(callbackUri); + await (await getRequest.close()).drain(); + } finally { + client.close(force: true); + } + return true; + }, + ); + + final metadata = OidcProviderMetadata.fromJson({ + 'issuer': 'https://op.example.com', + 'end_session_endpoint': 'https://op.example.com/end-session', + }); + final request = OidcEndSessionRequest( + postLogoutRedirectUri: Uri( + scheme: 'http', + host: '127.0.0.1', + port: 0, + path: '/post-logout', + ), + ); + + final response = await oidc.getEndSessionResponse( + metadata, + request, + const OidcPlatformSpecificOptions(), + const {}, + ); + + expect(response, isNotNull); + expect(response!.state, 'logout-state-1'); + }, + ); + }); }); } diff --git a/packages/oidc_linux/test/library_surface_test.dart b/packages/oidc_linux/test/library_surface_test.dart new file mode 100644 index 00000000..52ec0bb8 --- /dev/null +++ b/packages/oidc_linux/test/library_surface_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:oidc_core/oidc_core.dart'; +import 'package:oidc_desktop/oidc_desktop.dart'; +import 'package:oidc_linux/oidc_linux.dart'; + +// `oidc_linux_test.dart`'s only test overrides `options.linux` explicitly, +// so `getNativeOptions`' default-passthrough branch (the un-overridden +// `OidcPlatformSpecificOptions.linux` default) is never asserted, and +// `OidcLinux`'s `with OidcDesktop` mixin wiring is never asserted directly. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('oidc_linux library surface', () { + test( + 'OidcLinux mixes in OidcDesktop (shares the desktop redirect-flow ' + 'implementation)', () { + expect(OidcLinux(), isA()); + }); + + test( + 'getNativeOptions reads options.linux specifically, not ' + 'options.windows/android (default, un-overridden options)', + () { + final result = OidcLinux().getNativeOptions( + const OidcPlatformSpecificOptions(), + ); + expect(result, const OidcPlatformSpecificOptions_Native()); + }, + ); + }); +} diff --git a/packages/oidc_loopback_listener/test/library_surface_test.dart b/packages/oidc_loopback_listener/test/library_surface_test.dart new file mode 100644 index 00000000..926ed227 --- /dev/null +++ b/packages/oidc_loopback_listener/test/library_surface_test.dart @@ -0,0 +1,30 @@ +@TestOn('vm') +library; + +// `MockOidcLoopbackListener` (`lib/src/mock.dart`) is marked +// `@visibleForTesting` and is deliberately NOT re-exported by the public +// barrel (`oidc_loopback_listener.dart`) -- it is meant to be imported +// directly by consumers' test code, the same way this test imports it. No +// other test in this package ever imports `src/mock.dart`, so without this +// test the file is never loaded and stays invisible to coverage tooling. +import 'package:oidc_loopback_listener/oidc_loopback_listener.dart'; +import 'package:oidc_loopback_listener/src/mock.dart'; +import 'package:test/test.dart'; + +void main() { + group('oidc_loopback_listener library surface', () { + test('MockOidcLoopbackListener is a real subtype of OidcLoopbackListener ' + 'that inherits the documented defaults', () { + final mock = MockOidcLoopbackListener(); + expect(mock, isA()); + // `MockOidcLoopbackListener` adds no fields of its own; it must + // inherit the base class's documented defaults verbatim. + expect(mock.port, 0); + expect(mock.path, isNull); + }); + + test('oidcDefaultHtmlPage documents the redirect-back message', () { + expect(oidcDefaultHtmlPage, contains('Please return to the app.')); + }); + }); +} diff --git a/packages/oidc_platform_interface/lib/src/platform.dart b/packages/oidc_platform_interface/lib/src/platform.dart index 65ee9d1c..3204ab13 100644 --- a/packages/oidc_platform_interface/lib/src/platform.dart +++ b/packages/oidc_platform_interface/lib/src/platform.dart @@ -1,4 +1,3 @@ -// coverage:ignore-file import 'package:oidc_core/oidc_core.dart'; import 'package:oidc_platform_interface/src/native_events.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; diff --git a/packages/oidc_platform_interface/test/library_surface_test.dart b/packages/oidc_platform_interface/test/library_surface_test.dart new file mode 100644 index 00000000..13ff80a5 --- /dev/null +++ b/packages/oidc_platform_interface/test/library_surface_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:oidc_platform_interface/oidc_platform_interface.dart'; + +// `native_channel_constants.dart` is exported by the public barrel, but no +// other test in this package ever references `OidcNativeMethods` / +// `OidcNativeErrorCodes` directly, so its documented values are never +// asserted against. These constants must stay byte-for-byte in sync with the +// native Kotlin/Swift plugins (see the file's own doc comment), so a drift +// here is a real cross-platform break. +void main() { + group('oidc_platform_interface library surface', () { + test( + 'OidcNativeMethods match the Pigeon OidcAndroidHostApi method names', + () { + expect(OidcNativeMethods.authorize, 'authorize'); + expect(OidcNativeMethods.endSession, 'endSession'); + expect(OidcNativeMethods.cancel, 'cancel'); + }, + ); + + test( + "OidcNativeErrorCodes match the native plugins' PlatformException " + 'codes', + () { + expect(OidcNativeErrorCodes.userCancelled, 'USER_CANCELLED'); + expect( + OidcNativeErrorCodes.presentationContextInvalid, + 'PRESENTATION_CONTEXT_INVALID', + ); + }, + ); + }); +} diff --git a/packages/oidc_web/test/library_surface_test.dart b/packages/oidc_web/test/library_surface_test.dart new file mode 100644 index 00000000..bd89550c --- /dev/null +++ b/packages/oidc_web/test/library_surface_test.dart @@ -0,0 +1,64 @@ +@TestOn('chrome') +library; + +// `oidc_web.dart` (the real `OidcWeb` plugin class, the KNOWN gap this test +// closes) imports `oidc_web_core`, which unconditionally depends on +// `package:web` (`dart:js_interop`) -- so this suite only compiles for a +// browser compile target and must be run with `flutter test -d chrome` +// (equivalent to `dart test -p chrome` for a pure-Dart package). This +// package's other "test" file is only a placeholder pointing at +// `integration_test`, so `oidc_web.dart` was never loaded by any unit test +// before this file. +import 'package:flutter_test/flutter_test.dart'; +import 'package:oidc_core/oidc_core.dart'; +import 'package:oidc_platform_interface/oidc_platform_interface.dart'; +import 'package:oidc_web/oidc_web.dart'; + +void main() { + group('oidc_web library surface', () { + test( + 'registerWith installs OidcWeb as the OidcPlatform.instance singleton', + () { + OidcWeb.registerWith(); + expect(OidcPlatform.instance, isA()); + // restore the default for any other suite sharing this isolate. + OidcPlatform.instance = NoOpOidcPlatform(); + }, + ); + + test( + 'prepareForRedirectFlow delegates to OidcWebCore: samePage navigation ' + 'mode produces an empty preparation map (no popup/new-tab opened)', + () { + final web = OidcWeb(); + final result = web.prepareForRedirectFlow( + const OidcPlatformSpecificOptions( + web: OidcPlatformSpecificOptions_Web( + navigationMode: + OidcPlatformSpecificOptions_Web_NavigationMode.samePage, + ), + ), + ); + expect(result, isEmpty); + }, + ); + + test( + 'monitorSessionStatus delegates to OidcWebCore, which documents that ' + 'it "creates a hidden iframe every time you listen to it" -- i.e. the ' + 'returned stream must be single-subscription, not broadcast', + () { + final web = OidcWeb(); + final stream = web.monitorSessionStatus( + checkSessionIframe: Uri.parse('https://op.example.com/checksession'), + request: const OidcMonitorSessionStatusRequest( + clientId: 'client-1', + sessionState: 'state-1', + interval: Duration(seconds: 30), + ), + ); + expect(stream.isBroadcast, isFalse); + }, + ); + }); +} diff --git a/packages/oidc_web_core/test/library_surface_test.dart b/packages/oidc_web_core/test/library_surface_test.dart new file mode 100644 index 00000000..174f570b --- /dev/null +++ b/packages/oidc_web_core/test/library_surface_test.dart @@ -0,0 +1,197 @@ +@TestOn('js') +library; + +// `oidc_web_core` unconditionally depends on `package:web` +// (`dart:js_interop`), so its entire barrel only compiles for a web +// (js/wasm) compile target — it can never run on the `vm` platform. This +// test therefore runs only on `js` (chrome/firefox), matching the rest of +// this package's suite (see oidc_web_core_test.dart). Coverage for this +// package must be collected with `dart test -p chrome --coverage=...` +// rather than the vm-default invocation, since `dart test --coverage` +// (implicit vm platform) never even attempts to compile this suite. +import 'dart:js_interop'; + +import 'package:oidc_core/oidc_core.dart'; +import 'package:oidc_web_core/oidc_web_core.dart'; +import 'package:test/test.dart'; +import 'package:web/web.dart' as web; + +/// Builds a minimal, valid [OidcUserManagerWeb] for exercising its +/// `OidcPlatform` delegate overrides directly (without running a full +/// login/logout flow through `OidcUserManagerBase`). +OidcUserManagerWeb _buildManager({bool withEndSessionEndpoint = false}) { + return OidcUserManagerWeb( + discoveryDocument: OidcProviderMetadata.fromJson({ + 'issuer': 'https://op.example.com', + 'authorization_endpoint': 'https://op.example.com/authorize', + 'token_endpoint': 'https://op.example.com/token', + if (withEndSessionEndpoint) + 'end_session_endpoint': 'https://op.example.com/endsession', + }), + clientCredentials: const OidcClientAuthentication.none( + clientId: 'client-1', + ), + store: OidcMemoryStore(), + settings: OidcUserManagerSettings( + redirectUri: Uri.parse('https://app.example.com/cb'), + ), + ); +} + +void main() { + group('oidc_web_core library surface', () { + test('OidcWebCore is a real, const, stateless entry point', () { + const a = OidcWebCore(); + const b = OidcWebCore(); + // `const` construction canonicalizes identical const instances. + expect(identical(a, b), isTrue); + }); + + test('OidcUserManagerWeb.isWeb is true, per its documented override', () { + final manager = _buildManager(); + expect(manager.isWeb, isTrue); + }); + + test('OidcUserManagerWeb.prepareForRedirectFlow is a no-op in samePage ' + 'navigation mode (no window.open, no preparation payload)', () { + final manager = _buildManager(); + // In `samePage` navigation mode, `_prepareWindow` returns null (no + // popup / new tab is opened), so the preparation payload must be + // empty. + final result = manager.prepareForRedirectFlow( + const OidcPlatformSpecificOptions( + web: OidcPlatformSpecificOptions_Web( + navigationMode: + OidcPlatformSpecificOptions_Web_NavigationMode.samePage, + ), + ), + ); + expect(result, isEmpty); + }); + + test('OidcUserManagerWeb.getAuthorizationResponse delegates to ' + 'OidcWebCore: a metadata document without an authorization_endpoint ' + 'surfaces as an OidcException synchronously (no popup/navigation ' + 'ever happens)', () async { + final manager = _buildManager(); + final metadata = OidcProviderMetadata.fromJson({ + 'issuer': 'https://op.example.com', + 'token_endpoint': 'https://op.example.com/token', + }); + final request = OidcAuthorizeRequest( + responseType: const ['code'], + clientId: 'client-1', + redirectUri: Uri.parse('https://app.example.com/cb'), + scope: const ['openid'], + ); + + await expectLater( + manager.getAuthorizationResponse( + metadata, + request, + const OidcPlatformSpecificOptions(), + const {}, + ), + throwsA(isA()), + ); + }); + + test('OidcUserManagerWeb.getEndSessionResponse delegates to OidcWebCore: ' + 'a metadata document without an end_session_endpoint surfaces as an ' + 'OidcException synchronously', () async { + final manager = _buildManager(); + final metadata = OidcProviderMetadata.fromJson({ + 'issuer': 'https://op.example.com', + 'authorization_endpoint': 'https://op.example.com/authorize', + 'token_endpoint': 'https://op.example.com/token', + }); + const request = OidcEndSessionRequest(clientId: 'client-1'); + + await expectLater( + manager.getEndSessionResponse( + metadata, + request, + const OidcPlatformSpecificOptions(), + const {}, + ), + throwsA(isA()), + ); + }); + + test('OidcUserManagerWeb.getEndSessionResponse delegates to OidcWebCore: ' + 'given an end_session_endpoint, samePage navigation returns null ' + '(the response only ever arrives via the redirected page)', () async { + final manager = _buildManager(withEndSessionEndpoint: true); + final metadata = OidcProviderMetadata.fromJson({ + 'issuer': 'https://op.example.com', + 'authorization_endpoint': 'https://op.example.com/authorize', + 'token_endpoint': 'https://op.example.com/token', + 'end_session_endpoint': 'https://op.example.com/endsession', + }); + const request = OidcEndSessionRequest(clientId: 'client-1'); + + final result = await manager.getEndSessionResponse( + metadata, + request, + const OidcPlatformSpecificOptions( + web: OidcPlatformSpecificOptions_Web( + navigationMode: + OidcPlatformSpecificOptions_Web_NavigationMode.samePage, + ), + ), + const {}, + ); + expect(result, isNull); + }); + + test('OidcUserManagerWeb.listenToFrontChannelLogoutRequests delegates to ' + 'OidcWebCore and yields a request parsed out of a same-origin ' + 'BroadcastChannel message', () async { + final manager = _buildManager(); + final stream = manager.listenToFrontChannelLogoutRequests( + // no path segments and no query -- matched via the default + // `requestType=front-channel-logout` check. + Uri.parse('https://app.example.com'), + const OidcFrontChannelRequestListeningOptions(), + ); + expect(stream.isBroadcast, isFalse); + + final received = stream.first; + // Let the stream's `onListen` attach its BroadcastChannel handler + // before we post to it. + await Future.delayed(Duration.zero); + + final channel = web.BroadcastChannel( + OidcFrontChannelRequestListeningOptions_Web.defaultBroadcastChannel, + ); + // `close` is an external extension-type interop member -- dart2js + // disallows tearing it off, so this can't be + // `addTearDown(channel.close)`. + // ignore: unnecessary_lambdas + addTearDown(() => channel.close()); + channel.postMessage( + 'https://app.example.com/?requestType=front-channel-logout' + '&sid=session-42' + .toJS, + ); + + final result = await received.timeout(const Duration(seconds: 5)); + expect(result.sid, 'session-42'); + }); + + test('OidcUserManagerWeb.monitorSessionStatus delegates to OidcWebCore, ' + 'which creates a hidden iframe every time you listen to it -- i.e. ' + 'the returned stream must be single-subscription, not broadcast', () { + final manager = _buildManager(); + final stream = manager.monitorSessionStatus( + checkSessionIframe: Uri.parse('https://op.example.com/checksession'), + request: const OidcMonitorSessionStatusRequest( + clientId: 'client-1', + sessionState: 'state-1', + interval: Duration(seconds: 30), + ), + ); + expect(stream.isBroadcast, isFalse); + }); + }); +} diff --git a/packages/oidc_web_core/test/oidc_web_store_test.dart b/packages/oidc_web_core/test/oidc_web_store_test.dart index 582a9a15..c435912e 100644 --- a/packages/oidc_web_core/test/oidc_web_store_test.dart +++ b/packages/oidc_web_core/test/oidc_web_store_test.dart @@ -286,6 +286,38 @@ void main() { }); }); + group('OidcWebStore other namespaces (request, discoveryDocument, ' + 'stateResponse) share the plain default* path with state', () { + test('getMany/removeMany round-trip through the shared default storage ' + 'path for the discoveryDocument namespace (regression guard: only ' + 'setMany was exercised for a non-session/secureTokens namespace ' + 'before)', () async { + const store = OidcWebStore(storagePrefix: 'other-namespaces'); + await store.init(); + + await store.setMany( + OidcStoreNamespace.discoveryDocument, + values: {'doc': '{"issuer":"https://op.example.com"}'}, + ); + + final result = await store.getMany( + OidcStoreNamespace.discoveryDocument, + keys: {'doc'}, + ); + expect(result['doc'], '{"issuer":"https://op.example.com"}'); + + await store.removeMany( + OidcStoreNamespace.discoveryDocument, + keys: {'doc'}, + ); + final afterRemove = await store.getMany( + OidcStoreNamespace.discoveryDocument, + keys: {'doc'}, + ); + expect(afterRemove.containsKey('doc'), isFalse); + }); + }); + group('when WebCrypto/IndexedDB are unavailable', () { test('OidcWebStoreEncryption.preferred falls back to a warned plaintext ' 'write', () async { diff --git a/packages/oidc_windows/test/library_surface_test.dart b/packages/oidc_windows/test/library_surface_test.dart new file mode 100644 index 00000000..163b331f --- /dev/null +++ b/packages/oidc_windows/test/library_surface_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:oidc_core/oidc_core.dart'; +import 'package:oidc_desktop/oidc_desktop.dart'; +import 'package:oidc_windows/oidc_windows.dart'; + +// `oidc_windows_test.dart`'s only test overrides `options.windows` +// explicitly and never touches `monitorSessionStatus` -- `OidcWindows` +// overrides the `OidcDesktop` mixin's default with its own empty-stream +// implementation, and that override's body is never executed by any +// existing test. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('oidc_windows library surface', () { + test( + 'OidcWindows mixes in OidcDesktop (shares the desktop redirect-flow ' + 'implementation)', () { + expect(OidcWindows(), isA()); + }); + + test( + 'getNativeOptions reads options.windows specifically (default, ' + 'un-overridden options)', + () { + final result = OidcWindows().getNativeOptions( + const OidcPlatformSpecificOptions(), + ); + expect(result, const OidcPlatformSpecificOptions_Native()); + }, + ); + + test( + "monitorSessionStatus's own override returns an empty stream " + '(session-status polling is not supported on Windows)', + () async { + final events = await OidcWindows() + .monitorSessionStatus( + checkSessionIframe: Uri.parse('https://op.example.com/check'), + request: const OidcMonitorSessionStatusRequest( + clientId: 'client-1', + sessionState: 'state-1', + interval: Duration(seconds: 1), + ), + ) + .toList(); + expect(events, isEmpty); + }, + ); + }); +} diff --git a/packages/x509_plus/lib/src/extension.dart b/packages/x509_plus/lib/src/extension.dart index ca8766f4..b2a3983f 100644 --- a/packages/x509_plus/lib/src/extension.dart +++ b/packages/x509_plus/lib/src/extension.dart @@ -550,9 +550,15 @@ class NoticeReference { /// organization DisplayText, /// noticeNumbers SEQUENCE OF INTEGER } factory NoticeReference.fromAsn1(ASN1Sequence sequence) { + // `noticeNumbers` is a `SEQUENCE OF INTEGER`, so `toDart` returns a + // `List` whose elements are `BigInt`. Assigning that directly to + // `List` throws a TypeError, making any UserNotice with a noticeRef + // unparseable. Convert each element to an `int` explicitly. return NoticeReference( organization: toDart(sequence.elements[0]), - noticeNumbers: toDart(sequence.elements[1])); + noticeNumbers: (toDart(sequence.elements[1]) as List) + .map((e) => (e as BigInt).toInt()) + .toList()); } @override diff --git a/packages/x509_plus/lib/src/objectidentifier.dart b/packages/x509_plus/lib/src/objectidentifier.dart index f6b30379..cd92881c 100644 --- a/packages/x509_plus/lib/src/objectidentifier.dart +++ b/packages/x509_plus/lib/src/objectidentifier.dart @@ -32,21 +32,12 @@ class ObjectIdentifier { } ASN1ObjectIdentifier toAsn1() { - var bytes = []; - bytes.add(nodes.first * 40 + nodes[1]); - for (var v in nodes.skip(2)) { - var w = []; - while (v > 128) { - var u = v % 128; - v -= u; - v ~/= 128; - w.add(u); - } - w.add(v); - bytes.addAll(w.skip(1).toList().reversed.map((v) => v + 128)); - bytes.add(w.first); - } - return ASN1ObjectIdentifier(bytes); + // asn1lib's ASN1ObjectIdentifier constructor takes the plain arc + // components (its `oi` field) and performs the base-128 DER encoding + // itself in `_encode()`. Passing pre-encoded bytes here caused the arcs + // to be encoded twice, producing corrupt DER from every re-encode path + // (including toPem()). Hand the constructor the raw nodes instead. + return ASN1ObjectIdentifier(nodes); } @override diff --git a/packages/x509_plus/test/certificate_extra_test.dart b/packages/x509_plus/test/certificate_extra_test.dart new file mode 100644 index 00000000..b87e80aa --- /dev/null +++ b/packages/x509_plus/test/certificate_extra_test.dart @@ -0,0 +1,76 @@ +// Reads DER fixtures from disk in setUpAll: dart:io File reads cannot run on +// the web platform (see #353's convention: fixture tests are pinned to vm). +@TestOn('vm') +library; + +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:asn1lib/asn1lib.dart'; +import 'package:test/test.dart'; +import 'package:x509_plus/x509.dart'; + +void main() { + group('TbsCertificate.fromAsn1 optional unique IDs', () { + // RFC 5280 §4.1.2.8 defines `issuerUniqueID` ([1] IMPLICIT) and + // `subjectUniqueID` ([2] IMPLICIT) as optional BIT STRING fields between + // the six required TBSCertificate fields and the [3] extensions. Real + // CA certificates essentially never populate them (deprecated by + // RFC 5280), so no fixture in `test/resources` carries them. Reuse the + // six required fields from a real certificate and append hand-built + // context-tagged elements to exercise the two parsing branches. + late List requiredFields; + + setUpAll(() { + final bytes = File('test/resources/rfc5280_cert1.cer').readAsBytesSync(); + final certSeq = ASN1Parser(bytes).nextObject() as ASN1Sequence; + final tbsSeq = certSeq.elements[0] as ASN1Sequence; + var elements = tbsSeq.elements; + if (elements.first.tag == 0xa0) { + // Drop the explicit version tag; the six required fields follow it. + elements = elements.skip(1).toList(); + } + requiredFields = elements.take(6).toList(); + }); + + test('parses issuerUniqueID ([1]) and subjectUniqueID ([2])', () { + final seq = ASN1Sequence(); + for (final e in requiredFields) { + seq.add(e); + } + final issuerUid = ASN1Object.preEncoded( + 0x81, Uint8List.fromList([0x00, 0xff, 0x00, 0xff])); + final subjectUid = + ASN1Object.preEncoded(0x82, Uint8List.fromList([0x00, 0xaa, 0xbb])); + seq.add(issuerUid); + seq.add(subjectUid); + + final tbs = TbsCertificate.fromAsn1(seq); + expect(tbs.issuerUniqueID, [0x00, 0xff, 0x00, 0xff]); + expect(tbs.subjectUniqueID, [0x00, 0xaa, 0xbb]); + }); + + test('leaves both unique IDs null when absent', () { + final seq = ASN1Sequence(); + for (final e in requiredFields) { + seq.add(e); + } + final tbs = TbsCertificate.fromAsn1(seq); + expect(tbs.issuerUniqueID, isNull); + expect(tbs.subjectUniqueID, isNull); + }); + + test('parses only issuerUniqueID when subjectUniqueID is absent', () { + final seq = ASN1Sequence(); + for (final e in requiredFields) { + seq.add(e); + } + final issuerUid = ASN1Object.preEncoded(0x81, Uint8List.fromList([0x07])); + seq.add(issuerUid); + + final tbs = TbsCertificate.fromAsn1(seq); + expect(tbs.issuerUniqueID, [0x07]); + expect(tbs.subjectUniqueID, isNull); + }); + }); +} diff --git a/packages/x509_plus/test/extension_extra_test.dart b/packages/x509_plus/test/extension_extra_test.dart index 887952c4..8dd8dcbc 100644 --- a/packages/x509_plus/test/extension_extra_test.dart +++ b/packages/x509_plus/test/extension_extra_test.dart @@ -102,8 +102,6 @@ void main() { group('PolicyQualifierInfo', () { test('parses a user-notice qualifier with explicit text', () { - // Note: a noticeRef with noticeNumbers is intentionally omitted because - // NoticeReference.fromAsn1 throws a TypeError (see bugsFound). final userNotice = ASN1Sequence() ..add(ASN1PrintableString('See the CPS')); final qualifier = ASN1Sequence() @@ -118,6 +116,36 @@ void main() { expect(pqi.toString(), contains('User Notice')); }); + test('parses a user-notice qualifier with a noticeRef', () { + // NoticeReference ::= SEQUENCE { + // organization DisplayText, + // noticeNumbers SEQUENCE OF INTEGER } + final noticeNumbers = ASN1Sequence() + ..add(ASN1Integer(BigInt.from(1))) + ..add(ASN1Integer(BigInt.from(2))) + ..add(ASN1Integer(BigInt.from(300))); + final noticeRef = ASN1Sequence() + ..add(ASN1PrintableString('Example CA')) + ..add(noticeNumbers); + final userNotice = ASN1Sequence()..add(noticeRef); + final qualifier = ASN1Sequence() + ..add(_oid([1, 3, 6, 1, 5, 5, 7, 2, 2])) // id-qt-unotice + ..add(userNotice); + + final pqi = PolicyQualifierInfo.fromAsn1(qualifier); + final ref = pqi.userNotice!.noticeRef!; + expect(ref.organization, 'Example CA'); + expect(ref.noticeNumbers, [1, 2, 300]); + expect(ref.noticeNumbers, isA>()); + expect(ref.toString(), 'Example CA [1, 2, 300]'); + // UserNotice.toString() with a non-null noticeRef (and no + // explicitText) must render the "Notice Reference:" line. + expect( + pqi.userNotice.toString(), + 'Notice Reference: Example CA [1, 2, 300]\n', + ); + }); + test('rejects an unsupported qualifier id when parsing', () { final qualifier = ASN1Sequence() ..add(_oid([1, 3, 6, 1, 5, 5, 7, 2, 99])) diff --git a/packages/x509_plus/test/library_surface_test.dart b/packages/x509_plus/test/library_surface_test.dart new file mode 100644 index 00000000..cfc4038c --- /dev/null +++ b/packages/x509_plus/test/library_surface_test.dart @@ -0,0 +1,24 @@ +// Ensures the package-name-matching entrypoint (`x509_plus.dart`) is +// actually loaded by the test suite, so its re-exported surface stays +// visible to coverage tooling even though the rest of the suite only ever +// imports the legacy `x509.dart` entrypoint. +import 'package:test/test.dart'; +import 'package:x509_plus/x509_plus.dart'; + +void main() { + group('x509_plus library surface', () { + test('ObjectIdentifier.parent drops the last arc (RFC-defined OID tree)', + () { + // 1.2.840.113549.1 (pkcs) is a well-known OID; its parent must be + // 1.2.840.113549 (rsadsi), per the OID tree structure documented on + // `ObjectIdentifier.parent`. + const oid = ObjectIdentifier([1, 2, 840, 113549, 1]); + expect(oid.parent, const ObjectIdentifier([1, 2, 840, 113549])); + }); + + test('ObjectIdentifier with a single arc has no parent', () { + const oid = ObjectIdentifier([1]); + expect(oid.parent, isNull); + }); + }); +} diff --git a/packages/x509_plus/test/objectidentifier_test.dart b/packages/x509_plus/test/objectidentifier_test.dart index 065e391f..64ac816e 100644 --- a/packages/x509_plus/test/objectidentifier_test.dart +++ b/packages/x509_plus/test/objectidentifier_test.dart @@ -1,3 +1,6 @@ +import 'dart:io'; + +import 'package:asn1lib/asn1lib.dart'; import 'package:test/test.dart'; import 'package:x509_plus/x509.dart'; @@ -13,4 +16,70 @@ void main() { expect(() => oid.name, throwsA(TypeMatcher())); }); }); + + group('toAsn1 / fromAsn1 round-trip', () { + // Real-world OIDs, including ones with multi-byte (>= 128) arcs that + // exercise the base-128 encoding path. + final oids = >[ + [2, 5, 4, 3], // commonName + [1, 2, 840, 113549, 1, 1, 11], // sha256WithRSAEncryption + [1, 3, 6, 1, 5, 5, 7, 3, 1], // serverAuth + [1, 2, 840, 10045, 3, 1, 7], // prime256v1 + [2, 16, 840, 1, 113730, 1, 13], // netscape-comment + ]; + + for (final nodes in oids) { + test('${nodes.join('.')} survives toAsn1 -> fromAsn1', () { + final oid = ObjectIdentifier(nodes); + final roundTripped = ObjectIdentifier.fromAsn1(oid.toAsn1()); + expect(roundTripped, equals(oid)); + expect(roundTripped.nodes, equals(nodes)); + }); + + test('${nodes.join('.')} toAsn1 produces correct DER value bytes', () { + // asn1lib decodes the DER value octets independently of x509_plus' + // own decoder, so this cross-checks that toAsn1() emits valid DER + // (not the previously doubly-encoded bytes). + final der = ObjectIdentifier(nodes).toAsn1().encodedBytes; + final decoded = ASN1Parser(der).nextObject() as ASN1ObjectIdentifier; + expect(decoded.oi, equals(nodes)); + }); + } + }); + + group('certificate re-encode preserves subject/issuer OIDs', () { + test('rfc5280 cert survives toPem -> parse round-trip', () { + final bytes = File('test/resources/rfc5280_cert1.cer').readAsBytesSync(); + final cert = X509Certificate.fromAsn1( + ASN1Parser(bytes).nextObject() as ASN1Sequence); + + List oidsOf(Name name) => [ + for (final rdn in name.names) + ...rdn.keys.whereType() + ]; + + final subjectOids = oidsOf(cert.tbsCertificate.subject!); + final issuerOids = oidsOf(cert.tbsCertificate.issuer!); + expect(subjectOids, isNotEmpty); + expect(issuerOids, isNotEmpty); + + // Re-encode the Name (drives ObjectIdentifier.toAsn1 via fromDart) and + // parse it back. Before the fix the OIDs were doubly encoded and the + // recovered arcs did not match the originals. + final subjectReparsed = + Name.fromAsn1(cert.tbsCertificate.subject!.toAsn1()); + final issuerReparsed = + Name.fromAsn1(cert.tbsCertificate.issuer!.toAsn1()); + + expect(oidsOf(subjectReparsed), equals(subjectOids)); + expect(oidsOf(issuerReparsed), equals(issuerOids)); + + // The full PEM of the re-encoded subject must round-trip through a real + // DER parser as well. + final der = cert.tbsCertificate.subject!.toAsn1().encodedBytes; + final subjectFromDer = + Name.fromAsn1(ASN1Parser(der).nextObject() as ASN1Sequence); + expect(oidsOf(subjectFromDer), equals(subjectOids)); + }); + }, testOn: 'vm'); } diff --git a/pubspec.yaml b/pubspec.yaml index 4d3f29de..1b895e48 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -123,11 +123,73 @@ melos: TEST_PLATFORM: firefox MOZ_HEADLESS: "1" WITH_WASM: false + test:web:pr:chrome: + name: PR-scoped Dart Web tests in chrome + run: melos run test:web:pr:single + env: + TEST_PLATFORM: chrome + WITH_WASM: false + test:web:pr:firefox: + name: PR-scoped Dart Web tests in firefox + run: melos run test:web:pr:single + env: + TEST_PLATFORM: firefox + MOZ_HEADLESS: "1" + WITH_WASM: false + test:web:pr:single: + name: PR-scoped Dart Web tests in a browser + # Per-PR browser testing runs ONLY the packages whose lib code is + # genuinely platform-divergent (js-interop / web-conditional imports). + # Everything else is platform-neutral pure Dart: its browser runs + # duplicate the VM signal while paying one dart2js compile PER TEST + # FILE (~30-60s each on CI; oidc_core alone is 60+ files, so the old + # full sweep spent the better part of an hour per browser per matrix + # leg just compiling). The full sweep still runs on main pushes via + # test:web:chrome / test:web:firefox. + # --concurrency=1 and NO --coverage: three consecutive CI runs wedged + # AFTER every test passed -- the runner reached a fully successful end + # and never exited, idling from the last passing test until an outer + # timeout killed it (runs 29146393931/29148902320/29150605150, both + # browsers). The post-success phase is browser/suite teardown plus + # (Chrome-only) coverage collection; dart-lang/test#2294 documents a + # multi-suite post-success exit hang with --concurrency=1 as the + # workaround, and PR runs don't need browser coverage anyway (codecov + # gets it from the full sweep on main pushes). + # --suite-load-timeout 4m: suite loads default to NO timeout at all + # (test 1.31: suiteLoadTimeout falls back to Timeout.none), so a + # load-phase wedge would otherwise also hang forever; five ~10s suite + # loads make 4m generous. No --timeout override: tests are sub-second, + # so the 30s default bounds mid-test hangs. The workflow adds + # step-level timeout-minutes as the backstop for anything else. + exec: | + set -e + dart test --suite-load-timeout 4m --concurrency=1 --platform ${TEST_PLATFORM} --chain-stack-traces + packageFilters: + scope: + - oidc_web_core test:web:single: name: Dart Web tests in a browser env: WITH_WASM: false - # We have to use --timeout 60m alongside --ignore-timeouts + # --timeout 60m: jose_plus's crypto tests are legitimately slow under + # dart2js. --suite-load-timeout 2h: package:test 1.31 puts NO timeout + # on suite loads by default (suiteLoadTimeout falls back to + # Timeout.none), so a wedged browser suite load hangs silently until + # GitHub's 6h job limit (run 29118828702; the last two suites never + # printed a line). The bound must clear the worst honest wait: the + # load timer keeps running while suites queue behind jose's ~55min + # crypto executors, so a small bound false-fails healthy suites (a 4m + # bound failed 8 of them in run 29140552735). 2h turns a wedge into a + # loud, rerunnable failure instead of a 6h hang. Deliberately NO + # --ignore-timeouts: it disables all of these bounds. + # WEB_EXCLUDE_TAGS: passed through to `dart test --exclude-tags`. + # jose_plus tags its crypto-executor suites (keygen/sign/encrypt) as + # slow-web-crypto (see packages/jose_plus/dart_test.yaml) because they + # take ~55min per browser under dart2js's ~200x slower BigInt (#371); + # while they run, other suites' loads starve. CI sets this to + # slow-web-crypto for pull_request events only, so PR web runs skip + # them while VM runs (which always execute every tag) and main-branch + # pushes keep full web crypto signal. exec: | # set -e: without it this script's exit status is that of its LAST # statement -- the WITH_WASM if-block, which is 0 whenever WITH_WASM @@ -135,11 +197,11 @@ melos: # CI step stayed green (issue #353: 27 web test failures hidden). set -e if [ "$TARGET_DART_SDK" = "min" ]; then - dart test --timeout 60m --ignore-timeouts --platform ${TEST_PLATFORM} --chain-stack-traces + dart test --timeout 60m --platform ${TEST_PLATFORM} --chain-stack-traces ${WEB_EXCLUDE_TAGS:+--exclude-tags "$WEB_EXCLUDE_TAGS"} else - dart test --timeout 60m --ignore-timeouts --platform ${TEST_PLATFORM} --coverage=coverage/${TEST_PLATFORM} --chain-stack-traces + dart test --timeout 60m --suite-load-timeout 2h --platform ${TEST_PLATFORM} --coverage=coverage/${TEST_PLATFORM} --chain-stack-traces ${WEB_EXCLUDE_TAGS:+--exclude-tags "$WEB_EXCLUDE_TAGS"} if [ "$WITH_WASM" = "true" ]; then - dart test --timeout 60m --ignore-timeouts --platform ${TEST_PLATFORM} --coverage=coverage/${TEST_PLATFORM} --chain-stack-traces --compiler=dart2wasm + dart test --timeout 60m --suite-load-timeout 2h --platform ${TEST_PLATFORM} --coverage=coverage/${TEST_PLATFORM} --chain-stack-traces --compiler=dart2wasm ${WEB_EXCLUDE_TAGS:+--exclude-tags "$WEB_EXCLUDE_TAGS"} fi fi packageFilters: @@ -202,6 +264,7 @@ melos: test:coverage: name: Run all tests and combine coverage run: | + set -e melos run test melos run coverage:format @@ -230,7 +293,12 @@ melos: coverage:format:package: name: Format coverage for each package - exec: dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib + # --check-ignore: honor // coverage:ignore-file / ignore-start markers in + # pure-Dart packages, matching what flutter test does natively for the + # Flutter packages (evidence: ignore-marked platform.dart never appears + # in their lcov). Without it the two halves of the repo obey different + # ignore semantics. + exec: dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib --check-ignore packageFilters: # Dart-only. `dart test --coverage=` writes VM hitmap JSON that # still needs format_coverage to become lcov; `flutter test --coverage` @@ -250,7 +318,7 @@ melos: dart pub global activate remove_from_coverage melos run coverage:format dart pub global run combine_coverage --repo-path=$pwd - dart pub global run remove_from_coverage:remove_from_coverage -f coverage/lcov.info -r '\.g\.dart$' + dart pub global run remove_from_coverage:remove_from_coverage -f coverage/lcov.info -r '\.g\.dart$' -r 'oidc_cli/lib/src/version\.dart$' oidc:activate: name: Activate OIDC CLI locally