feat(seo-data): label-keyed atomic OAuth token store
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deterministic tests for the seo-data engine (no network, no venv).
|
||||
set -u
|
||||
REPO="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
SD="$REPO/lib/seo-data"
|
||||
PASS=0; FAIL=0
|
||||
ok() { echo " PASS $1"; PASS=$((PASS+1)); }
|
||||
no() { echo " FAIL $1 — $2"; FAIL=$((FAIL+1)); }
|
||||
# assert stdout of a command contains / omits a fixed string
|
||||
has() { if printf '%s' "$2" | grep -qF -- "$3"; then ok "$1"; else no "$1" "missing: $3"; fi; }
|
||||
hasnt(){ if printf '%s' "$2" | grep -qF -- "$3"; then no "$1" "forbidden: $3"; else ok "$1"; fi; }
|
||||
|
||||
echo "── tokenstore ──"
|
||||
TMP="$(mktemp -d)"; STORE="$TMP/tokens.json"
|
||||
python3 "$SD/tokenstore.py" set --file "$STORE" --label client-a \
|
||||
--refresh-token RT_AAA --scopes https://www.googleapis.com/auth/webmasters.readonly \
|
||||
--properties sc-domain:a.com,https://www.a.com/ >/dev/null
|
||||
python3 "$SD/tokenstore.py" set --file "$STORE" --label client-b \
|
||||
--refresh-token RT_BBB --scopes https://www.googleapis.com/auth/webmasters.readonly \
|
||||
--properties sc-domain:b.com >/dev/null
|
||||
LIST="$(python3 "$SD/tokenstore.py" list --file "$STORE")"
|
||||
has "list shows client-a" "$LIST" '"client-a"'
|
||||
has "list shows client-b" "$LIST" '"client-b"'
|
||||
has "list shows a property" "$LIST" 'sc-domain:a.com'
|
||||
hasnt "list redacts refresh tokens" "$LIST" 'RT_AAA'
|
||||
PERM="$(stat -c '%a' "$STORE")"
|
||||
[ "$PERM" = "600" ] && ok "store file is 0600" || no "store file 0600" "got $PERM"
|
||||
rm -rf "$TMP"
|
||||
|
||||
echo ""
|
||||
echo "seo-data engine: $PASS pass, $FAIL fail"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Label-keyed OAuth refresh-token store. Atomic writes under an fcntl lock.
|
||||
No third-party deps — must run without the venv (used by the offline test path)."""
|
||||
import argparse, fcntl, json, os, sys, tempfile
|
||||
from datetime import datetime, timezone
|
||||
|
||||
def load(path):
|
||||
if not os.path.exists(path):
|
||||
return {"version": 1, "accounts": {}}
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
def list_accounts(path):
|
||||
data = load(path)
|
||||
return [
|
||||
{"label": lbl, "properties": a.get("properties", []),
|
||||
"granted_at": a.get("granted_at")}
|
||||
for lbl, a in data.get("accounts", {}).items()
|
||||
] # refresh_token intentionally omitted (redaction)
|
||||
|
||||
def get_refresh_token(path, label):
|
||||
return load(path).get("accounts", {}).get(label, {}).get("refresh_token")
|
||||
|
||||
def save_account(path, label, refresh_token, scopes, properties):
|
||||
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
|
||||
lock_path = path + ".lock"
|
||||
with open(lock_path, "w") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX) # serialize concurrent connects
|
||||
data = load(path)
|
||||
data.setdefault("version", 1)
|
||||
data.setdefault("accounts", {})
|
||||
data["accounts"][label] = {
|
||||
"refresh_token": refresh_token,
|
||||
"scopes": scopes,
|
||||
"granted_at": datetime.now(timezone.utc).isoformat(),
|
||||
"properties": properties,
|
||||
}
|
||||
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.flush(); os.fsync(f.fileno())
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, path) # atomic
|
||||
finally:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
|
||||
def _cli():
|
||||
p = argparse.ArgumentParser()
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
pl = sub.add_parser("list"); pl.add_argument("--file", required=True)
|
||||
ps = sub.add_parser("set")
|
||||
for flag in ("--file", "--label", "--refresh-token"):
|
||||
ps.add_argument(flag, required=True)
|
||||
ps.add_argument("--scopes", default="")
|
||||
ps.add_argument("--properties", default="")
|
||||
args = p.parse_args()
|
||||
if args.cmd == "list":
|
||||
print(json.dumps({"status": "ok", "accounts": list_accounts(args.file)}))
|
||||
else:
|
||||
save_account(args.file, args.label, getattr(args, "refresh_token"),
|
||||
[s for s in args.scopes.split(",") if s],
|
||||
[x for x in args.properties.split(",") if x])
|
||||
print(json.dumps({"status": "ok"}))
|
||||
|
||||
if __name__ == "__main__":
|
||||
_cli()
|
||||
Reference in New Issue
Block a user