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:
ImBenji
2026-09-23 18:49:21 +01:00
co-authored by Claude Opus 5.5
commit b269201919
117 changed files with 26944 additions and 0 deletions
+282
View File
@@ -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"),
),
);
});
}