diff --git a/README.md b/README.md index 1d5224b..5e56364 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ share for translations. One package, `session_ops`, deployed to one self-hosted | `snode-list` | Weekday: the fallback service node list into session-ios | [snode-list](docs/jobs/snode-list.md) | | `release-stats` | On demand: download counts of the latest releases | [release-stats](docs/jobs/release-stats.md) | | `session-ops-silence` | Discord alerts for a job that failed, or stopped running | [session-ops-silence](docs/jobs/session-ops-silence.md) | +| `token-expiry` | Discord alerts 14 days, 7 days and 24 hours before a token expires | [token-expiry](docs/jobs/token-expiry.md) | Run by hand, not scheduled: [`sogs-ban`](docs/tools/sogs-ban.md) for community bans, and [`sogs-perms`](src/session_ops/sogs/perms.py) for a room's per-account permissions. diff --git a/deploy/README.md b/deploy/README.md index e0fe969..314bab1 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -56,6 +56,8 @@ files and state over. Remove `/opt/zendesk`, `/etc/zendesk` and `/var/lib/zendes Each `/etc/session-ops/.env` has a commented `.env.example` beside it, installed from [`env/`](env/), saying what goes in it. +`/etc/session-ops/expiry.toml` is not a secret: the expiry dates +[token-expiry](../docs/jobs/token-expiry.md) cannot read from an API, from `expiry.toml.example`. ## Checking diff --git a/deploy/env/expiry.toml.example b/deploy/env/expiry.toml.example new file mode 100644 index 0000000..ddf894c --- /dev/null +++ b/deploy/env/expiry.toml.example @@ -0,0 +1,14 @@ +# /etc/session-ops/expiry.toml: what token-expiry cannot learn by itself. Not a +# secret: mode 644. + +[expires] +# Crowdin → Account Settings → API → Personal Access Tokens, the Expires column. +# A date, or "never" when it does not expire or this host does not use it. +#CROWDIN_API_TOKEN = "never" + +# Only for a Claude Code token installed before token-expiry first ran: it otherwise +# dates a token from the day it first saw it. Ignored once that token is replaced. +# The fingerprint is printed by `session-ops run token-expiry --dry-run`. +#[issued.CLAUDE_CODE_OAUTH_TOKEN] +#fingerprint = "3f2a9c01b4de" +#date = 2025-11-20 diff --git a/deploy/install.sh b/deploy/install.sh index 6b78646..74d4fde 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -73,7 +73,7 @@ for name in zendesk github-prs crowdin publish alerts; do done # What each file takes, commented; the env files stay empty until filled, since a job # is enabled once its env files have content. -install -m 644 "$ROOT"/deploy/env/*.env.example "$ETC/" +install -m 644 "$ROOT"/deploy/env/*.example "$ETC/" # The publishing units load this as a credential, and a missing file would stop them # starting; left empty, their runs exit naming the key. [ -e "$ETC/github-app.pem" ] || install -m 600 /dev/null "$ETC/github-app.pem" diff --git a/docs/jobs/token-expiry.md b/docs/jobs/token-expiry.md new file mode 100644 index 0000000..a164f43 --- /dev/null +++ b/docs/jobs/token-expiry.md @@ -0,0 +1,30 @@ +# Token Expiry Alerts + +Posts to the alerts channel 14 days, 7 days and 24 hours before a token the jobs use +expires, and once when it has. Quiet otherwise. Times are UTC; a date without one, such +as Crowdin's, counts from 00:00 UTC that day. + +| | | +| --- | --- | +| Runs | `session-ops@token-expiry.timer`, daily at 09:00 Melbourne | +| Secrets | `/etc/session-ops/alerts.env`: `ALERT_DISCORD_WEBHOOK_URL`; `github-prs.env` and `zendesk.env` for the tokens it checks | +| Dates | `/etc/session-ops/expiry.toml`, from [`expiry.toml.example`](../../deploy/env/expiry.toml.example) | +| Dry run | `session-ops run token-expiry --dry-run` prints each token's expiry and the alert it would post | +| Logs | `journalctl -u session-ops@token-expiry -n 50 --no-pager` | + +| Token | Expiry from | +| --- | --- | +| `GITHUB_PRS_TOKEN` | GitHub's `github-authentication-token-expiration` header; nothing to update on rotation | +| `CROWDIN_API_TOKEN` | `expiry.toml`: the Expires column at https://crowdin.com/settings#api-key | +| `CLAUDE_CODE_OAUTH_TOKEN` | A year after the job first saw it, by fingerprint; nothing to update on rotation | + +`CROWDIN_API_TOKEN` is reported on every daily run until `expiry.toml` has a date or +`"never"` for it. + +The Claude Code token's first sighting lives in `/var/lib/session-ops/token-expiry/state.json`. +For a token installed before the job first ran, or if that file is lost, add its real +issue date under `[issued]` with the fingerprint the dry run prints; the entry is +ignored once the token changes. A date that is wrong is not caught: the Zendesk +digest's failure alert, which names a dead Claude login, is then the backstop. + +The Zendesk API token, the Discord webhooks and the GitHub App key do not expire. diff --git a/pyproject.toml b/pyproject.toml index 8480762..b8d908c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ sogs-perms = "session_ops.sogs.perms:cli" session-ops = "session_ops.ops.runner:main" session-ops-alert = "session_ops.monitor.alert:main" session-ops-silence = "session_ops.monitor.silence:main" +session-ops-token-expiry = "session_ops.monitor.token_expiry:main" [dependency-groups] dev = [ diff --git a/src/session_ops/jobs.toml b/src/session_ops/jobs.toml index b23b351..05dfdae 100644 --- a/src/session_ops/jobs.toml +++ b/src/session_ops/jobs.toml @@ -84,6 +84,22 @@ schedule = "hourly" max_age_hours = 3 timeout = "5min" +[[job]] +name = "token-expiry" +description = "Alert before a token the jobs use expires" +entry = "session_ops.monitor.token_expiry:main" +args = ["--state", "{state}/state.json"] +user = "sessionops" +# For the GitHub token it probes and the Claude Code token it fingerprints, which brings +# the rest of zendesk.env's secrets into this unit. expiry.toml holds none. +env_files = ["/etc/session-ops/alerts.env", "/etc/session-ops/github-prs.env", + "/etc/session-ops/zendesk.env"] +env = ["ALERT_DISCORD_WEBHOOK_URL"] +schedule = "*-*-* 09:00 Australia/Melbourne" +# A day, plus the hour a DST change adds and slack for the run. +max_age_hours = 27 +timeout = "5min" + [[job]] name = "crowdin-sync" description = "Crowdin translations into the platform repos" diff --git a/src/session_ops/monitor/token_expiry.py b/src/session_ops/monitor/token_expiry.py new file mode 100644 index 0000000..86fba44 --- /dev/null +++ b/src/session_ops/monitor/token_expiry.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +""" +Alert before a token the jobs use expires. + +Each token's expiry comes from one of three places: + +- GITHUB_PRS_TOKEN: GitHub's response header, so a rotation needs nothing updated. +- CLAUDE_CODE_OAUTH_TOKEN: a year after this job first saw it, by a fingerprint kept + in --state, so a rotation needs nothing updated either. A token installed before the + job existed gets its real issue date from [issued] in the expiry file, which applies + only while that same token is in place. +- CROWDIN_API_TOKEN: exposes its expiry to no API, so it is recorded by hand, and + reported on every run until it is. + +/etc/session-ops/expiry.toml: + + [expires] + # A date, or "never" when it does not expire or this host does not use it. + CROWDIN_API_TOKEN = "never" + + [issued.CLAUDE_CODE_OAUTH_TOKEN] + # As printed by --dry-run. + fingerprint = "3f2a9c01b4de" + date = 2025-11-20 + +Posts once as each token comes within 14 days, 7 days and 24 hours of expiring, and +once more when it has expired; a new expiry date starts it over. Times are UTC, and a +date without one counts from its start, so a run on any timezone's morning errs early. +Success is quiet. + +Config: + ALERT_DISCORD_WEBHOOK_URL where the alert goes (not needed with --dry-run) + GITHUB_PRS_TOKEN probed for its expiry; skipped when unset + CLAUDE_CODE_OAUTH_TOKEN fingerprinted; skipped when unset + +Usage: + session-ops-token-expiry --state /var/lib/session-ops/token-expiry/state.json + session-ops-token-expiry --dry-run # print every token's expiry and the alert +""" +import argparse +import hashlib +import json +import os +import socket +import sys +import tomllib +from dataclasses import dataclass +from datetime import date, datetime, timedelta, timezone + +from session_ops.shared import discord, http +from session_ops.shared.env import get_env + +EXPIRY_FILE = "/etc/session-ops/expiry.toml" +THRESHOLDS = (1, 7, 14) +NEVER = "never" +GITHUB, RECORDED, FIRST_SEEN = "github", "recorded", "first seen" +GITHUB_RATE_LIMIT = "https://api.github.com/rate_limit" +GITHUB_EXPIRY_HEADER = "github-authentication-token-expiration" + + +@dataclass(frozen=True) +class Token: + env_file: str + renew: str + source: str + lifetime_days: int = None + + +TOKENS = { + "GITHUB_PRS_TOKEN": Token("/etc/session-ops/github-prs.env", + "GitHub → Settings → Developer settings → Personal access tokens", + GITHUB), + "CROWDIN_API_TOKEN": Token("/etc/session-ops/crowdin.env", + "https://crowdin.com/settings#api-key", RECORDED), + "CLAUDE_CODE_OAUTH_TOKEN": Token("/etc/session-ops/zendesk.env", + "`claude setup-token` on a machine with a browser, " + "then restart zendesk-relay", + FIRST_SEEN, lifetime_days=365), +} + + +@dataclass(frozen=True) +class Config: + expires: dict + issued: dict + + +def start_of(day): + return datetime(day.year, day.month, day.day, tzinfo=timezone.utc) + + +def as_date(value): + return value.date() if isinstance(value, datetime) else value + + +def load_config(path): + """The expiry file's [expires] and [issued]; empty when it does not exist yet.""" + try: + with open(path, "rb") as handle: + data = tomllib.load(handle) + except FileNotFoundError: + return Config({}, {}) + except tomllib.TOMLDecodeError as exc: + sys.exit(f"{path}: {exc}") + expires = {} + for name, value in data.get("expires", {}).items(): + value = as_date(value) + if not (isinstance(value, date) or value == NEVER): + sys.exit(f"{path}: {name} must be a date (2027-03-01) or \"{NEVER}\", not {value!r}") + expires[name] = value + issued = {} + for name, entry in data.get("issued", {}).items(): + when = as_date(entry.get("date")) if isinstance(entry, dict) else None + if not (isinstance(when, date) and isinstance(entry.get("fingerprint"), str)): + sys.exit(f"{path}: [issued.{name}] needs a fingerprint and a date") + issued[name] = (entry["fingerprint"], when) + return Config(expires, issued) + + +def fingerprint(value): + """Enough of a hash to tell tokens apart, and nothing that helps recover one.""" + return hashlib.sha256(value.encode()).hexdigest()[:12] + + +def issued_on(name, value, issued, seen, today): + """When the token in `value` was issued: its [issued] date while that names this + token, else the day it was first seen. Records it in `seen`.""" + current = fingerprint(value) + recorded = issued.get(name) + entry = seen.get(name, {}) + if recorded and recorded[0] == current: + since = recorded[1] + elif entry.get("fingerprint") == current: + since = date.fromisoformat(entry["since"]) + else: + since = today + seen[name] = {"fingerprint": current, "since": since.isoformat()} + return since + + +def parse_github_expiry(value): + """The time in GitHub's expiry header, "2027-10-02 03:00:00 UTC".""" + try: + return datetime.strptime(value.removesuffix(" UTC")[:19], "%Y-%m-%d %H:%M:%S") \ + .replace(tzinfo=timezone.utc) + except ValueError: + raise ValueError(f"unreadable {GITHUB_EXPIRY_HEADER}: {value!r}") from None + + +def probe_github(session, token): + """The token's expiry time, NEVER, or "rejected" when GitHub refuses it.""" + resp = session.request("GET", GITHUB_RATE_LIMIT, + headers={"Authorization": f"Bearer {token}"}) + if resp.status_code == 401: + return "rejected" + resp.raise_for_status() + header = resp.headers.get(GITHUB_EXPIRY_HEADER) + return parse_github_expiry(header) if header else NEVER + + +def level(expiry, now): + """What an alert about `expiry` would say, or None while it needs none.""" + if expiry in ("missing", "rejected"): + return expiry + if expiry == NEVER: + return None + left = expiry - now + if left <= timedelta(0): + return "expired" + return next((f"{t}d" for t in THRESHOLDS if left <= timedelta(days=t)), None) + + +def evaluate(expiries, state, now): + """The tokens due an alert, as (name, expiry, level): each window once, a missing + date on every run. `state` drops every token that needs no alert, so a renewed one + starts over; recording an alert is left to a delivered post.""" + due = [] + for name, expiry in expiries.items(): + current = level(expiry, now) + if current is None: + state.pop(name, None) + elif current == "missing" or state.get(name) != record(expiry, current): + due.append((name, expiry, current)) + for name in [name for name in state if name not in expiries]: + del state[name] + return due + + +def record(expiry, current): + return {"level": current, "expires": shown(expiry) if isinstance(expiry, datetime) else None} + + +def shown(when): + return when.strftime("%Y-%m-%d %H:%M UTC") + + +WITHIN = {"1d": "in less than 24h", "7d": "in less than 7 days", "14d": "in less than 14 days"} + + +def describe(expiry, current, expiry_file): + if current == "missing": + return f"no expiry date in `{expiry_file}`" + if current == "rejected": + return "GitHub rejects it (401)" + if current == "expired": + return f"**expired** at {shown(expiry)}" + return f"expires **{shown(expiry)}**, {WITHIN[current]}" + + +def build_message(due, host, expiry_file): + lines = [f"🔑 **Token expiry** on `{host}`"] + for name, expiry, current in due: + token = TOKENS[name] + lines.append(f"• **{name}**: {describe(expiry, current, expiry_file)}.") + if current == "missing": + continue + then = f", then its date in `{expiry_file}`" if token.source == RECORDED else "" + lines.append(f" Renew: {token.renew}; update `{token.env_file}`{then}.") + return "\n".join(lines) + + +def utc_now(): + return datetime.now(timezone.utc) + + +def load_state(path): + """{"alerts": ..., "seen": ...}. Losing it re-dates a fingerprinted token from the + next run, which alerts late: the reason it is saved even when a post fails.""" + data = {} + if path and os.path.exists(path): + try: + with open(path, encoding="utf-8") as handle: + data = json.load(handle) + except (OSError, ValueError): + data = {} + data = data if isinstance(data, dict) else {} + return {"alerts": data.get("alerts", {}), "seen": data.get("seen", {})} + + +def save_state(path, state): + temporary = f"{path}.tmp" + with open(temporary, "w", encoding="utf-8") as handle: + json.dump(state, handle, indent=2, sort_keys=True) + os.replace(temporary, path) + + +def collect(config, environ, seen, session, now): + """{name: expiry} for every token in use. A token set nowhere is left out, and so + is its fingerprint.""" + expiries = {} + for name, token in TOKENS.items(): + value = environ.get(name) + if token.source == RECORDED: + expiry = config.expires.get(name, "missing") + expiries[name] = start_of(expiry) if isinstance(expiry, date) else expiry + elif not value: + seen.pop(name, None) + elif token.source == GITHUB: + expiries[name] = probe_github(session, value) + else: + since = issued_on(name, value, config.issued, seen, now.date()) + expiries[name] = start_of(since + timedelta(days=token.lifetime_days)) + return expiries + + +def main(argv=None): + parser = argparse.ArgumentParser(description="Alert before a token expires.") + parser.add_argument("--expiry-file", default=EXPIRY_FILE) + parser.add_argument("--state", metavar="PATH", + help="Alert bookkeeping; without it every run re-alerts.") + parser.add_argument("--webhook", help="Discord webhook URL (else ALERT_DISCORD_WEBHOOK_URL).") + parser.add_argument("--dry-run", action="store_true", + help="Print the alert instead of posting it; write no state.") + args = parser.parse_args(argv) + + webhook = get_env("ALERT_DISCORD_WEBHOOK_URL", args.webhook, required=not args.dry_run) + now = utc_now() + state = load_state(args.state) + expiries = collect(load_config(args.expiry_file), os.environ, state["seen"], + http.Session(), now) + for name in TOKENS: + expiry = expiries.get(name, "not set") + line = f"{name}: {shown(expiry) if isinstance(expiry, datetime) else expiry}" + seen = state["seen"].get(name) + if seen: + line += f" (fingerprint {seen['fingerprint']}, issued {seen['since']})" + print(line) + + due = evaluate(expiries, state["alerts"], now) + persist = bool(args.state) and not args.dry_run + if not due: + print("Nothing due an alert.") + if persist: + save_state(args.state, state) + return + + message = build_message(due, socket.gethostname(), args.expiry_file) + if args.dry_run: + print(message) + return + if not discord.post_to_discord(http.Session(), webhook, [{"content": message}]): + if persist: + save_state(args.state, state) + sys.exit("Could not post the token expiry alert to Discord.") + for name, expiry, current in due: + state["alerts"][name] = record(expiry, current) + if persist: + save_state(args.state, state) + print(f"Alerted on {len(due)} token(s).") + + +if __name__ == "__main__": + main() diff --git a/tests/goldens/units/dropins.txt b/tests/goldens/units/dropins.txt index 27a9306..b9de733 100644 --- a/tests/goldens/units/dropins.txt +++ b/tests/goldens/units/dropins.txt @@ -89,6 +89,26 @@ EnvironmentFile=/etc/session-ops/publish.env TimeoutStartSec=10min LoadCredential=github-app.pem:/etc/session-ops/github-app.pem +==> session-ops@token-expiry.service.d/job.conf <== +# Generated by `session-ops units` from jobs.toml. Edit the registry, not this. + +[Unit] +Description=Alert before a token the jobs use expires + +[Service] +User=sessionops +Group=sessionops +EnvironmentFile=/etc/session-ops/alerts.env +EnvironmentFile=/etc/session-ops/github-prs.env +EnvironmentFile=/etc/session-ops/zendesk.env +TimeoutStartSec=5min + +==> session-ops@token-expiry.timer.d/schedule.conf <== +# Generated by `session-ops units` from jobs.toml. Edit the registry, not this. + +[Timer] +OnCalendar=*-*-* 09:00 Australia/Melbourne + ==> session-ops@zendesk-digest.service.d/job.conf <== # Generated by `session-ops units` from jobs.toml. Edit the registry, not this. diff --git a/tests/monitor/test_token_expiry.py b/tests/monitor/test_token_expiry.py new file mode 100644 index 0000000..5147e2e --- /dev/null +++ b/tests/monitor/test_token_expiry.py @@ -0,0 +1,239 @@ +""" + uv run python -m unittest tests.monitor.test_token_expiry +""" +import os +import tempfile +import unittest +from datetime import date, datetime, timedelta, timezone +from unittest import mock + +from session_ops.monitor import token_expiry + + + +def utc(*args): + return datetime(*args, tzinfo=timezone.utc) + + +# 09:00 Melbourne on 6 October, the job's schedule: still the 5th in UTC. +NOW = utc(2026, 10, 5, 22) +TODAY = NOW.date() + + +def expiry_file(text): + handle = tempfile.NamedTemporaryFile("w", suffix=".toml", delete=False) + handle.write(text) + handle.close() + return handle.name + + +CLAUDE = "CLAUDE_CODE_OAUTH_TOKEN" +YEAR = timedelta(days=365) + + +class TestConfig(unittest.TestCase): + def test_reads_expires_and_issued(self): + config = token_expiry.load_config(expiry_file( + '[expires]\nCROWDIN_API_TOKEN = 2027-03-01\n' + '[issued.CLAUDE_CODE_OAUTH_TOKEN]\nfingerprint = "abc"\ndate = 2025-11-20\n')) + self.assertEqual(config.expires, {"CROWDIN_API_TOKEN": date(2027, 3, 1)}) + self.assertEqual(config.issued, {CLAUDE: ("abc", date(2025, 11, 20))}) + + def test_a_missing_file_records_nothing(self): + self.assertEqual(token_expiry.load_config("/nonexistent/expiry.toml"), + token_expiry.Config({}, {})) + + def test_refuses_an_expiry_that_is_neither_a_date_nor_never(self): + with self.assertRaises(SystemExit): + token_expiry.load_config(expiry_file('[expires]\nCROWDIN_API_TOKEN = "March"\n')) + + def test_refuses_an_issued_entry_without_a_fingerprint(self): + with self.assertRaises(SystemExit): + token_expiry.load_config(expiry_file('[issued.CLAUDE_CODE_OAUTH_TOKEN]\n' + 'date = 2025-11-20\n')) + + +class TestCollect(unittest.TestCase): + def collect(self, environ, seen=None, config=None, now=NOW): + seen = {} if seen is None else seen + expiries = token_expiry.collect(config or token_expiry.Config({}, {}), environ, + seen, None, now) + return expiries, seen + + def test_crowdin_without_a_date_is_missing_and_unset_tokens_are_left_out(self): + expiries, _ = self.collect({}) + self.assertEqual(expiries, {"CROWDIN_API_TOKEN": "missing"}) + + def test_a_recorded_date_counts_from_its_start_in_utc(self): + config = token_expiry.Config({"CROWDIN_API_TOKEN": date(2027, 3, 1)}, {}) + self.assertEqual(self.collect({}, config=config)[0]["CROWDIN_API_TOKEN"], + utc(2027, 3, 1)) + + def test_a_new_claude_token_expires_a_year_after_it_was_first_seen(self): + expiries, seen = self.collect({CLAUDE: "tok-a"}) + self.assertEqual(expiries[CLAUDE], utc(2027, 10, 5)) + later, _ = self.collect({CLAUDE: "tok-a"}, seen, now=NOW + timedelta(days=30)) + self.assertEqual(later[CLAUDE], utc(2027, 10, 5)) + + def test_a_replaced_token_starts_a_new_year(self): + _, seen = self.collect({CLAUDE: "tok-a"}) + expiries, _ = self.collect({CLAUDE: "tok-b"}, seen, now=utc(2027, 8, 1, 22)) + self.assertEqual(expiries[CLAUDE], utc(2028, 7, 31)) + + def test_the_issued_date_applies_only_to_the_token_it_names(self): + config = token_expiry.Config({}, {CLAUDE: (token_expiry.fingerprint("tok-a"), + date(2025, 11, 20))}) + expiries, seen = self.collect({CLAUDE: "tok-a"}, config=config) + self.assertEqual(expiries[CLAUDE], utc(2026, 11, 20)) + expiries, _ = self.collect({CLAUDE: "tok-b"}, seen, config) + self.assertEqual(expiries[CLAUDE], utc(2027, 10, 5)) + + def test_the_state_holds_a_fingerprint_not_the_token(self): + _, seen = self.collect({CLAUDE: "tok-a"}) + self.assertNotIn("tok-a", repr(seen)) + self.assertEqual(seen[CLAUDE]["fingerprint"], token_expiry.fingerprint("tok-a")) + + def test_an_unset_token_forgets_its_fingerprint(self): + _, seen = self.collect({CLAUDE: "tok-a"}) + self.collect({}, seen) + self.assertEqual(seen, {}) + + +class FakeResponse: + def __init__(self, status, headers=None): + self.status_code, self.headers = status, headers or {} + + def raise_for_status(self): + if self.status_code >= 400: + raise RuntimeError(self.status_code) + + +class TestProbe(unittest.TestCase): + def probe(self, response): + session = mock.Mock() + session.request.return_value = response + return token_expiry.probe_github(session, "tok") + + def test_reads_the_time_from_the_expiry_header(self): + response = FakeResponse(200, {token_expiry.GITHUB_EXPIRY_HEADER: + "2027-10-02 03:00:00 UTC"}) + self.assertEqual(self.probe(response), utc(2027, 10, 2, 3)) + + def test_no_header_is_a_token_that_never_expires(self): + self.assertEqual(self.probe(FakeResponse(200)), "never") + + def test_a_401_is_reported_not_raised(self): + self.assertEqual(self.probe(FakeResponse(401)), "rejected") + + def test_any_other_error_fails_the_run(self): + with self.assertRaises(RuntimeError): + self.probe(FakeResponse(503)) + + +class TestEvaluate(unittest.TestCase): + def level_at(self, left, state=None): + due = token_expiry.evaluate({"CROWDIN_API_TOKEN": NOW + left}, + {} if state is None else state, NOW) + return due[0][2] if due else None + + def test_each_window_by_time_left(self): + hours = [24 * 14 + 1, 24 * 14, 24 * 7 + 1, 24 * 7, 25, 24, 1, 0, -1] + self.assertEqual([self.level_at(timedelta(hours=h)) for h in hours], + [None, "14d", "14d", "7d", "7d", "1d", "1d", "expired", "expired"]) + + def test_the_last_warning_comes_within_a_day_of_an_expiry_hours_away(self): + """09:00 Melbourne on 2 Oct is 23:00 UTC on 1 Oct; the token expires at 03:00 UTC on 2 Oct.""" + expiry, now = utc(2027, 10, 2, 3), utc(2027, 10, 1, 23) + due = token_expiry.evaluate({"GITHUB_PRS_TOKEN": expiry}, {}, now) + self.assertEqual(due, [("GITHUB_PRS_TOKEN", expiry, "1d")]) + + def test_a_date_only_expiry_warns_the_run_before_and_reports_expired_the_run_after(self): + expiry = utc(2027, 3, 1) + runs = [utc(2027, 2, 27, 22), utc(2027, 2, 28, 22), utc(2027, 3, 1, 22)] + self.assertEqual([token_expiry.level(expiry, run) for run in runs], + ["7d", "1d", "expired"]) + + def test_an_alerted_window_is_not_repeated(self): + expiry = NOW + timedelta(days=10) + state = {"CROWDIN_API_TOKEN": token_expiry.record(expiry, "14d")} + self.assertEqual(token_expiry.evaluate({"CROWDIN_API_TOKEN": expiry}, state, NOW), []) + + def test_a_renewed_token_clears_its_state(self): + state = {"CROWDIN_API_TOKEN": token_expiry.record(NOW + timedelta(hours=5), "1d")} + self.assertIsNone(self.level_at(timedelta(days=365), state)) + self.assertEqual(state, {}) + + def test_a_missing_date_alerts_on_every_run_and_rejected_once(self): + state = {} + expiries = {"CROWDIN_API_TOKEN": "missing", "GITHUB_PRS_TOKEN": "rejected"} + due = token_expiry.evaluate(expiries, state, NOW) + self.assertEqual([d[2] for d in due], ["missing", "rejected"]) + for name, expiry, current in due: + state[name] = token_expiry.record(expiry, current) + self.assertEqual(token_expiry.evaluate(expiries, state, NOW + timedelta(days=1)), + [("CROWDIN_API_TOKEN", "missing", "missing")]) + + def test_a_date_recorded_later_stops_the_missing_alert(self): + state = {"CROWDIN_API_TOKEN": token_expiry.record("missing", "missing")} + self.assertEqual(token_expiry.evaluate({"CROWDIN_API_TOKEN": "never"}, state, NOW), []) + self.assertEqual(state, {}) + + def test_never_is_quiet(self): + self.assertEqual(token_expiry.evaluate({"CROWDIN_API_TOKEN": "never"}, {}, NOW), []) + + +class TestMessage(unittest.TestCase): + def test_names_the_token_its_time_window_and_how_to_renew(self): + message = token_expiry.build_message( + [("CROWDIN_API_TOKEN", utc(2026, 10, 12), "7d"), + ("GITHUB_PRS_TOKEN", utc(2026, 10, 6, 3), "1d")], + "box", "/etc/session-ops/expiry.toml") + self.assertIn("`box`", message) + self.assertIn("**CROWDIN_API_TOKEN**: expires **2026-10-12 00:00 UTC**, " + "in less than 7 days.", message) + self.assertIn("crowdin.com/settings#api-key", message) + self.assertIn("then its date in `/etc/session-ops/expiry.toml`", message) + self.assertIn("**GITHUB_PRS_TOKEN**: expires **2026-10-06 03:00 UTC**, " + "in less than 24h.", message) + + def test_an_expired_token_says_when(self): + message = token_expiry.build_message( + [("GITHUB_PRS_TOKEN", utc(2026, 10, 5, 3), "expired")], "box", "/x/expiry.toml") + self.assertIn("**expired** at 2026-10-05 03:00 UTC", message) + + def test_a_missing_date_names_the_file(self): + message = token_expiry.build_message([("CLAUDE_CODE_OAUTH_TOKEN", "missing", "missing")], + "box", "/x/expiry.toml") + self.assertIn("no expiry date in `/x/expiry.toml`", message) + self.assertNotIn("Renew", message) + + +class TestMain(unittest.TestCase): + def test_a_delivered_alert_is_recorded_and_not_repeated(self): + state = os.path.join(tempfile.mkdtemp(), "state.json") + path = expiry_file('[expires]\nCROWDIN_API_TOKEN = 2026-10-10\n') + argv = ["--expiry-file", path, "--state", state, "--webhook", "https://example.invalid"] + with mock.patch.dict(os.environ, {}, clear=True), \ + mock.patch.object(token_expiry, "utc_now", return_value=NOW), \ + mock.patch.object(token_expiry.discord, "post_to_discord", + return_value=1) as post: + token_expiry.main(argv) + token_expiry.main(argv) + self.assertEqual(post.call_count, 1) + + def test_a_failed_post_keeps_the_fingerprint_but_records_no_alert(self): + state = os.path.join(tempfile.mkdtemp(), "state.json") + path = expiry_file('[expires]\nCROWDIN_API_TOKEN = 2026-10-10\n') + with mock.patch.dict(os.environ, {CLAUDE: "tok-a"}, clear=True), \ + mock.patch.object(token_expiry, "utc_now", return_value=NOW), \ + mock.patch.object(token_expiry.discord, "post_to_discord", return_value=0), \ + self.assertRaises(SystemExit): + token_expiry.main(["--expiry-file", path, "--state", state, + "--webhook", "https://example.invalid"]) + saved = token_expiry.load_state(state) + self.assertEqual(saved["alerts"], {}) + self.assertEqual(saved["seen"][CLAUDE]["since"], TODAY.isoformat()) + + +if __name__ == "__main__": + unittest.main()