import "dart:convert"; import "package:flutter_test/flutter_test.dart"; import "package:garage_auth/garage_auth.dart"; import "package:garage_entitlements/garage_entitlements.dart"; import "package:http/http.dart" as http; import "package:http/testing.dart"; import "package:pointycastle/export.dart"; import "keys.dart"; const _issuer = "https://hub.test/auth-api"; const _api = "https://pay.test/api"; const _project = "field-notes"; void main() { late RSAPublicKey pub; late RSAPrivateKey priv; late Map jwks; // what the next /v1/licences call answers with, sku -> token. late Map served; // set to a body to return instead, for the failure cases. String? servedRaw; int licenceCalls = 0; setUpAll(() { final pair = genKey(2); pub = pair.publicKey as RSAPublicKey; priv = pair.privateKey as RSAPrivateKey; jwks = jwksOf(pub); }); setUp(() { served = {}; servedRaw = null; licenceCalls = 0; }); Future signedInAuth() async { final store = MemoryTokenStore(); await store.write("ga.access.test-client", "oauth_fake"); final mock = MockClient((req) async { final path = req.url.path; if (path.endsWith("/.well-known/openid-configuration")) { return http.Response( jsonEncode({ "authorization_endpoint": "$_issuer/oauth/authorize", "token_endpoint": "$_issuer/oauth/token", "userinfo_endpoint": "$_issuer/oauth/userinfo", }), 200, headers: {"content-type": "application/json"}, ); } if (path.endsWith("/oauth/userinfo")) { return http.Response( jsonEncode({"sub": "user-123"}), 200, headers: {"content-type": "application/json"}, ); } if (path.endsWith("/v1/licences")) { licenceCalls++; if (servedRaw != null) return http.Response(servedRaw!, 200); return http.Response( jsonEncode({ "licences": [ for (final e in served.entries) {"sku": e.key, "licence": e.value, "expires_in": 3600}, ], "jwks": jwks, }), 200, headers: {"content-type": "application/json"}, ); } return http.Response(jsonEncode({"error": "nope"}), 404); }); final auth = GarageAuth( issuer: _issuer, clientId: "test-client", redirectUri: "test://cb", httpClient: mock, tokenStore: store, ); await auth.restore(); return auth; } Future subject({KeyCache? cache}) async => GarageEntitlements( auth: await signedInAuth(), projectSlug: _project, apiBaseUrl: _api, cache: cache ?? MemoryKeyCache(), ); String tokenFor(String sku, {Duration life = const Duration(hours: 1)}) => keyToken(priv, project: _project, sku: sku, life: life); test("refresh verifies and holds every key it got", () async { final ent = await subject(); served = {"pro": tokenFor("pro"), "extras": tokenFor("extras")}; await ent.refresh(); expect(ent.has("pro"), isTrue); expect(ent.has("extras"), isTrue); expect(ent.has("never-bought"), isFalse); expect(ent.key("pro")!.sku, "pro"); }); // THE important one. a cancelled subscription stops coming back in the // response; if a refresh merged, its key would sit there working untill its // own exp — which could be a day. test("refresh REPLACES the set, it does not merge", () async { final cache = MemoryKeyCache(); final ent = await subject(cache: cache); served = {"pro": tokenFor("pro"), "extras": tokenFor("extras")}; await ent.refresh(); expect(ent.has("extras"), isTrue); // they cancelled "extras". it simply isnt in the response any more. served = {"pro": tokenFor("pro")}; await ent.refresh(); expect(ent.has("pro"), isTrue); expect(ent.has("extras"), isFalse, reason: "a dropped key must be evicted"); // and it is gone from disk too, not just from memory — otherwise the next // cold boot would bring it back. final blob = await cache.read(_project); expect(blob!.keys.keys, ["pro"]); }); test("everything gone means everything gone", () async { final ent = await subject(); served = {"pro": tokenFor("pro")}; await ent.refresh(); expect(ent.has("pro"), isTrue); served = {}; await ent.refresh(); expect(ent.keys, isEmpty); }); test("a response with one bad key changes nothing", () async { final cache = MemoryKeyCache(); final ent = await subject(cache: cache); served = {"pro": tokenFor("pro")}; await ent.refresh(); // second call carries a key signed by somebody else entirely final rogue = genKey(11).privateKey as RSAPrivateKey; served = { "pro": tokenFor("pro"), "extras": keyToken(rogue, project: _project, sku: "extras"), }; await expectLater(ent.refresh(), throwsA(isA())); // the good set from before is untouched — no half applied refresh. expect(ent.has("pro"), isTrue); final blob = await cache.read(_project); expect(blob!.keys.keys, ["pro"]); }); test("no jwks in the response is refused", () async { final ent = await subject(); servedRaw = jsonEncode({"licences": []}); await expectLater( ent.refresh(), throwsA(predicate((e) => e is EntitlementsError && e.code == "no_jwks")), ); }); test("cached() reads the blob back with no network at all", () async { final cache = MemoryKeyCache(); final first = await subject(cache: cache); served = {"pro": tokenFor("pro")}; await first.refresh(); final callsAfterRefresh = licenceCalls; final second = await subject(cache: cache); await second.cached(); expect(second.has("pro"), isTrue); // the second instance has its own mock, so this only proves the first one // wasnt asked again — which is the bit that matters. expect(licenceCalls, callsAfterRefresh); }); test("cached() drops an expired key and keeps the rest", () async { final cache = MemoryKeyCache(); // write a blob by hand: one live key, one that went stale on disk. await cache.write( _project, CachedKeys( keys: { "pro": tokenFor("pro"), "trial": tokenFor("trial", life: const Duration(hours: -1)), }, jwks: jwks, ), ); final ent = await subject(cache: cache); await ent.cached(); expect(ent.has("pro"), isTrue); expect(ent.has("trial"), isFalse); }); test("cached() with nothing stored is simply empty", () async { final ent = await subject(); await ent.cached(); expect(ent.keys, isEmpty); expect(ent.has("pro"), isFalse); }); test("clear() empties memory and disk", () async { final cache = MemoryKeyCache(); final ent = await subject(cache: cache); served = {"pro": tokenFor("pro")}; await ent.refresh(); await ent.clear(); expect(ent.has("pro"), isFalse); expect(await cache.read(_project), isNull); }); test("it notifies, so a ListenableBuilder redraws the gates", () async { final ent = await subject(); var fired = 0; ent.addListener(() => fired++); served = {"pro": tokenFor("pro")}; await ent.refresh(); expect(fired, 1); served = {}; await ent.refresh(); expect(fired, 2); }); test("not signed in is an error, not an empty set", () async { final auth = GarageAuth( issuer: _issuer, clientId: "test-client", redirectUri: "test://cb", httpClient: MockClient((_) async => http.Response("{}", 200)), tokenStore: MemoryTokenStore(), ); await auth.restore(); final ent = GarageEntitlements( auth: auth, projectSlug: _project, apiBaseUrl: _api, cache: MemoryKeyCache(), ); await expectLater( ent.refresh(), throwsA( predicate((e) => e is EntitlementsError && e.code == "not_signed_in"), ), ); }); }