mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:07:18 +01:00
Gate: no Google non-free sign-in in apps
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
scripts/check_no_gms_signin.sh fails when a pubspec.lock or android gradle file pulls google_sign_in / play-services-auth / googleid, or a built APK (--apk) carries GMS auth classes, and names google_account_token as the fix. install_repo_contract_gate.sh now installs its shim and an always-run hook in Flutter Android repos. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWVQE5pYiASpRGgA3Kg
This commit is contained in:
parent
17e300af0a
commit
9daa376254
@ -93,6 +93,14 @@ web-relevant file is staged (`web.DEFAULT_FILES`, overridable with
|
||||
degrades to a pass, and the gate defers to that workflow when run inside
|
||||
another GitHub Actions job.
|
||||
|
||||
## Flutter apps: no Google non-free sign-in
|
||||
|
||||
A repo with a `pubspec.yaml` beside an `android/` dir (root or one level
|
||||
down) also gets `scripts/check_no_gms_signin.sh` (a shim) and the always-run
|
||||
`no-gms-signin` hook (`install_gms.py`). The shared gate fails on
|
||||
google_sign_in / play-services-auth / googleid in pubspec.lock or gradle, or
|
||||
in a built APK with `--apk`; apps use `google_account_token` instead.
|
||||
|
||||
## Flutter apps: the launcher rules
|
||||
|
||||
Decided by kuhy 2026-10-23 (`launcher.py`). They apply to every tracked
|
||||
|
||||
@ -25,6 +25,7 @@ from dep_freshness.install import NO_WORKFLOW_MARKER
|
||||
from repo_contract import bootstrap
|
||||
from repo_contract.check import violations
|
||||
from repo_contract.infer import propose
|
||||
from repo_contract.install_gms import install_gms, plan_gms
|
||||
from repo_contract.install_web import install_web, plan_web
|
||||
from repo_contract.precommit import PRECOMMIT, needs_hook, write_hook
|
||||
from repo_contract.stacks import detect_stacks
|
||||
@ -64,6 +65,7 @@ def plan(repo: Path) -> list[str]:
|
||||
|
||||
A website (detected or declared in repo-contract.toml) also gets the
|
||||
AgentReady gate: config, shim, workflow and hook (see install_web.py).
|
||||
A Flutter Android app also gets the no-GMS-sign-in gate (install_gms.py).
|
||||
"""
|
||||
todo = []
|
||||
if _needs_shim(repo):
|
||||
@ -72,7 +74,7 @@ def plan(repo: Path) -> list[str]:
|
||||
todo.append(str(WORKFLOW))
|
||||
if needs_hook(repo, HOOK_ID):
|
||||
todo.append(f"{PRECOMMIT} ({HOOK_ID} hook)")
|
||||
return todo + plan_web(repo)
|
||||
return todo + plan_web(repo) + plan_gms(repo)
|
||||
|
||||
|
||||
def install(repo: Path) -> list[str]:
|
||||
@ -92,7 +94,7 @@ def install(repo: Path) -> list[str]:
|
||||
if needs_hook(repo, HOOK_ID):
|
||||
write_hook(repo, _template("pre-commit-snippet.yaml"))
|
||||
done.append(f"{PRECOMMIT} ({HOOK_ID} hook)")
|
||||
return done + install_web(repo)
|
||||
return done + install_web(repo) + install_gms(repo)
|
||||
|
||||
|
||||
def bootstrap_commands(repo: Path) -> list[str]:
|
||||
|
||||
62
repo_contract/install_gms.py
Normal file
62
repo_contract/install_gms.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""The no-GMS-sign-in half of the installer: Flutter Android apps get the gate.
|
||||
|
||||
Why it rides on install_repo_contract_gate.sh: that installer is the one step
|
||||
every new repo gets after `git init`, so a new Flutter app cannot come out of
|
||||
scaffolding able to re-add google_sign_in (13 apps were migrated off it onto
|
||||
utils/google_account_token in November 2026). Pieces: the
|
||||
scripts/check_no_gms_signin.sh delegate and an always-run pre-commit hook.
|
||||
Only repos with a pubspec.yaml next to an android/ dir (at the root or one
|
||||
level down, e.g. tutor/app) get it; utils itself holds the real gate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from repo_contract.precommit import PRECOMMIT, needs_hook, write_hook
|
||||
|
||||
TEMPLATES = Path(__file__).resolve().parent / "templates"
|
||||
SHIM = Path("scripts/check_no_gms_signin.sh")
|
||||
HOOK_ID = "no-gms-signin"
|
||||
HOOK_PIECE = f"{PRECOMMIT} ({HOOK_ID} hook)"
|
||||
SHIM_TEMPLATE = "check_no_gms_signin.shim.sh"
|
||||
|
||||
|
||||
def _template(name: str) -> str:
|
||||
return (TEMPLATES / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def is_flutter_android(repo: Path) -> bool:
|
||||
"""A pubspec.yaml beside an android/ dir, at the root or one level down."""
|
||||
if (repo / "repo_contract" / "check.py").is_file():
|
||||
return False # utils: SHIM is the real gate, never a delegate
|
||||
pubspecs = [repo / "pubspec.yaml", *repo.glob("*/pubspec.yaml")]
|
||||
return any(p.is_file() and (p.parent / "android").is_dir() for p in pubspecs)
|
||||
|
||||
|
||||
def plan_gms(repo: Path) -> list[str]:
|
||||
"""Which no-GMS-sign-in gate pieces are missing or drifted."""
|
||||
if not is_flutter_android(repo):
|
||||
return []
|
||||
todo = []
|
||||
shim = repo / SHIM
|
||||
if not shim.is_file() or shim.read_text(encoding="utf-8") != _template(
|
||||
SHIM_TEMPLATE
|
||||
):
|
||||
todo.append(str(SHIM))
|
||||
if needs_hook(repo, HOOK_ID):
|
||||
todo.append(HOOK_PIECE)
|
||||
return todo
|
||||
|
||||
|
||||
def install_gms(repo: Path) -> list[str]:
|
||||
"""Write every missing or drifted no-GMS-sign-in piece. Returns them."""
|
||||
todo = plan_gms(repo)
|
||||
if str(SHIM) in todo:
|
||||
target = repo / SHIM
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(_template(SHIM_TEMPLATE), encoding="utf-8")
|
||||
target.chmod(0o755)
|
||||
if HOOK_PIECE in todo:
|
||||
write_hook(repo, _template("no-gms-signin-hook.yaml"))
|
||||
return todo
|
||||
27
repo_contract/templates/check_no_gms_signin.shim.sh
Normal file
27
repo_contract/templates/check_no_gms_signin.shim.sh
Normal file
@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# Fail if this app links Google's non-free sign-in (google_sign_in,
|
||||
# play-services-auth, googleid) instead of google_account_token.
|
||||
#
|
||||
# Thin delegate to the shared gate in ~/src/utils, which owns the package
|
||||
# list and the fix message. Copy this file to scripts/check_no_gms_signin.sh
|
||||
# in a repo; never copy the shared gate itself.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly SHARED_GATE="${UTILS_ROOT:-$HOME/src/utils}/scripts/check_no_gms_signin.sh"
|
||||
|
||||
main() {
|
||||
if [[ ! -x "$SHARED_GATE" ]]; then
|
||||
echo "Error: shared no-gms-signin gate not found at $SHARED_GATE" >&2
|
||||
echo " Clone github.com/kuhyx/utils to ~/src/utils, or set" >&2
|
||||
echo " UTILS_ROOT to where it lives." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec bash "$SHARED_GATE" "$@"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
12
repo_contract/templates/no-gms-signin-hook.yaml
Normal file
12
repo_contract/templates/no-gms-signin-hook.yaml
Normal file
@ -0,0 +1,12 @@
|
||||
# Added under `repos: - repo: local: hooks:` by install_repo_contract_gate.sh
|
||||
# for any repo with a pubspec.yaml beside an android/ dir (a Flutter app).
|
||||
# No Google non-free sign-in: google_sign_in links play-services-auth /
|
||||
# googleid into the APK; apps use utils/google_account_token instead.
|
||||
# always_run rather than `files:`: a poisoned pubspec.lock can arrive by
|
||||
# pull or merge, and a staged-only trigger would never look again.
|
||||
- id: no-gms-signin
|
||||
name: no google_sign_in / GMS auth (use google_account_token)
|
||||
entry: scripts/check_no_gms_signin.sh .
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
77
repo_contract/tests/test_install_gms.py
Normal file
77
repo_contract/tests/test_install_gms.py
Normal file
@ -0,0 +1,77 @@
|
||||
"""A Flutter repo with an Android target gets the no-GMS-sign-in gate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import stat
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from repo_contract import install as inst
|
||||
from repo_contract import install_gms as gms
|
||||
|
||||
from .conftest import write
|
||||
|
||||
|
||||
def flutter_android(root: Path, sub: str = "") -> Path:
|
||||
"""A Flutter project at `root/sub` with an android/ dir."""
|
||||
base = f"{sub}/" if sub else ""
|
||||
write(root, f"{base}pubspec.yaml", "name: x\n")
|
||||
write(root, f"{base}android/app/build.gradle.kts", "plugins {}\n")
|
||||
return root
|
||||
|
||||
|
||||
def test_no_pubspec_means_no_gate(repo: Path) -> None:
|
||||
assert gms.plan_gms(repo) == []
|
||||
assert gms.install_gms(repo) == []
|
||||
|
||||
|
||||
def test_pubspec_without_android_means_no_gate(repo: Path) -> None:
|
||||
write(repo, "pubspec.yaml", "name: x\n")
|
||||
assert gms.plan_gms(repo) == []
|
||||
|
||||
|
||||
def test_root_flutter_android_gets_shim_and_hook(repo: Path) -> None:
|
||||
flutter_android(repo)
|
||||
assert gms.plan_gms(repo) == [str(gms.SHIM), gms.HOOK_PIECE]
|
||||
assert inst.install(repo)[-2:] == [str(gms.SHIM), gms.HOOK_PIECE]
|
||||
shim = repo / gms.SHIM
|
||||
assert shim.stat().st_mode & stat.S_IXUSR
|
||||
config = (repo / inst.PRECOMMIT).read_text(encoding="utf-8")
|
||||
assert f"id: {gms.HOOK_ID}" in config
|
||||
assert "always_run: true" in config
|
||||
assert gms.plan_gms(repo) == []
|
||||
assert gms.install_gms(repo) == []
|
||||
|
||||
|
||||
def test_nested_app_dir_counts(repo: Path) -> None:
|
||||
flutter_android(repo, "app")
|
||||
assert gms.plan_gms(repo) == [str(gms.SHIM), gms.HOOK_PIECE]
|
||||
|
||||
|
||||
def test_drifted_shim_is_rewritten_hook_kept(repo: Path) -> None:
|
||||
flutter_android(repo)
|
||||
gms.install_gms(repo)
|
||||
write(repo, gms.SHIM.as_posix(), "#!/bin/bash\nexit 0\n")
|
||||
assert gms.install_gms(repo) == [str(gms.SHIM)]
|
||||
|
||||
|
||||
def test_utils_itself_is_never_given_a_shim(repo: Path) -> None:
|
||||
flutter_android(repo, "google_account_token")
|
||||
write(repo, "repo_contract/check.py")
|
||||
assert gms.plan_gms(repo) == []
|
||||
|
||||
|
||||
def test_installed_shim_delegates_to_the_shared_gate(repo: Path) -> None:
|
||||
flutter_android(repo)
|
||||
write(repo, "pubspec.lock", "packages:\n google_sign_in_android:\n")
|
||||
gms.install_gms(repo)
|
||||
utils_root = Path(__file__).resolve().parents[2]
|
||||
proc = subprocess.run(
|
||||
["bash", str(repo / gms.SHIM), str(repo)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env={"UTILS_ROOT": str(utils_root), "PATH": "/usr/bin:/bin"},
|
||||
)
|
||||
assert proc.returncode == 1
|
||||
assert "google_account_token" in proc.stderr
|
||||
180
scripts/check_no_gms_signin.sh
Executable file
180
scripts/check_no_gms_signin.sh
Executable file
@ -0,0 +1,180 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# Fail if a Flutter/Android app links Google's non-free sign-in stack.
|
||||
#
|
||||
# Every app signs in through kuhy's FOSS plugin google_account_token
|
||||
# (utils/google_account_token, Android AccountManager). google_sign_in pulls
|
||||
# google_sign_in_android, which links play-services-auth / googleid into the
|
||||
# APK. Nine apps were migrated off it on 2026-11-02 and four more on
|
||||
# 2026-11-10; nothing else stops a later session from re-adding it.
|
||||
#
|
||||
# Lockfile mode (default) reads pubspec.lock package keys, plus the gradle
|
||||
# files under each lock's sibling android/ dir. Web-only pieces
|
||||
# (google_sign_in_web, google_sign_in_platform_interface and their transitive
|
||||
# google_identity_services_web) are allowed while google_sign_in_android is
|
||||
# absent: kartka's web build uses them for the GIS button and they never
|
||||
# reach an APK. APK mode greps the built dex for the GMS auth classes, the
|
||||
# ground truth when a dependency arrives some other way.
|
||||
#
|
||||
# Usage: check_no_gms_signin.sh [pubspec.lock | dir ...]
|
||||
# check_no_gms_signin.sh --apk app.apk
|
||||
# A dir is searched for pubspec.lock files (build/, .dart_tool/ pruned).
|
||||
# With no arguments, checks ./pubspec.lock (passes if there is none).
|
||||
# Exit: 0 clean | 1 non-free sign-in found | 2 usage error
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
readonly SCRIPT_NAME
|
||||
readonly BANNED=(google_sign_in google_sign_in_android google_sign_in_ios)
|
||||
readonly WEB_ONLY=(google_sign_in_web google_sign_in_platform_interface
|
||||
google_identity_services_web)
|
||||
# Maven coordinates (and split-form catalog names) of the GMS sign-in stack.
|
||||
# Anchored so com.google.gms:google-services (Firebase) and
|
||||
# play-services-auth-api-phone do not trip it.
|
||||
readonly GRADLE_PATTERN='com\.google\.android\.gms:play-services-(auth|identity-credentials)([^-[:alnum:]]|$)|com\.google\.android\.libraries\.identity\.googleid|androidx\.credentials:credentials-play-services-auth|name[[:space:]]*=[[:space:]]*"(play-services-auth|play-services-identity-credentials|credentials-play-services-auth|googleid)"'
|
||||
readonly DEX_PATTERN='Lcom/google/android/(gms/auth|libraries/identity/googleid)/'
|
||||
readonly PRUNE=(-name build -o -name .dart_tool -o -name .git -o -name .gradle -o -name .claude
|
||||
-o -name node_modules)
|
||||
|
||||
usage() {
|
||||
echo "Usage: $SCRIPT_NAME [pubspec.lock | dir ...]"
|
||||
echo " $SCRIPT_NAME --apk app.apk"
|
||||
echo " Fails when an app links Google's non-free sign-in (GMS auth)."
|
||||
exit 0
|
||||
}
|
||||
|
||||
fix_hint() {
|
||||
echo " Use google_account_token instead (FOSS, AccountManager):" >&2
|
||||
echo " google_account_token:" >&2
|
||||
echo " git:" >&2
|
||||
echo " url: https://github.com/kuhyx/utils" >&2
|
||||
echo " ref: google_account_token-v0.1.0" >&2
|
||||
echo " path: google_account_token" >&2
|
||||
echo " See utils/google_account_token/README.md." >&2
|
||||
}
|
||||
|
||||
# Package keys of a pubspec.lock: the 2-space keys under `packages:`.
|
||||
lock_packages() {
|
||||
awk '
|
||||
/^packages:[[:space:]]*$/ { in_packages = 1; next }
|
||||
/^[^[:space:]]/ { in_packages = 0; next }
|
||||
in_packages && /^ [A-Za-z0-9_]+:[[:space:]]*$/ {
|
||||
sub(/^ /, ""); sub(/:.*/, ""); print
|
||||
}
|
||||
' "$1"
|
||||
}
|
||||
|
||||
# Prints the offending packages of one lock, one per line.
|
||||
lock_offenders() {
|
||||
local -A present=()
|
||||
local name
|
||||
while IFS= read -r name; do
|
||||
present["$name"]=1
|
||||
done < <(lock_packages "$1")
|
||||
for name in "${BANNED[@]}"; do
|
||||
[[ -n "${present[$name]:-}" ]] && echo "$name"
|
||||
done
|
||||
if [[ -n "${present[google_sign_in_android]:-}" ]]; then
|
||||
for name in "${WEB_ONLY[@]}"; do
|
||||
[[ -n "${present[$name]:-}" ]] && echo "$name"
|
||||
done
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Prints file:line:text for every non-comment GMS sign-in coordinate under
|
||||
# the lock's sibling android/ dir.
|
||||
gradle_offenders() {
|
||||
local android_dir="$1/android" file
|
||||
[[ -d "$android_dir" ]] || return 0
|
||||
while IFS= read -r -d '' file; do
|
||||
grep -nE "$GRADLE_PATTERN" "$file" \
|
||||
| grep -vE '^[0-9]+:[[:space:]]*(//|#|\*|/\*)' \
|
||||
| sed "s|^|$file:|" || true
|
||||
done < <(find "$android_dir" \( "${PRUNE[@]}" \) -prune -o -type f \
|
||||
\( -name 'build.gradle*' -o -name '*.versions.toml' \) -print0)
|
||||
}
|
||||
|
||||
indent() {
|
||||
local line
|
||||
while IFS= read -r line; do
|
||||
echo " $line"
|
||||
done
|
||||
}
|
||||
|
||||
check_lock() {
|
||||
local lock="$1" offenders gradle
|
||||
offenders="$(lock_offenders "$lock")"
|
||||
gradle="$(gradle_offenders "$(dirname "$lock")")"
|
||||
[[ -z "$offenders" && -z "$gradle" ]] && return 0
|
||||
if [[ -n "$offenders" ]]; then
|
||||
echo "Error: $lock links Google's non-free sign-in:" >&2
|
||||
indent <<<"$offenders" >&2
|
||||
fi
|
||||
if [[ -n "$gradle" ]]; then
|
||||
echo "Error: gradle names the GMS sign-in stack:" >&2
|
||||
indent <<<"$gradle" >&2
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
check_apk() {
|
||||
local apk="$1" dex count
|
||||
[[ -f "$apk" ]] || { echo "Error: no such APK: $apk" >&2; exit 2; }
|
||||
count="$(for dex in $(unzip -Z1 "$apk" 'classes*.dex'); do
|
||||
unzip -p "$apk" "$dex"
|
||||
done | strings | grep -cE "$DEX_PATTERN" || true)"
|
||||
if [[ "$count" -gt 0 ]]; then
|
||||
echo "Error: $apk has $count GMS sign-in class references" >&2
|
||||
echo " (Lcom/google/android/gms/auth or .../identity/googleid)." >&2
|
||||
fix_hint
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Expands the arguments into pubspec.lock paths, NUL-separated.
|
||||
collect_locks() {
|
||||
local arg
|
||||
for arg in "$@"; do
|
||||
if [[ -d "$arg" ]]; then
|
||||
find "$arg" \( "${PRUNE[@]}" \) -prune -o -type f \
|
||||
-name pubspec.lock -print0
|
||||
else
|
||||
printf '%s\0' "$arg"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
main() {
|
||||
case "${1:-}" in
|
||||
-h|--help) usage ;;
|
||||
--apk)
|
||||
[[ $# -eq 2 ]] || { echo "Usage: $SCRIPT_NAME --apk app.apk" >&2; exit 2; }
|
||||
check_apk "$2"
|
||||
return
|
||||
;;
|
||||
esac
|
||||
if [[ $# -eq 0 ]]; then
|
||||
[[ -f pubspec.lock ]] || return 0
|
||||
set -- pubspec.lock
|
||||
fi
|
||||
|
||||
local -a locks=()
|
||||
local arg lock failed=0
|
||||
# Validated up front: collect_locks runs in a process substitution, whose
|
||||
# exit status would be lost, and a typo'd path must not pass silently.
|
||||
for arg in "$@"; do
|
||||
[[ -e "$arg" ]] || { echo "Error: no such file or directory: $arg" >&2; exit 2; }
|
||||
done
|
||||
mapfile -d '' locks < <(collect_locks "$@")
|
||||
for lock in "${locks[@]}"; do
|
||||
check_lock "$lock" || failed=1
|
||||
done
|
||||
[[ "$failed" -eq 0 ]] || fix_hint
|
||||
return "$failed"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@ -9,6 +9,7 @@
|
||||
# when the repo has .dep-freshness-no-workflow), Commands section. A website
|
||||
# (Vite/Astro/index.html build, or [agent_ready] in repo-contract.toml) also
|
||||
# gets the AgentReady gate: config table, shim, hook, agent-ready workflow.
|
||||
# A Flutter app with android/ also gets the no-GMS-sign-in gate (shim + hook).
|
||||
#
|
||||
# Usage: scripts/install_repo_contract_gate.sh <repo> [--check] [--no-bootstrap]
|
||||
# Exit: 0 contract satisfied | 1 violations left (e.g. <FILL IN>) | 2 not a git repo
|
||||
|
||||
158
scripts/tests/test_check_no_gms_signin.py
Normal file
158
scripts/tests/test_check_no_gms_signin.py
Normal file
@ -0,0 +1,158 @@
|
||||
"""check_no_gms_signin.sh: no app may link Google's non-free sign-in stack.
|
||||
|
||||
Each test writes a throwaway Flutter-shaped project (pubspec.lock, optional
|
||||
android/ gradle files) or a fake APK built with zipfile, runs the real
|
||||
script, and asserts on the exit code and the message.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parent.parent / "check_no_gms_signin.sh"
|
||||
|
||||
# The package keys of kuhy's real kartka lock (web-only GIS button).
|
||||
KARTKA_KEYS = (
|
||||
"google_account_token",
|
||||
"google_identity_services_web",
|
||||
"google_sign_in_platform_interface",
|
||||
"google_sign_in_web",
|
||||
)
|
||||
|
||||
|
||||
def _lock(root: Path, *packages: str) -> Path:
|
||||
"""Write a pubspec.lock under `root` with the given package keys."""
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
body = ["# Generated by pub", "packages:"]
|
||||
for name in ("collection", *packages):
|
||||
body += [
|
||||
f" {name}:",
|
||||
' dependency: "direct main"',
|
||||
" description:",
|
||||
f" name: {name}",
|
||||
" source: hosted",
|
||||
' version: "1.0.0"',
|
||||
]
|
||||
body += ["sdks:", ' dart: ">=3.0.0 <4.0.0"', ""]
|
||||
lock = root / "pubspec.lock"
|
||||
lock.write_text("\n".join(body), encoding="utf-8")
|
||||
return lock
|
||||
|
||||
|
||||
def _run(*args: str | Path) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["bash", str(SCRIPT), *map(str, args)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def test_clean_lock_passes(tmp_path: Path) -> None:
|
||||
lock = _lock(tmp_path, "google_account_token", "http")
|
||||
assert _run(lock).returncode == 0
|
||||
|
||||
|
||||
def test_google_sign_in_android_fails_and_names_the_fix(tmp_path: Path) -> None:
|
||||
lock = _lock(tmp_path, "google_sign_in_android")
|
||||
proc = _run(lock)
|
||||
assert proc.returncode == 1
|
||||
assert str(lock) in proc.stderr
|
||||
assert "google_sign_in_android" in proc.stderr
|
||||
assert "google_account_token-v0.1.0" in proc.stderr
|
||||
assert "google_account_token/README.md" in proc.stderr
|
||||
|
||||
|
||||
def test_app_facing_google_sign_in_fails(tmp_path: Path) -> None:
|
||||
proc = _run(_lock(tmp_path, "google_sign_in"))
|
||||
assert proc.returncode == 1
|
||||
assert "google_sign_in " in proc.stderr or "google_sign_in\n" in proc.stderr
|
||||
|
||||
|
||||
def test_kartka_style_web_only_passes(tmp_path: Path) -> None:
|
||||
assert _run(_lock(tmp_path, *KARTKA_KEYS)).returncode == 0
|
||||
|
||||
|
||||
def test_web_pieces_fail_once_android_is_linked(tmp_path: Path) -> None:
|
||||
proc = _run(_lock(tmp_path, *KARTKA_KEYS, "google_sign_in_android"))
|
||||
assert proc.returncode == 1
|
||||
assert "google_sign_in_web" in proc.stderr
|
||||
|
||||
|
||||
def test_gradle_play_services_auth_fails(tmp_path: Path) -> None:
|
||||
lock = _lock(tmp_path)
|
||||
gradle = tmp_path / "android" / "app" / "build.gradle.kts"
|
||||
gradle.parent.mkdir(parents=True)
|
||||
gradle.write_text(
|
||||
'dependencies {\n implementation("com.google.gms:google-services:4")\n'
|
||||
' implementation("com.google.android.gms:play-services-auth:21.3.0")\n}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = _run(lock)
|
||||
assert proc.returncode == 1
|
||||
assert str(gradle) in proc.stderr
|
||||
assert "play-services-auth" in proc.stderr
|
||||
|
||||
|
||||
def test_versions_toml_googleid_fails(tmp_path: Path) -> None:
|
||||
lock = _lock(tmp_path)
|
||||
toml = tmp_path / "android" / "gradle" / "libs.versions.toml"
|
||||
toml.parent.mkdir(parents=True)
|
||||
toml.write_text(
|
||||
'googleid = "com.google.android.libraries.identity.googleid:googleid:1.1"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert _run(lock).returncode == 1
|
||||
|
||||
|
||||
def test_firebase_google_services_plugin_passes(tmp_path: Path) -> None:
|
||||
lock = _lock(tmp_path)
|
||||
gradle = tmp_path / "android" / "build.gradle"
|
||||
gradle.parent.mkdir(parents=True)
|
||||
gradle.write_text(
|
||||
"// play-services-auth was removed 2026-11-02\n"
|
||||
"classpath 'com.google.gms:google-services:4.4.2'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert _run(lock).returncode == 0
|
||||
|
||||
|
||||
def test_directory_mode_finds_nested_locks_and_prunes_build(tmp_path: Path) -> None:
|
||||
_lock(tmp_path / "app", "google_account_token")
|
||||
_lock(tmp_path / "app" / "build" / "x", "google_sign_in_android")
|
||||
_lock(tmp_path / ".dart_tool" / "y", "google_sign_in_android")
|
||||
# Agent worktrees are other checkouts, judged in their own repo.
|
||||
_lock(tmp_path / ".claude" / "worktrees" / "z", "google_sign_in_android")
|
||||
assert _run(tmp_path).returncode == 0
|
||||
_lock(tmp_path / "other", "google_sign_in_ios")
|
||||
proc = _run(tmp_path)
|
||||
assert proc.returncode == 1
|
||||
assert "google_sign_in_ios" in proc.stderr
|
||||
|
||||
|
||||
def test_missing_explicit_file_is_a_usage_error(tmp_path: Path) -> None:
|
||||
assert _run(tmp_path / "nope.lock").returncode == 2
|
||||
|
||||
|
||||
def _apk(path: Path, dex: bytes) -> Path:
|
||||
with zipfile.ZipFile(path, "w") as apk:
|
||||
apk.writestr("AndroidManifest.xml", b"\x03\x00")
|
||||
apk.writestr("classes.dex", b"dex\n035\x00" + dex)
|
||||
apk.writestr("classes2.dex", b"dex\n035\x00Lkotlin/Unit;\x00")
|
||||
return path
|
||||
|
||||
|
||||
def test_apk_with_gms_auth_classes_fails(tmp_path: Path) -> None:
|
||||
dex = b"\x00Lcom/google/android/gms/auth/api/signin/GoogleSignIn;\x00" * 2
|
||||
proc = _run("--apk", _apk(tmp_path / "bad.apk", dex))
|
||||
assert proc.returncode == 1
|
||||
assert "2" in proc.stderr
|
||||
assert "google_account_token" in proc.stderr
|
||||
|
||||
|
||||
def test_apk_without_gms_auth_classes_passes(tmp_path: Path) -> None:
|
||||
dex = b"\x00Lcom/google/android/gms/common/api/Status;\x00"
|
||||
proc = _run("--apk", _apk(tmp_path / "good.apk", dex))
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
Loading…
Reference in New Issue
Block a user