The Garage SDKs, in the open
garage_auth, garage_entitlements, garage_iap and garage_ui, moved out of Garage-Services and Metro-Map-Maker into one public repo. MIT, one readme, docs under docs/. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
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<String, dynamic> jwks;
|
||||
|
||||
// what the next /v1/licences call answers with, sku -> token.
|
||||
late Map<String, String> 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<GarageAuth> 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<GarageEntitlements> 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<EntitlementsError>()));
|
||||
|
||||
// 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"),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user