diff --git a/src/baudolo/seed/__main__.py b/src/baudolo/seed/__main__.py index 9ffdcad..c4d3f2f 100644 --- a/src/baudolo/seed/__main__.py +++ b/src/baudolo/seed/__main__.py @@ -73,9 +73,30 @@ def main() -> None: help="Database name or '*' to dump all databases", ) parser.add_argument("username", help="Database username") - parser.add_argument("password", help="Database password") + parser.add_argument( + "password", + nargs="?", + help="Deprecated: database password as an argument, visible in the " + "process list; use --password-stdin", + ) + parser.add_argument( + "--password-stdin", + action="store_true", + help="Read the database password from stdin", + ) args = parser.parse_args() + if args.password_stdin == (args.password is not None): + parser.error("give the password either via --password-stdin or as an argument") + if args.password_stdin: + password = sys.stdin.read().removesuffix("\n") + else: + print( + "WARNING: a password argument is visible in the process list; " + "use --password-stdin", + file=sys.stderr, + ) + password = args.password try: check_and_add_entry( @@ -83,7 +104,7 @@ def main() -> None: instance=args.instance, database=args.database, username=args.username, - password=args.password, + password=password, ) except Exception as exc: # noqa: BLE001 - CLI boundary: any failure becomes exit 1 print(f"ERROR: {exc}", file=sys.stderr) diff --git a/tests/integration/test_seed_integration.py b/tests/integration/test_seed_integration.py index ca1ee1d..3209075 100644 --- a/tests/integration/test_seed_integration.py +++ b/tests/integration/test_seed_integration.py @@ -65,6 +65,32 @@ def run_seed_expect_fail( ) +def run_seed_stdin( + csv_path: Path, instance: str, database: str, username: str, stdin: str, *extra: str +) -> subprocess.CompletedProcess: + """ + Run the real CLI with --password-stdin, feeding the password on stdin. + Any extra arguments are appended after the username. + """ + return subprocess.run( + [ + sys.executable, + "-m", + "baudolo.seed", + "--password-stdin", + str(csv_path), + instance, + database, + username, + *extra, + ], + input=stdin, + text=True, + capture_output=True, + check=False, + ) + + def read_csv_semicolon(path: Path) -> list[dict]: with path.open("r", encoding="utf-8", newline="") as f: reader = csv.DictReader(f, delimiter=";") @@ -195,6 +221,61 @@ class TestSeedIntegration(unittest.TestCase): self.assertEqual(len(rows), 1) self.assertEqual(rows[0]["database"], "my_db-1") + def test_password_stdin_stores_the_password_without_its_trailing_newline( + self, + ) -> None: + with tempfile.TemporaryDirectory() as td: + p = Path(td) / "databases.csv" + + cp = run_seed_stdin(p, "docker.test", "appdb", "alice", "s3cr;et\n") + + self.assertEqual(cp.returncode, 0, cp.stderr) + self.assertNotIn("WARNING", cp.stderr) + rows = read_csv_semicolon(p) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["username"], "alice") + self.assertEqual(rows[0]["password"], "s3cr;et") + + def test_password_argument_still_works_with_a_deprecation_warning(self) -> None: + with tempfile.TemporaryDirectory() as td: + p = Path(td) / "databases.csv" + + cp = run_seed(p, "docker.test", "appdb", "alice", "secret") + + self.assertIn("--password-stdin", cp.stderr) + self.assertEqual(read_csv_semicolon(p)[0]["password"], "secret") + + def test_password_given_both_ways_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as td: + p = Path(td) / "databases.csv" + + cp = run_seed_stdin(p, "docker.test", "appdb", "alice", "pw\n", "pw") + + self.assertNotEqual(cp.returncode, 0) + self.assertFalse(p.exists(), "Should not create file on invalid input") + + def test_password_given_no_way_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as td: + p = Path(td) / "databases.csv" + + cp = subprocess.run( + [ + sys.executable, + "-m", + "baudolo.seed", + str(p), + "docker.test", + "appdb", + "alice", + ], + text=True, + capture_output=True, + check=False, + ) + + self.assertNotEqual(cp.returncode, 0) + self.assertFalse(p.exists(), "Should not create file on invalid input") + def test_file_is_semicolon_delimited_and_has_header(self) -> None: with tempfile.TemporaryDirectory() as td: p = Path(td) / "databases.csv" diff --git a/tests/unit/seed/test_main.py b/tests/unit/seed/test_main.py index 4da6585..253f723 100644 --- a/tests/unit/seed/test_main.py +++ b/tests/unit/seed/test_main.py @@ -164,6 +164,7 @@ class TestSeedMain(unittest.TestCase): ns.database = "db" ns.username = "user" ns.password = "pass" + ns.password_stdin = False parse_args.return_value = ns seed_main.main() @@ -195,6 +196,7 @@ class TestSeedMain(unittest.TestCase): ns.database = "db" ns.username = "user" ns.password = "pass" + ns.password_stdin = False parse_args.return_value = ns seed_main.main()