Emit a fixed credential-free login_setup warning and fail the optional prune step before any dependent package API calls if its existing login setup fails. Preserve successful pruning, retention, registry upload and local cleanup. Exercise the extracted prune shell with fake Tea and disposable configuration, including failed setup with a previous login and unchanged successful targets. This identifies the failed stage, not the suppressed authentication cause. Ticket: https://agenthub.fritzlab.net/t-c1070dtgpqqy Authored-By: Codex (GPT-5) <noreply@openai.com>
This commit is contained in:
1 parent
3f2d8336b6
commit
2d8a13840f
3 files changed
+149
-4
No files matched your search
@@ -1,6 +1,9 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import yaml
|
||||
@@ -34,5 +37,135 @@ class PruningContract(unittest.TestCase):
|
||||
self.assertEqual(result.returncode == 0, success)
|
||||
|
||||
|
||||
class PruneLoginContract(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.action = yaml.safe_load((Path(__file__).resolve().parents[1] / "action.yaml").read_text())
|
||||
cls.prune = next(step for step in cls.action["runs"]["steps"] if step["name"] == "Prune old tags")
|
||||
|
||||
def run_prune(self, login_exit=0, keep="3", existing_login=False):
|
||||
# Only this fake executable can receive the synthetic token. Do not
|
||||
# inherit the caller's environment or use its real Tea configuration.
|
||||
with tempfile.TemporaryDirectory(prefix="image-push-fake-tea-") as directory:
|
||||
root = Path(directory)
|
||||
binary = root / "bin"
|
||||
binary.mkdir()
|
||||
config = root / "config"
|
||||
config.mkdir()
|
||||
marker = config / "fake-login"
|
||||
if existing_login:
|
||||
marker.write_text("previous fake login")
|
||||
calls = root / "calls.jsonl"
|
||||
fake = binary / "tea"
|
||||
fake.write_text(f"#!{sys.executable}\n" + r'''
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
args = sys.argv[1:]
|
||||
with Path(os.environ["FAKE_CALLS"]).open("a") as calls:
|
||||
calls.write(json.dumps(args) + "\n")
|
||||
marker = Path(os.environ["FAKE_CONFIG_ROOT"]) / "fake-login"
|
||||
if args[:2] == ["login", "add"]:
|
||||
failure = int(os.environ["FAKE_LOGIN_EXIT"])
|
||||
if failure:
|
||||
# Deliberately sensitive-looking synthetic output must stay suppressed.
|
||||
print("SYNTHETIC_TOKEN_VALUE stdout config=/fake/private")
|
||||
print("SYNTHETIC_TOKEN_VALUE stderr config=/fake/private", file=sys.stderr)
|
||||
sys.exit(failure)
|
||||
marker.write_text("new fake login")
|
||||
sys.exit(0)
|
||||
if args[:1] == ["api"] and marker.exists():
|
||||
if len(args) == 2 and args[1].startswith("/packages/example?type=container&limit=50&page="):
|
||||
page = args[1].rsplit("=", 1)[1]
|
||||
pages = {
|
||||
"1": [
|
||||
{"name": "widget", "version": "9"},
|
||||
{"name": "widget", "version": "latest"},
|
||||
{"name": "other", "version": "0"},
|
||||
{"name": "widget", "version": "2"},
|
||||
{"name": "widget", "version": "001"},
|
||||
{"name": "widget", "version": "12"},
|
||||
],
|
||||
"2": [
|
||||
{"name": "widget", "version": "4"},
|
||||
{"name": "other", "version": "100"},
|
||||
{"name": "widget", "version": "release-1"},
|
||||
],
|
||||
"3": [],
|
||||
}
|
||||
if page not in pages:
|
||||
sys.exit(92)
|
||||
print(json.dumps(pages[page]))
|
||||
sys.exit(0)
|
||||
if len(args) == 4 and args[:3] == ["api", "-X", "DELETE"]:
|
||||
# Log a fake request only: this executable never sends a network request.
|
||||
sys.exit(0)
|
||||
sys.exit(93)
|
||||
''')
|
||||
fake.chmod(0o700)
|
||||
result = subprocess.run(
|
||||
["bash", "-c", self.prune["run"]],
|
||||
env={
|
||||
"PATH": str(binary) + os.pathsep + os.defpath,
|
||||
"FAKE_CONFIG_ROOT": str(config),
|
||||
"TOKEN": "SYNTHETIC_TOKEN_VALUE",
|
||||
"ORG": "example",
|
||||
"NAME": "widget",
|
||||
"KEEP": keep,
|
||||
"FAKE_CALLS": str(calls),
|
||||
"FAKE_LOGIN_EXIT": str(login_exit),
|
||||
},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
recorded = [json.loads(line) for line in calls.read_text().splitlines()]
|
||||
return result, recorded
|
||||
|
||||
def test_failed_setup_stops_all_dependent_calls_without_leaking_output(self):
|
||||
for failure in (1, 7):
|
||||
for previous in (False, True):
|
||||
with self.subTest(failure=failure, previous=previous):
|
||||
result, calls = self.run_prune(login_exit=failure, existing_login=previous)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertEqual(calls[0], [
|
||||
"login", "add", "--name", "ci", "--url", "https://code.fritzlab.net",
|
||||
"--token", "SYNTHETIC_TOKEN_VALUE", "--no-version-check",
|
||||
])
|
||||
self.assertEqual(result.stdout, "")
|
||||
self.assertEqual(result.stderr,
|
||||
"::warning::image-push prune failed at login_setup; package cleanup skipped\n")
|
||||
self.assertNotIn("SYNTHETIC_TOKEN_VALUE", result.stdout + result.stderr)
|
||||
self.assertNotIn("/fake/private", result.stdout + result.stderr)
|
||||
|
||||
def test_success_keeps_pagination_selection_numeric_order_and_retention(self):
|
||||
for keep, removed in (("3", ["001", "2"]), ("1", ["001", "2", "4", "9"]), ("5", [])):
|
||||
with self.subTest(keep=keep):
|
||||
result, calls = self.run_prune(keep=keep)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
lists = [args[1] for args in calls if args[:1] == ["api"] and len(args) == 2]
|
||||
self.assertEqual(lists, [
|
||||
f"/packages/example?type=container&limit=50&page={page}" for page in (1, 2, 3)
|
||||
])
|
||||
deletes = [args for args in calls if args[:3] == ["api", "-X", "DELETE"]]
|
||||
self.assertEqual(deletes, [
|
||||
["api", "-X", "DELETE", f"/packages/example/container/widget/{tag}"] for tag in removed
|
||||
])
|
||||
self.assertEqual(result.stdout, "".join(f"deleting widget:{tag}\n" for tag in removed))
|
||||
self.assertEqual(result.stderr, "")
|
||||
self.assertNotIn("SYNTHETIC_TOKEN_VALUE", result.stdout + result.stderr)
|
||||
|
||||
def test_cleanup_failure_remains_optional_and_login_contract_unchanged(self):
|
||||
self.assertIs(self.prune["continue-on-error"], True)
|
||||
self.assertEqual(self.prune["if"], "${{ inputs.prune == 'true' }}")
|
||||
self.assertEqual(self.prune["env"], {
|
||||
"TOKEN": "${{ inputs.token }}", "ORG": "${{ inputs.org }}",
|
||||
"NAME": "${{ inputs.name }}", "KEEP": "${{ inputs.keep }}",
|
||||
})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user