From ec7a4d21bdc0c116ffffc8c8051abb56184918bd Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:02:08 +0000 Subject: [PATCH] feat(rust): port namespace lifecycle commands with one SDK attempt --- rust/README.md | 7 +- rust/parity/cases.json | 1137 ++++++++++++++++++++++++++++++++ rust/parity/parity.py | 25 +- rust/parity/test_parity.py | 33 + rust/src/client.rs | 20 +- rust/src/commands/namespace.rs | 63 ++ rust/src/error.rs | 7 +- rust/src/main.rs | 10 +- rust/tests/namespace_list.rs | 244 ++++++- 9 files changed, 1533 insertions(+), 13 deletions(-) diff --git a/rust/README.md b/rust/README.md index 130df7c..7224019 100644 --- a/rust/README.md +++ b/rust/README.md @@ -25,13 +25,18 @@ every command group reaches parity. | `runtime:check`: native build identity and compilation target | Ported, intentional output/help difference | | `doctor`: redacted connection sources, server metadata, warnings and next steps | Ported | | `workflow:list`: one namespace/page, filters, Server envelopes, JSON and JSONL | Ported | -| `namespace:list`, `namespace:describe`: visible namespaces/configuration, JSON and JSONL | Ported | +| `namespace:list`, `namespace:describe`, `namespace:create`, `namespace:update`, `namespace:delete`: configuration and lifecycle, JSON and JSONL | Ported | | Workflow, namespace and schedule commands | Next slices | | `--tls-verify=false` against HTTPS | CLI support pending | Server calls go through the published `durable-workflow` crate, pinned to the latest qualified release. +Namespace lifecycle requests make one target attempt and refuse HTTP redirects. +Use the actual Server URL; a redirect is not reported as a completed creation, +update or deletion. A lost response remains uncertain and is not automatically +submitted again. + ## Build and test Requires Rust 1.86 or newer. diff --git a/rust/parity/cases.json b/rust/parity/cases.json index 4aa9583..a8b1b8a 100644 --- a/rust/parity/cases.json +++ b/rust/parity/cases.json @@ -5773,6 +5773,1143 @@ } } } + }, + { + "name": "namespace create JSON", + "argv": [ + "namespace:create", + "billing", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces": { + "method": "POST", + "status": 200, + "body": { + "name": "billing", + "message": "retained" + } + } + } + }, + { + "name": "namespace create JSONL", + "argv": [ + "namespace:create", + "billing", + "--server={mock}", + "--output=jsonl" + ], + "mock": [], + "mock_routes": { + "/api/namespaces": { + "method": "POST", + "status": 200, + "body": { + "name": "billing", + "message": "retained" + } + } + } + }, + { + "name": "namespace create human", + "argv": [ + "namespace:create", + "billing", + "--server={mock}" + ], + "mock": [], + "mock_routes": { + "/api/namespaces": { + "method": "POST", + "status": 200, + "body": { + "name": "billing", + "message": "retained" + } + } + } + }, + { + "name": "namespace create HTTP 401", + "argv": [ + "namespace:create", + "billing", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces": { + "method": "POST", + "status": 401, + "body": { + "message": "Namespace operation refused.", + "reason": "fixture" + } + } + } + }, + { + "name": "namespace create HTTP 403", + "argv": [ + "namespace:create", + "billing", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces": { + "method": "POST", + "status": 403, + "body": { + "message": "Namespace operation refused.", + "reason": "fixture" + } + } + } + }, + { + "name": "namespace create HTTP 404", + "argv": [ + "namespace:create", + "billing", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces": { + "method": "POST", + "status": 404, + "body": { + "message": "Namespace operation refused.", + "reason": "fixture" + } + } + } + }, + { + "name": "namespace create HTTP 422", + "argv": [ + "namespace:create", + "billing", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces": { + "method": "POST", + "status": 422, + "body": { + "message": "Namespace operation refused.", + "reason": "fixture" + } + } + } + }, + { + "name": "namespace create HTTP 500", + "argv": [ + "namespace:create", + "billing", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces": { + "method": "POST", + "status": 500, + "body": { + "message": "Namespace operation refused.", + "reason": "fixture" + } + } + } + }, + { + "name": "namespace create HTTP 503", + "argv": [ + "namespace:create", + "billing", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces": { + "method": "POST", + "status": 503, + "body": { + "message": "Namespace operation refused.", + "reason": "fixture" + } + } + } + }, + { + "name": "namespace create empty successful response", + "argv": [ + "namespace:create", + "billing", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces": { + "method": "POST", + "status": 204, + "body": "" + } + } + }, + { + "name": "namespace create redirect is refused without following", + "argv": [ + "namespace:create", + "billing", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces": { + "method": "POST", + "status": 302, + "body": { + "message": "redirected" + }, + "headers": { + "Location": "/no-replay" + } + } + }, + "expected_difference": { + "*": { + "fields": [ + "exit_code", + "stdout_json" + ], + "reason": "The SDK makes one target attempt without following redirects. Rust refuses the initial 3xx; the PHP reference follows the redirect and receives a different refusal. Use the actual Server URL (cli#72)." + } + } + }, + { + "name": "namespace update JSON", + "argv": [ + "namespace:update", + "billing", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "PUT", + "status": 200, + "body": { + "name": "billing", + "message": "retained" + } + } + } + }, + { + "name": "namespace update JSONL", + "argv": [ + "namespace:update", + "billing", + "--server={mock}", + "--output=jsonl" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "PUT", + "status": 200, + "body": { + "name": "billing", + "message": "retained" + } + } + } + }, + { + "name": "namespace update human", + "argv": [ + "namespace:update", + "billing", + "--server={mock}" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "PUT", + "status": 200, + "body": { + "name": "billing", + "message": "retained" + } + } + } + }, + { + "name": "namespace update HTTP 401", + "argv": [ + "namespace:update", + "billing", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "PUT", + "status": 401, + "body": { + "message": "Namespace operation refused.", + "reason": "fixture" + } + } + } + }, + { + "name": "namespace update HTTP 403", + "argv": [ + "namespace:update", + "billing", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "PUT", + "status": 403, + "body": { + "message": "Namespace operation refused.", + "reason": "fixture" + } + } + } + }, + { + "name": "namespace update HTTP 404", + "argv": [ + "namespace:update", + "billing", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "PUT", + "status": 404, + "body": { + "message": "Namespace operation refused.", + "reason": "fixture" + } + } + } + }, + { + "name": "namespace update HTTP 422", + "argv": [ + "namespace:update", + "billing", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "PUT", + "status": 422, + "body": { + "message": "Namespace operation refused.", + "reason": "fixture" + } + } + } + }, + { + "name": "namespace update HTTP 500", + "argv": [ + "namespace:update", + "billing", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "PUT", + "status": 500, + "body": { + "message": "Namespace operation refused.", + "reason": "fixture" + } + } + } + }, + { + "name": "namespace update HTTP 503", + "argv": [ + "namespace:update", + "billing", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "PUT", + "status": 503, + "body": { + "message": "Namespace operation refused.", + "reason": "fixture" + } + } + } + }, + { + "name": "namespace update empty successful response", + "argv": [ + "namespace:update", + "billing", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "PUT", + "status": 204, + "body": "" + } + } + }, + { + "name": "namespace update redirect is refused without following", + "argv": [ + "namespace:update", + "billing", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "PUT", + "status": 302, + "body": { + "message": "redirected" + }, + "headers": { + "Location": "/no-replay" + } + } + }, + "expected_difference": { + "*": { + "fields": [ + "exit_code", + "stdout_json" + ], + "reason": "The SDK makes one target attempt without following redirects. Rust refuses the initial 3xx; the PHP reference follows the redirect and receives a different refusal. Use the actual Server URL (cli#72)." + } + } + }, + { + "name": "namespace delete JSON", + "argv": [ + "namespace:delete", + "billing", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "DELETE", + "status": 200, + "body": { + "name": "billing", + "message": "retained" + } + } + } + }, + { + "name": "namespace delete JSONL", + "argv": [ + "namespace:delete", + "billing", + "--server={mock}", + "--output=jsonl" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "DELETE", + "status": 200, + "body": { + "name": "billing", + "message": "retained" + } + } + } + }, + { + "name": "namespace delete human", + "argv": [ + "namespace:delete", + "billing", + "--server={mock}" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "DELETE", + "status": 200, + "body": { + "name": "billing", + "message": "retained" + } + } + } + }, + { + "name": "namespace delete HTTP 401", + "argv": [ + "namespace:delete", + "billing", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "DELETE", + "status": 401, + "body": { + "message": "Namespace operation refused.", + "reason": "fixture" + } + } + } + }, + { + "name": "namespace delete HTTP 403", + "argv": [ + "namespace:delete", + "billing", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "DELETE", + "status": 403, + "body": { + "message": "Namespace operation refused.", + "reason": "fixture" + } + } + } + }, + { + "name": "namespace delete HTTP 404", + "argv": [ + "namespace:delete", + "billing", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "DELETE", + "status": 404, + "body": { + "message": "Namespace operation refused.", + "reason": "fixture" + } + } + } + }, + { + "name": "namespace delete HTTP 422", + "argv": [ + "namespace:delete", + "billing", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "DELETE", + "status": 422, + "body": { + "message": "Namespace operation refused.", + "reason": "fixture" + } + } + } + }, + { + "name": "namespace delete HTTP 500", + "argv": [ + "namespace:delete", + "billing", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "DELETE", + "status": 500, + "body": { + "message": "Namespace operation refused.", + "reason": "fixture" + } + } + } + }, + { + "name": "namespace delete HTTP 503", + "argv": [ + "namespace:delete", + "billing", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "DELETE", + "status": 503, + "body": { + "message": "Namespace operation refused.", + "reason": "fixture" + } + } + } + }, + { + "name": "namespace delete empty successful response", + "argv": [ + "namespace:delete", + "billing", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "DELETE", + "status": 204, + "body": "" + } + } + }, + { + "name": "namespace delete redirect is refused without following", + "argv": [ + "namespace:delete", + "billing", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "DELETE", + "status": 302, + "body": { + "message": "redirected" + }, + "headers": { + "Location": "/no-replay" + } + } + }, + "expected_difference": { + "*": { + "fields": [ + "exit_code", + "stdout_json" + ], + "reason": "The SDK makes one target attempt without following redirects. Rust refuses the initial 3xx; the PHP reference follows the redirect and receives a different refusal. Use the actual Server URL (cli#72)." + } + } + }, + { + "name": "namespace create exact request body 0", + "argv": [ + "namespace:create", + "billing", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces": { + "method": "POST", + "status": 200, + "body": {}, + "echo_request": true + } + } + }, + { + "name": "namespace create exact request body 1", + "argv": [ + "namespace:create", + "billing", + "--server={mock}", + "--description=", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces": { + "method": "POST", + "status": 200, + "body": {}, + "echo_request": true + } + } + }, + { + "name": "namespace create exact request body 2", + "argv": [ + "namespace:create", + "billing", + "--server={mock}", + "--description", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces": { + "method": "POST", + "status": 200, + "body": {}, + "echo_request": true + } + } + }, + { + "name": "namespace create exact request body 3", + "argv": [ + "namespace:create", + "billing", + "--server={mock}", + "--description=changed", + "--retention=-4", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces": { + "method": "POST", + "status": 200, + "body": {}, + "echo_request": true + } + } + }, + { + "name": "namespace create exact request body 4", + "argv": [ + "namespace:create", + "billing", + "--server={mock}", + "--retention", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces": { + "method": "POST", + "status": 200, + "body": {}, + "echo_request": true + } + } + }, + { + "name": "namespace create exact request body 5", + "argv": [ + "namespace:create", + "billing", + "--server={mock}", + "--retention=", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces": { + "method": "POST", + "status": 200, + "body": {}, + "echo_request": true + } + } + }, + { + "name": "namespace create exact request body 6", + "argv": [ + "namespace:create", + "billing", + "--server={mock}", + "--retention=0", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces": { + "method": "POST", + "status": 200, + "body": {}, + "echo_request": true + } + } + }, + { + "name": "namespace create exact request body 7", + "argv": [ + "namespace:create", + "billing", + "--server={mock}", + "--retention=0.0", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces": { + "method": "POST", + "status": 200, + "body": {}, + "echo_request": true + } + } + }, + { + "name": "namespace create exact request body 8", + "argv": [ + "namespace:create", + "billing", + "--server={mock}", + "--retention=7.9days", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces": { + "method": "POST", + "status": 200, + "body": {}, + "echo_request": true + } + } + }, + { + "name": "namespace create exact request body 9", + "argv": [ + "namespace:create", + "billing", + "--server={mock}", + "--retention=1e3", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces": { + "method": "POST", + "status": 200, + "body": {}, + "echo_request": true + } + } + }, + { + "name": "namespace create exact request body 10", + "argv": [ + "namespace:create", + "billing", + "--server={mock}", + "--retention=9999999999999999999999999", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces": { + "method": "POST", + "status": 200, + "body": {}, + "echo_request": true + } + } + }, + { + "name": "namespace create exact request body 11", + "argv": [ + "namespace:create", + "billing", + "--server={mock}", + "--retention=noise", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces": { + "method": "POST", + "status": 200, + "body": {}, + "echo_request": true + } + } + }, + { + "name": "namespace update exact request body 0", + "argv": [ + "namespace:update", + "billing", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "PUT", + "status": 200, + "body": {}, + "echo_request": true + } + } + }, + { + "name": "namespace update exact request body 1", + "argv": [ + "namespace:update", + "billing", + "--server={mock}", + "--description=", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "PUT", + "status": 200, + "body": {}, + "echo_request": true + } + } + }, + { + "name": "namespace update exact request body 2", + "argv": [ + "namespace:update", + "billing", + "--server={mock}", + "--description", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "PUT", + "status": 200, + "body": {}, + "echo_request": true + } + } + }, + { + "name": "namespace update exact request body 3", + "argv": [ + "namespace:update", + "billing", + "--server={mock}", + "--description=changed", + "--retention=-4", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "PUT", + "status": 200, + "body": {}, + "echo_request": true + } + } + }, + { + "name": "namespace update exact request body 4", + "argv": [ + "namespace:update", + "billing", + "--server={mock}", + "--retention", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "PUT", + "status": 200, + "body": {}, + "echo_request": true + } + } + }, + { + "name": "namespace update exact request body 5", + "argv": [ + "namespace:update", + "billing", + "--server={mock}", + "--retention=", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "PUT", + "status": 200, + "body": {}, + "echo_request": true + } + } + }, + { + "name": "namespace update exact request body 6", + "argv": [ + "namespace:update", + "billing", + "--server={mock}", + "--retention=0", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "PUT", + "status": 200, + "body": {}, + "echo_request": true + } + } + }, + { + "name": "namespace update exact request body 7", + "argv": [ + "namespace:update", + "billing", + "--server={mock}", + "--retention=0.0", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "PUT", + "status": 200, + "body": {}, + "echo_request": true + } + } + }, + { + "name": "namespace update exact request body 8", + "argv": [ + "namespace:update", + "billing", + "--server={mock}", + "--retention=7.9days", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "PUT", + "status": 200, + "body": {}, + "echo_request": true + } + } + }, + { + "name": "namespace update exact request body 9", + "argv": [ + "namespace:update", + "billing", + "--server={mock}", + "--retention=1e3", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "PUT", + "status": 200, + "body": {}, + "echo_request": true + } + } + }, + { + "name": "namespace update exact request body 10", + "argv": [ + "namespace:update", + "billing", + "--server={mock}", + "--retention=9999999999999999999999999", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "PUT", + "status": 200, + "body": {}, + "echo_request": true + } + } + }, + { + "name": "namespace update exact request body 11", + "argv": [ + "namespace:update", + "billing", + "--server={mock}", + "--retention=noise", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/namespaces/billing": { + "method": "PUT", + "status": 200, + "body": {}, + "echo_request": true + } + } } ] } diff --git a/rust/parity/parity.py b/rust/parity/parity.py index f4265cd..93c8bfd 100755 --- a/rust/parity/parity.py +++ b/rust/parity/parity.py @@ -273,26 +273,41 @@ def __init__(self, cluster_info, routes=None): content = route.get("body", "") content = content if isinstance(content, str) else json.dumps(content) headers = {"Content-Type": route.get("content_type", "application/json"), **route.get("headers", {})} - extra[path] = (route.get("status", 200), headers, content.encode()) + extra[path] = (route.get("method", "GET"), route.get("status", 200), headers, + content.encode(), route.get("echo_request", False)) class Handler(http.server.BaseHTTPRequestHandler): - def do_GET(self): # noqa: N802 (http.server API) + def respond(self): path = self.path.split("?")[0] - if path in extra: - status, headers, content = extra[path] + if path in extra and extra[path][0] == self.command: + _, status, headers, content, echo = extra[path] + if echo: + self.connection.settimeout(2) + length = int(self.headers.get("Content-Length", "0")) + if length < 0 or length > 65536: + self.send_error(413) + return + data = self.rfile.read(length) + content = json.dumps({"request": {"method": self.command, + "body": json.loads(data) if data else None}}).encode() self.send_response(status) for name, value in headers.items(): self.send_header(name, value) self.end_headers() self.wfile.write(content) return - payload = {"/api/cluster/info": body, "/api/health": health}.get(path) + payload = {"/api/cluster/info": body, "/api/health": health}.get(path) if self.command == "GET" else None self.send_response(200 if payload else 404) self.send_header("Content-Type", "application/json") self.send_header("X-Durable-Workflow-Control-Plane-Version", "2") self.end_headers() self.wfile.write(payload or b'{"message":"Not found."}') + do_GET = respond + do_POST = respond + do_PUT = respond + do_DELETE = respond + def log_message(self, *args): # Keep request logs out of the compared stderr. return diff --git a/rust/parity/test_parity.py b/rust/parity/test_parity.py index 8178fd4..c760a68 100644 --- a/rust/parity/test_parity.py +++ b/rust/parity/test_parity.py @@ -3,10 +3,43 @@ import json from pathlib import Path import unittest +import urllib.error +import urllib.request import parity +class NamespaceRequestFixtureTest(unittest.TestCase): + def test_lifecycle_fixtures_echo_method_and_json_without_mutating_state(self): + routes = {"/" + method.lower(): {"method": method, "echo_request": True} + for method in ("POST", "PUT", "DELETE")} + server = parity.MockServer({}, routes) + try: + for method, body in (("POST", {"description": "é", "retention_days": 0}), + ("PUT", []), ("DELETE", None)): + data = json.dumps(body).encode() if body is not None else None + request = urllib.request.Request(server.url + "/" + method.lower(), data=data, method=method) + with urllib.request.urlopen(request, timeout=3) as response: + self.assertEqual({"request": {"method": method, "body": body}}, json.load(response)) + with self.assertRaises(urllib.error.HTTPError) as error: + urllib.request.urlopen(server.url + "/post", timeout=3) + self.assertEqual(404, error.exception.code) + error.exception.close() + finally: + server.close() + + def test_echo_body_is_bounded(self): + server = parity.MockServer({}, {"/bounded": {"method": "POST", "echo_request": True}}) + try: + request = urllib.request.Request(server.url + "/bounded", data=b"x" * 65537, method="POST") + with self.assertRaises(urllib.error.HTTPError) as error: + urllib.request.urlopen(request, timeout=3) + self.assertEqual(413, error.exception.code) + error.exception.close() + finally: + server.close() + + class SchemaManifestParityTest(unittest.TestCase): def setUp(self): with open(parity.CASES, encoding="utf-8") as handle: diff --git a/rust/src/client.rs b/rust/src/client.rs index 8cb5e7a..b9eb184 100644 --- a/rust/src/client.rs +++ b/rust/src/client.rs @@ -93,14 +93,26 @@ impl Context { /// One namespace visibility request over the SDK's configured transport. pub fn namespace_list(&self) -> Result { - self.namespace_read(RawRequest::get(["namespaces"])) + self.namespace_request(RawRequest::get(["namespaces"])) } pub fn namespace_describe(&self, name: &str) -> Result { - self.namespace_read(RawRequest::get(["namespaces", name])) + self.namespace_request(RawRequest::get(["namespaces", name])) } - fn namespace_read(&self, request: RawRequest) -> Result { + pub fn namespace_create(&self, body: Value) -> Result { + self.namespace_request(RawRequest::post(["namespaces"]).json(body)) + } + + pub fn namespace_update(&self, name: &str, body: Value) -> Result { + self.namespace_request(RawRequest::put(["namespaces", name]).json(body)) + } + + pub fn namespace_delete(&self, name: &str) -> Result { + self.namespace_request(RawRequest::delete(["namespaces", name])) + } + + fn namespace_request(&self, request: RawRequest) -> Result { let request = request .protocol_version(compat::worker_protocol_version()) .control_plane_version(compat::control_plane_version()); @@ -114,7 +126,7 @@ impl Context { let body = Value::Object(crate::php::array_map( response.json().unwrap_or(Value::Null), )); - if response.status() >= 400 { + if !response.is_success() { return Err(self.http_error(response.status(), &body)); } Ok(body) diff --git a/rust/src/commands/namespace.rs b/rust/src/commands/namespace.rs index e4356bc..7f8ae81 100644 --- a/rust/src/commands/namespace.rs +++ b/rust/src/commands/namespace.rs @@ -99,6 +99,69 @@ pub fn describe(context: &Context, input: &Input) -> Result { Ok(0) } +pub fn create(context: &Context, input: &Input) -> Result { + context.preflight(false)?; + let name = input.arg("name").unwrap_or_default(); + let body = json!({ + "name": name, + "description": input.opt("description").flatten(), + "retention_days": php::string_to_int(input.opt("retention").flatten().unwrap_or_default()), + }); + resource_result(context, input, context.namespace_create(body)?, "created") +} + +pub fn update(context: &Context, input: &Input) -> Result { + context.preflight(false)?; + let name = input.arg("name").unwrap_or_default(); + let mut fields = serde_json::Map::new(); + if let Some(description) = input.opt("description").flatten() { + fields.insert("description".into(), json!(description)); + } + if let Some(retention) = input.opt_str("retention").filter(|value| *value != "0") { + fields.insert( + "retention_days".into(), + json!(php::string_to_int(retention)), + ); + } + // PHP's empty associative array is encoded as [], not {}. + let body = if fields.is_empty() { + json!([]) + } else { + Value::Object(fields) + }; + resource_result( + context, + input, + context.namespace_update(name, body)?, + "updated", + ) +} + +pub fn delete(context: &Context, input: &Input) -> Result { + context.preflight(false)?; + let name = input.arg("name").unwrap_or_default(); + resource_result(context, input, context.namespace_delete(name)?, "deleted") +} + +fn resource_result( + context: &Context, + input: &Input, + result: Value, + action: &str, +) -> Result { + let name = input.arg("name").unwrap_or_default(); + let result = with_resource_context(result, name); + if context.mode.machine_readable() { + output::print_json(&result); + } else { + formatter::writeln_out(&format!( + "Namespace {action}: {}", + cell(&result, "namespace") + )); + } + Ok(0) +} + fn with_resource_context(value: Value, fallback: &str) -> Value { let name = if nonempty_scalar(php::get(&value, "name")) { php::to_string(php::get(&value, "name")) diff --git a/rust/src/error.rs b/rust/src/error.rs index a41a61c..e75df25 100644 --- a/rust/src/error.rs +++ b/rust/src/error.rs @@ -406,7 +406,12 @@ pub fn human(error: &CliError, context: &ErrorContext) -> String { let display = |text: &str| { if matches!( context.command, - "workflow:list" | "namespace:list" | "namespace:describe" + "workflow:list" + | "namespace:list" + | "namespace:describe" + | "namespace:create" + | "namespace:update" + | "namespace:delete" ) { crate::formatter::display_text(text) } else { diff --git a/rust/src/main.rs b/rust/src/main.rs index 0a8000b..e57b76c 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -50,6 +50,9 @@ fn handler(name: &str) -> Option { "workflow:list" => Handler::Server(workflow::list), "namespace:list" => Handler::Server(namespace::list), "namespace:describe" => Handler::Server(namespace::describe), + "namespace:create" => Handler::Server(namespace::create), + "namespace:update" => Handler::Server(namespace::update), + "namespace:delete" => Handler::Server(namespace::delete), "env:list" => Handler::Local(env::list), "env:set" => Handler::Local(env::set), "env:use" => Handler::Local(env::use_env), @@ -551,7 +554,12 @@ fn run(raw: Vec) -> i32 { client::uses_worker_protocol(&command), matches!( command.as_str(), - "workflow:list" | "namespace:list" | "namespace:describe" + "workflow:list" + | "namespace:list" + | "namespace:describe" + | "namespace:create" + | "namespace:update" + | "namespace:delete" ), ); } diff --git a/rust/tests/namespace_list.rs b/rust/tests/namespace_list.rs index 2a63159..7b82852 100644 --- a/rust/tests/namespace_list.rs +++ b/rust/tests/namespace_list.rs @@ -72,6 +72,20 @@ impl Scratch { .output() .unwrap() } + + fn mutation(&self, url: &str, verb: &str, name: &str, args: &[&str]) -> Output { + self.command_named(&format!("namespace:{verb}")) + .args([ + name, + "--server", + url, + "--namespace=selected", + "--token=fixture-token", + ]) + .args(args) + .output() + .unwrap() + } } impl Drop for Scratch { fn drop(&mut self) { @@ -112,6 +126,20 @@ impl Server { Ok(size) => bytes.extend_from_slice(&buffer[..size]), } } + if let Some(end) = bytes.windows(4).position(|part| part == b"\r\n\r\n") { + let length = String::from_utf8_lossy(&bytes[..end]) + .lines() + .filter_map(|line| line.split_once(':')) + .find(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .and_then(|(_, length)| length.trim().parse::().ok()) + .unwrap_or(0); + while bytes.len() < end + 4 + length && bytes.len() < 65536 { + match stream.read(&mut buffer) { + Ok(0) | Err(_) => break, + Ok(size) => bytes.extend_from_slice(&buffer[..size]), + } + } + } let request = String::from_utf8(bytes).unwrap(); let cluster = request .lines() @@ -125,7 +153,12 @@ impl Server { body.clone() }; let status = if cluster { 200 } else { status }; - let headers = format!("HTTP/1.1 {status} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", payload.len()); + let location = if (300..400).contains(&status) { + "Location: /unexpected-redirect\r\n" + } else { + "" + }; + let headers = format!("HTTP/1.1 {status} Test\r\n{location}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", payload.len()); let _ = stream.write_all(headers.as_bytes()); let _ = stream.write_all(payload.as_bytes()); } @@ -169,6 +202,22 @@ impl Server { .cloned() .collect() } + + fn resource_requests(&self) -> Vec { + self.requests + .lock() + .unwrap() + .iter() + .filter(|request| { + request + .lines() + .next() + .unwrap_or("") + .contains("/api/namespaces") + }) + .cloned() + .collect() + } } impl Drop for Server { fn drop(&mut self) { @@ -493,3 +542,196 @@ fn namespace_describe_refuses_invalid_path_segments_before_the_target_request() .unwrap(); assert_eq!(output.status.code(), Some(1)); } + +fn request_body(request: &str) -> Value { + serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap() +} + +#[test] +fn namespace_create_posts_the_exact_php_payload_once_with_sdk_auth() { + let scratch = Scratch::new(); + for (args, expected) in [ + ( + vec![], + json!({"name":"billing", "description":null, "retention_days":30}), + ), + ( + vec!["--description=", "--retention=0"], + json!({"name":"billing", "description":"", "retention_days":0}), + ), + ( + vec!["--description", "--retention"], + json!({"name":"billing", "description":null, "retention_days":0}), + ), + ( + vec!["-d", "details", "-r", "7.9days"], + json!({"name":"billing", "description":"details", "retention_days":7}), + ), + ] { + let server = Server::new(info(), 201, r#"{"message":"created","retained":"value"}"#); + let mut flags = args; + flags.push("--json"); + let result = success(scratch.mutation(&server.url, "create", "billing", &flags)); + assert_eq!(result["namespace"], "billing"); + assert_eq!(result["retained"], "value"); + let requests = server.resource_requests(); + assert_eq!(requests.len(), 1); + assert!(requests[0].starts_with("POST /prefix/api/namespaces HTTP/1.1")); + assert!(requests[0].contains("Bearer fixture-token") && requests[0].contains("selected")); + assert_eq!(request_body(&requests[0]), expected); + } +} + +#[test] +fn namespace_update_sends_only_php_selected_fields_and_keeps_the_empty_array_body() { + let scratch = Scratch::new(); + for (flags, expected) in [ + (vec![], json!([])), + (vec!["--description"], json!([])), + (vec!["--description="], json!({"description":""})), + (vec!["--retention=0"], json!([])), + (vec!["--retention="], json!([])), + (vec!["--retention=0.0"], json!({"retention_days":0})), + (vec!["--retention=noise"], json!({"retention_days":0})), + ( + vec!["--retention=-4", "--description=changed"], + json!({"retention_days":-4,"description":"changed"}), + ), + ] { + let server = Server::new(info(), 200, "{}"); + let mut flags = flags; + flags.push("--output=jsonl"); + assert_eq!( + success(scratch.mutation(&server.url, "update", "bill /?#é", &flags))["namespace"], + "bill /?#é" + ); + let requests = server.resource_requests(); + assert_eq!(requests.len(), 1); + assert!(requests[0] + .to_lowercase() + .starts_with("put /prefix/api/namespaces/bill%20%2f%3f%23%c3%a9 http/1.1")); + assert_eq!(request_body(&requests[0]), expected); + } +} + +#[test] +fn namespace_delete_uses_one_encoded_bodyless_request_and_literal_result_text() { + let scratch = Scratch::new(); + let attack = "remote\nFORGED\u{1b}[2J"; + let server = Server::new(info(), 200, &json!({"namespace":attack}).to_string()); + let output = scratch.mutation(&server.url, "delete", "bill /?#é", &[]); + assert_eq!(output.status.code(), Some(0)); + assert_eq!( + String::from_utf8(output.stdout).unwrap(), + "Namespace deleted: remote\\nFORGED\\u{001b}[2J\n" + ); + let requests = server.resource_requests(); + assert_eq!(requests.len(), 1); + assert!(requests[0] + .to_lowercase() + .starts_with("delete /prefix/api/namespaces/bill%20%2f%3f%23%c3%a9 http/1.1")); + assert_eq!(requests[0].split_once("\r\n\r\n").unwrap().1, ""); +} + +#[test] +fn namespace_writes_do_not_retry_or_insert_a_selected_namespace_on_http_refusal() { + let scratch = Scratch::new(); + for verb in ["create", "update", "delete"] { + for (status, exit) in [(401, 4), (403, 4), (404, 5), (422, 2), (500, 6), (503, 6)] { + let server = Server::new( + info(), + status, + r#"{"message":"lifecycle refused","reason":"fixture"}"#, + ); + let output = scratch.mutation(&server.url, verb, "billing", &["--json"]); + assert_eq!(output.status.code(), Some(exit)); + let result: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert!(result.get("namespace").is_none()); + assert_eq!(result["reason"], "fixture"); + assert_eq!(result["command"], format!("namespace:{verb}")); + assert_eq!(server.resource_requests().len(), 1); + } + } +} + +#[test] +fn namespace_writes_refuse_usage_path_tls_and_compatibility_errors_before_mutation() { + let scratch = Scratch::new(); + for verb in ["create", "update", "delete"] { + let server = Server::new(info(), 200, "{}"); + assert_eq!( + scratch + .mutation(&server.url, verb, "billing", &["--unknown"]) + .status + .code(), + Some(1) + ); + assert!(server.requests.lock().unwrap().is_empty()); + for name in ["", ".", ".."] { + if verb != "create" { + assert_eq!( + scratch + .mutation(&server.url, verb, name, &["--json"]) + .status + .code(), + Some(2) + ); + assert!(server.resource_requests().is_empty()); + } + } + let https = server.url.replacen("http:", "https:", 1); + assert_eq!( + scratch + .mutation(&https, verb, "billing", &["--tls-verify=false", "--json"]) + .status + .code(), + Some(2) + ); + assert!(server.resource_requests().is_empty()); + let mut incompatible = info(); + incompatible["control_plane"]["version"] = json!("99"); + let server = Server::new(incompatible, 200, "{}"); + assert_eq!( + scratch + .mutation(&server.url, verb, "billing", &["--json"]) + .status + .code(), + Some(8) + ); + assert!(server.resource_requests().is_empty()); + } +} + +#[test] +fn namespace_writes_refuse_redirects_without_following_or_claiming_a_completed_operation() { + let scratch = Scratch::new(); + for verb in ["create", "update", "delete"] { + for status in [301, 302, 303, 307, 308] { + let server = Server::new( + info(), + status, + r#"{"message":"redirected","name":"billing"}"#, + ); + let output = scratch.mutation(&server.url, verb, "billing", &["--json"]); + assert_eq!(output.status.code(), Some(1)); + let result: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(result["exit_code"], 1); + assert_eq!(server.resource_requests().len(), 1); + assert!( + server.requests.lock().unwrap().iter().all(|request| { + request + .lines() + .next() + .unwrap_or("") + .contains("/api/cluster/info ") + || request + .lines() + .next() + .unwrap_or("") + .contains("/api/namespaces") + }), + "a redirect must not select another target route" + ); + } + } +}