spacerouter-cli 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/.gitignore +4 -0
- {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/PKG-INFO +2 -2
- {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/pyproject.toml +2 -2
- {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/src/spacerouter_cli/__init__.py +1 -1
- spacerouter_cli-0.2.0/src/spacerouter_cli/commands/billing.py +59 -0
- spacerouter_cli-0.2.0/src/spacerouter_cli/commands/dashboard.py +55 -0
- spacerouter_cli-0.2.0/src/spacerouter_cli/commands/node.py +116 -0
- {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/src/spacerouter_cli/commands/request.py +21 -23
- {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/src/spacerouter_cli/main.py +4 -2
- {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/src/spacerouter_cli/output.py +0 -1
- spacerouter_cli-0.2.0/tests/test_billing.py +66 -0
- spacerouter_cli-0.2.0/tests/test_dashboard.py +84 -0
- spacerouter_cli-0.2.0/tests/test_integration.py +91 -0
- spacerouter_cli-0.2.0/tests/test_node.py +113 -0
- {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/tests/test_request.py +38 -8
- spacerouter_cli-0.1.0/src/spacerouter_cli/commands/node.py +0 -30
- spacerouter_cli-0.1.0/tests/test_integration.py +0 -98
- spacerouter_cli-0.1.0/tests/test_node.py +0 -70
- {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/src/spacerouter_cli/commands/__init__.py +0 -0
- {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/src/spacerouter_cli/commands/api_key.py +0 -0
- {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/src/spacerouter_cli/commands/config_cmd.py +0 -0
- {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/src/spacerouter_cli/commands/status.py +0 -0
- {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/src/spacerouter_cli/config.py +0 -0
- {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/tests/__init__.py +0 -0
- {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/tests/conftest.py +0 -0
- {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/tests/test_api_key.py +0 -0
- {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/tests/test_config_cmd.py +0 -0
- {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/tests/test_status.py +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: spacerouter-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: CLI for the Space Router residential proxy network — designed for AI agents
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Requires-Python: >=3.10
|
|
7
7
|
Requires-Dist: httpx<1.0,>=0.27
|
|
8
8
|
Requires-Dist: rich<14.0,>=13.0
|
|
9
|
-
Requires-Dist: spacerouter>=0.
|
|
9
|
+
Requires-Dist: spacerouter>=0.2.0
|
|
10
10
|
Requires-Dist: typer<1.0,>=0.12
|
|
11
11
|
Provides-Extra: dev
|
|
12
12
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
@@ -4,14 +4,14 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "spacerouter-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "CLI for the Space Router residential proxy network — designed for AI agents"
|
|
9
9
|
requires-python = ">=3.10"
|
|
10
10
|
license = "MIT"
|
|
11
11
|
dependencies = [
|
|
12
12
|
"typer>=0.12,<1.0",
|
|
13
13
|
"httpx>=0.27,<1.0",
|
|
14
|
-
"spacerouter>=0.
|
|
14
|
+
"spacerouter>=0.2.0",
|
|
15
15
|
"rich>=13.0,<14.0",
|
|
16
16
|
]
|
|
17
17
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""``spacerouter billing`` — billing and checkout management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from spacerouter import SpaceRouterAdmin
|
|
10
|
+
|
|
11
|
+
from spacerouter_cli.config import resolve_config
|
|
12
|
+
from spacerouter_cli.output import cli_error_handler, print_json
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(no_args_is_help=True)
|
|
15
|
+
|
|
16
|
+
CoordinationUrlOpt = Annotated[
|
|
17
|
+
Optional[str],
|
|
18
|
+
typer.Option("--coordination-url", help="Coordination API URL."),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.command("checkout")
|
|
23
|
+
@cli_error_handler
|
|
24
|
+
def checkout(
|
|
25
|
+
email: Annotated[str, typer.Option("--email", help="Email for the checkout session.")],
|
|
26
|
+
coordination_url: CoordinationUrlOpt = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Create a Stripe checkout session."""
|
|
29
|
+
cfg = resolve_config(coordination_api_url=coordination_url)
|
|
30
|
+
with SpaceRouterAdmin(cfg.coordination_api_url) as admin:
|
|
31
|
+
session = admin.create_checkout(email)
|
|
32
|
+
print_json(session.model_dump())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.command("verify")
|
|
36
|
+
@cli_error_handler
|
|
37
|
+
def verify(
|
|
38
|
+
token: Annotated[str, typer.Option("--token", help="Email verification token.")],
|
|
39
|
+
coordination_url: CoordinationUrlOpt = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Verify an email address."""
|
|
42
|
+
cfg = resolve_config(coordination_api_url=coordination_url)
|
|
43
|
+
with SpaceRouterAdmin(cfg.coordination_api_url) as admin:
|
|
44
|
+
admin.verify_email(token)
|
|
45
|
+
print_json({"ok": True})
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.command("reissue")
|
|
49
|
+
@cli_error_handler
|
|
50
|
+
def reissue(
|
|
51
|
+
email: Annotated[str, typer.Option("--email", help="Account email.")],
|
|
52
|
+
token: Annotated[str, typer.Option("--token", help="Verification token.")],
|
|
53
|
+
coordination_url: CoordinationUrlOpt = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Reissue an API key using email verification."""
|
|
56
|
+
cfg = resolve_config(coordination_api_url=coordination_url)
|
|
57
|
+
with SpaceRouterAdmin(cfg.coordination_api_url) as admin:
|
|
58
|
+
result = admin.reissue_api_key(email=email, token=token)
|
|
59
|
+
print_json(result.model_dump())
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""``spacerouter dashboard`` — dashboard data access."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from spacerouter import SpaceRouterAdmin
|
|
10
|
+
|
|
11
|
+
from spacerouter_cli.config import resolve_config
|
|
12
|
+
from spacerouter_cli.output import cli_error_handler, print_json
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(no_args_is_help=True)
|
|
15
|
+
|
|
16
|
+
CoordinationUrlOpt = Annotated[
|
|
17
|
+
Optional[str],
|
|
18
|
+
typer.Option("--coordination-url", help="Coordination API URL."),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.command("transfers")
|
|
23
|
+
@cli_error_handler
|
|
24
|
+
def transfers(
|
|
25
|
+
identity_address: Annotated[Optional[str], typer.Option("--identity-address", help="Identity address to query.")] = None,
|
|
26
|
+
wallet_address: Annotated[Optional[str], typer.Option("--wallet-address", help="[Deprecated] Use --identity-address.", hidden=True)] = None,
|
|
27
|
+
page: Annotated[Optional[int], typer.Option("--page", help="Page number.")] = None,
|
|
28
|
+
page_size: Annotated[Optional[int], typer.Option("--page-size", help="Results per page.")] = None,
|
|
29
|
+
coordination_url: CoordinationUrlOpt = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Get paginated data transfer history."""
|
|
32
|
+
addr = identity_address or wallet_address
|
|
33
|
+
if not addr:
|
|
34
|
+
raise typer.BadParameter("--identity-address is required")
|
|
35
|
+
cfg = resolve_config(coordination_api_url=coordination_url)
|
|
36
|
+
with SpaceRouterAdmin(cfg.coordination_api_url) as admin:
|
|
37
|
+
result = admin.get_transfers(
|
|
38
|
+
identity_address=addr,
|
|
39
|
+
page=page,
|
|
40
|
+
page_size=page_size,
|
|
41
|
+
)
|
|
42
|
+
print_json(result.model_dump())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.command("credit-line")
|
|
46
|
+
@cli_error_handler
|
|
47
|
+
def credit_line(
|
|
48
|
+
address: Annotated[str, typer.Option("--address", help="Wallet address to check credit line for.")],
|
|
49
|
+
coordination_url: CoordinationUrlOpt = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Query credit line status for an address."""
|
|
52
|
+
cfg = resolve_config(coordination_api_url=coordination_url)
|
|
53
|
+
with SpaceRouterAdmin(cfg.coordination_api_url) as admin:
|
|
54
|
+
result = admin.get_credit_line(address)
|
|
55
|
+
print_json(result.model_dump())
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""``spacerouter node`` — manage proxy nodes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from spacerouter import SpaceRouterAdmin
|
|
10
|
+
|
|
11
|
+
from spacerouter_cli.config import resolve_config
|
|
12
|
+
from spacerouter_cli.output import cli_error_handler, print_json
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(no_args_is_help=True)
|
|
15
|
+
|
|
16
|
+
CoordinationUrlOpt = Annotated[
|
|
17
|
+
Optional[str],
|
|
18
|
+
typer.Option("--coordination-url", help="Coordination API URL."),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
IdentityKeyOpt = Annotated[
|
|
22
|
+
Optional[str],
|
|
23
|
+
typer.Option("--identity-key", help="Path to node identity key file. Default: ~/.spacerouter/identity.key"),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _load_identity(key_path: str | None = None) -> str:
|
|
28
|
+
"""Load the node identity private key (auto-create if missing)."""
|
|
29
|
+
from spacerouter.identity import load_or_create_identity, DEFAULT_IDENTITY_PATH
|
|
30
|
+
path = key_path or DEFAULT_IDENTITY_PATH
|
|
31
|
+
private_key, address = load_or_create_identity(path)
|
|
32
|
+
return private_key
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.command("register")
|
|
36
|
+
@cli_error_handler
|
|
37
|
+
def register(
|
|
38
|
+
endpoint_url: Annotated[str, typer.Option("--endpoint-url", help="Node endpoint URL.")],
|
|
39
|
+
staking_address: Annotated[str, typer.Option("--staking-address", help="Staking wallet address.")],
|
|
40
|
+
collection_address: Annotated[Optional[str], typer.Option("--collection-address", help="Collection wallet. Defaults to identity address.")] = None,
|
|
41
|
+
label: Annotated[Optional[str], typer.Option("--label", help="Human-readable node label.")] = None,
|
|
42
|
+
connectivity_type: Annotated[Optional[str], typer.Option("--connectivity-type", help="direct, upnp, or external_provider.")] = None,
|
|
43
|
+
identity_key: IdentityKeyOpt = None,
|
|
44
|
+
coordination_url: CoordinationUrlOpt = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Register a new proxy node using identity key. Creates vouching signature automatically."""
|
|
47
|
+
private_key = _load_identity(identity_key)
|
|
48
|
+
cfg = resolve_config(coordination_api_url=coordination_url)
|
|
49
|
+
with SpaceRouterAdmin(cfg.coordination_api_url) as admin:
|
|
50
|
+
node = admin.register_node_with_identity(
|
|
51
|
+
private_key=private_key,
|
|
52
|
+
endpoint_url=endpoint_url,
|
|
53
|
+
staking_address=staking_address,
|
|
54
|
+
collection_address=collection_address,
|
|
55
|
+
label=label,
|
|
56
|
+
connectivity_type=connectivity_type,
|
|
57
|
+
)
|
|
58
|
+
print_json(node.model_dump())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.command("list")
|
|
62
|
+
@cli_error_handler
|
|
63
|
+
def list_nodes(
|
|
64
|
+
coordination_url: CoordinationUrlOpt = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""List all registered nodes (deprecated — use 'get' instead)."""
|
|
67
|
+
cfg = resolve_config(coordination_api_url=coordination_url)
|
|
68
|
+
with SpaceRouterAdmin(cfg.coordination_api_url) as admin:
|
|
69
|
+
nodes = admin.list_nodes()
|
|
70
|
+
print_json([n.model_dump() for n in nodes])
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@app.command("update-status")
|
|
74
|
+
@cli_error_handler
|
|
75
|
+
def update_status(
|
|
76
|
+
node_id: Annotated[str, typer.Argument(help="Node ID.")],
|
|
77
|
+
status: Annotated[str, typer.Option("--status", help="offline or draining. To go online, use request-probe.")],
|
|
78
|
+
identity_key: IdentityKeyOpt = None,
|
|
79
|
+
coordination_url: CoordinationUrlOpt = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Update a node's operational status. Requires node identity key."""
|
|
82
|
+
private_key = _load_identity(identity_key)
|
|
83
|
+
cfg = resolve_config(coordination_api_url=coordination_url)
|
|
84
|
+
with SpaceRouterAdmin(cfg.coordination_api_url) as admin:
|
|
85
|
+
admin.update_node_status(node_id, status=status, private_key=private_key) # type: ignore[arg-type]
|
|
86
|
+
print_json({"ok": True})
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@app.command("request-probe")
|
|
90
|
+
@cli_error_handler
|
|
91
|
+
def request_probe(
|
|
92
|
+
node_id: Annotated[str, typer.Argument(help="Node ID.")],
|
|
93
|
+
identity_key: IdentityKeyOpt = None,
|
|
94
|
+
coordination_url: CoordinationUrlOpt = None,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Request a health probe for an offline node. Requires node identity key."""
|
|
97
|
+
private_key = _load_identity(identity_key)
|
|
98
|
+
cfg = resolve_config(coordination_api_url=coordination_url)
|
|
99
|
+
with SpaceRouterAdmin(cfg.coordination_api_url) as admin:
|
|
100
|
+
admin.request_probe(node_id, private_key=private_key)
|
|
101
|
+
print_json({"ok": True, "message": "Probe queued. Node will go online if probe passes."})
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.command("delete")
|
|
105
|
+
@cli_error_handler
|
|
106
|
+
def delete(
|
|
107
|
+
node_id: Annotated[str, typer.Argument(help="Node ID.")],
|
|
108
|
+
identity_key: IdentityKeyOpt = None,
|
|
109
|
+
coordination_url: CoordinationUrlOpt = None,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Delete a registered node. Requires node identity key."""
|
|
112
|
+
private_key = _load_identity(identity_key)
|
|
113
|
+
cfg = resolve_config(coordination_api_url=coordination_url)
|
|
114
|
+
with SpaceRouterAdmin(cfg.coordination_api_url) as admin:
|
|
115
|
+
admin.delete_node(node_id, private_key=private_key)
|
|
116
|
+
print_json({"ok": True})
|
|
@@ -19,8 +19,8 @@ app = typer.Typer(no_args_is_help=True)
|
|
|
19
19
|
ApiKeyOpt = Annotated[Optional[str], typer.Option("--api-key", help="API key for proxy auth.")]
|
|
20
20
|
GatewayOpt = Annotated[Optional[str], typer.Option("--gateway-url", help="Proxy gateway URL.")]
|
|
21
21
|
HeaderOpt = Annotated[Optional[list[str]], typer.Option("--header", "-H", help="Custom header (Name: Value). Repeatable.")]
|
|
22
|
+
RegionOpt = Annotated[Optional[str], typer.Option("--region", help="2-letter country code (e.g. US, KR).")]
|
|
22
23
|
IpTypeOpt = Annotated[Optional[str], typer.Option("--ip-type", help="IP type filter: residential, mobile, datacenter, business.")]
|
|
23
|
-
RegionOpt = Annotated[Optional[str], typer.Option("--region", help="Region filter substring.")]
|
|
24
24
|
TimeoutOpt = Annotated[Optional[float], typer.Option("--timeout", help="Request timeout in seconds.")]
|
|
25
25
|
OutputOpt = Annotated[str, typer.Option("--output", help="Output mode: json (structured) or raw (body only).")]
|
|
26
26
|
FollowOpt = Annotated[bool, typer.Option("--follow-redirects", help="Follow HTTP redirects.")]
|
|
@@ -53,8 +53,8 @@ def _do_request(
|
|
|
53
53
|
api_key: str | None,
|
|
54
54
|
gateway_url: str | None,
|
|
55
55
|
header: list[str] | None,
|
|
56
|
-
ip_type: str | None,
|
|
57
56
|
region: str | None,
|
|
57
|
+
ip_type: str | None = None,
|
|
58
58
|
timeout: float | None,
|
|
59
59
|
output: str,
|
|
60
60
|
follow_redirects: bool,
|
|
@@ -78,10 +78,9 @@ def _do_request(
|
|
|
78
78
|
with SpaceRouter(
|
|
79
79
|
cfg.api_key,
|
|
80
80
|
gateway_url=cfg.gateway_url,
|
|
81
|
-
ip_type=ip_type,
|
|
82
81
|
region=region,
|
|
82
|
+
ip_type=ip_type,
|
|
83
83
|
timeout=cfg.timeout,
|
|
84
|
-
coordination_url=cfg.coordination_api_url,
|
|
85
84
|
follow_redirects=follow_redirects,
|
|
86
85
|
) as client:
|
|
87
86
|
resp = client.request(method, url, **kwargs)
|
|
@@ -94,7 +93,6 @@ def _do_request(
|
|
|
94
93
|
"headers": dict(resp.headers),
|
|
95
94
|
"body": _try_parse_json(resp.text),
|
|
96
95
|
"spacerouter": {
|
|
97
|
-
"node_id": resp.node_id,
|
|
98
96
|
"request_id": resp.request_id,
|
|
99
97
|
},
|
|
100
98
|
})
|
|
@@ -110,16 +108,16 @@ def get(
|
|
|
110
108
|
api_key: ApiKeyOpt = None,
|
|
111
109
|
gateway_url: GatewayOpt = None,
|
|
112
110
|
header: HeaderOpt = None,
|
|
113
|
-
ip_type: IpTypeOpt = None,
|
|
114
111
|
region: RegionOpt = None,
|
|
112
|
+
ip_type: IpTypeOpt = None,
|
|
115
113
|
timeout: TimeoutOpt = None,
|
|
116
114
|
output: OutputOpt = "json",
|
|
117
115
|
follow_redirects: FollowOpt = False,
|
|
118
116
|
) -> None:
|
|
119
117
|
"""Send a GET request through the residential proxy."""
|
|
120
118
|
_do_request("GET", url, api_key=api_key, gateway_url=gateway_url, header=header,
|
|
121
|
-
|
|
122
|
-
follow_redirects=follow_redirects)
|
|
119
|
+
region=region, ip_type=ip_type, timeout=timeout,
|
|
120
|
+
output=output, follow_redirects=follow_redirects)
|
|
123
121
|
|
|
124
122
|
|
|
125
123
|
@app.command()
|
|
@@ -130,16 +128,16 @@ def post(
|
|
|
130
128
|
gateway_url: GatewayOpt = None,
|
|
131
129
|
header: HeaderOpt = None,
|
|
132
130
|
data: DataOpt = None,
|
|
133
|
-
ip_type: IpTypeOpt = None,
|
|
134
131
|
region: RegionOpt = None,
|
|
132
|
+
ip_type: IpTypeOpt = None,
|
|
135
133
|
timeout: TimeoutOpt = None,
|
|
136
134
|
output: OutputOpt = "json",
|
|
137
135
|
follow_redirects: FollowOpt = False,
|
|
138
136
|
) -> None:
|
|
139
137
|
"""Send a POST request through the residential proxy."""
|
|
140
138
|
_do_request("POST", url, api_key=api_key, gateway_url=gateway_url, header=header,
|
|
141
|
-
|
|
142
|
-
follow_redirects=follow_redirects, data=data)
|
|
139
|
+
region=region, ip_type=ip_type, timeout=timeout,
|
|
140
|
+
output=output, follow_redirects=follow_redirects, data=data)
|
|
143
141
|
|
|
144
142
|
|
|
145
143
|
@app.command()
|
|
@@ -150,16 +148,16 @@ def put(
|
|
|
150
148
|
gateway_url: GatewayOpt = None,
|
|
151
149
|
header: HeaderOpt = None,
|
|
152
150
|
data: DataOpt = None,
|
|
153
|
-
ip_type: IpTypeOpt = None,
|
|
154
151
|
region: RegionOpt = None,
|
|
152
|
+
ip_type: IpTypeOpt = None,
|
|
155
153
|
timeout: TimeoutOpt = None,
|
|
156
154
|
output: OutputOpt = "json",
|
|
157
155
|
follow_redirects: FollowOpt = False,
|
|
158
156
|
) -> None:
|
|
159
157
|
"""Send a PUT request through the residential proxy."""
|
|
160
158
|
_do_request("PUT", url, api_key=api_key, gateway_url=gateway_url, header=header,
|
|
161
|
-
|
|
162
|
-
follow_redirects=follow_redirects, data=data)
|
|
159
|
+
region=region, ip_type=ip_type, timeout=timeout,
|
|
160
|
+
output=output, follow_redirects=follow_redirects, data=data)
|
|
163
161
|
|
|
164
162
|
|
|
165
163
|
@app.command()
|
|
@@ -170,16 +168,16 @@ def patch(
|
|
|
170
168
|
gateway_url: GatewayOpt = None,
|
|
171
169
|
header: HeaderOpt = None,
|
|
172
170
|
data: DataOpt = None,
|
|
173
|
-
ip_type: IpTypeOpt = None,
|
|
174
171
|
region: RegionOpt = None,
|
|
172
|
+
ip_type: IpTypeOpt = None,
|
|
175
173
|
timeout: TimeoutOpt = None,
|
|
176
174
|
output: OutputOpt = "json",
|
|
177
175
|
follow_redirects: FollowOpt = False,
|
|
178
176
|
) -> None:
|
|
179
177
|
"""Send a PATCH request through the residential proxy."""
|
|
180
178
|
_do_request("PATCH", url, api_key=api_key, gateway_url=gateway_url, header=header,
|
|
181
|
-
|
|
182
|
-
follow_redirects=follow_redirects, data=data)
|
|
179
|
+
region=region, ip_type=ip_type, timeout=timeout,
|
|
180
|
+
output=output, follow_redirects=follow_redirects, data=data)
|
|
183
181
|
|
|
184
182
|
|
|
185
183
|
@app.command()
|
|
@@ -189,16 +187,16 @@ def delete(
|
|
|
189
187
|
api_key: ApiKeyOpt = None,
|
|
190
188
|
gateway_url: GatewayOpt = None,
|
|
191
189
|
header: HeaderOpt = None,
|
|
192
|
-
ip_type: IpTypeOpt = None,
|
|
193
190
|
region: RegionOpt = None,
|
|
191
|
+
ip_type: IpTypeOpt = None,
|
|
194
192
|
timeout: TimeoutOpt = None,
|
|
195
193
|
output: OutputOpt = "json",
|
|
196
194
|
follow_redirects: FollowOpt = False,
|
|
197
195
|
) -> None:
|
|
198
196
|
"""Send a DELETE request through the residential proxy."""
|
|
199
197
|
_do_request("DELETE", url, api_key=api_key, gateway_url=gateway_url, header=header,
|
|
200
|
-
|
|
201
|
-
follow_redirects=follow_redirects)
|
|
198
|
+
region=region, ip_type=ip_type, timeout=timeout,
|
|
199
|
+
output=output, follow_redirects=follow_redirects)
|
|
202
200
|
|
|
203
201
|
|
|
204
202
|
@app.command()
|
|
@@ -208,13 +206,13 @@ def head(
|
|
|
208
206
|
api_key: ApiKeyOpt = None,
|
|
209
207
|
gateway_url: GatewayOpt = None,
|
|
210
208
|
header: HeaderOpt = None,
|
|
211
|
-
ip_type: IpTypeOpt = None,
|
|
212
209
|
region: RegionOpt = None,
|
|
210
|
+
ip_type: IpTypeOpt = None,
|
|
213
211
|
timeout: TimeoutOpt = None,
|
|
214
212
|
output: OutputOpt = "json",
|
|
215
213
|
follow_redirects: FollowOpt = False,
|
|
216
214
|
) -> None:
|
|
217
215
|
"""Send a HEAD request through the residential proxy."""
|
|
218
216
|
_do_request("HEAD", url, api_key=api_key, gateway_url=gateway_url, header=header,
|
|
219
|
-
|
|
220
|
-
follow_redirects=follow_redirects)
|
|
217
|
+
region=region, ip_type=ip_type, timeout=timeout,
|
|
218
|
+
output=output, follow_redirects=follow_redirects)
|
|
@@ -7,7 +7,7 @@ import json
|
|
|
7
7
|
import typer
|
|
8
8
|
|
|
9
9
|
from spacerouter_cli import __version__
|
|
10
|
-
from spacerouter_cli.commands import api_key, config_cmd, node, request, status
|
|
10
|
+
from spacerouter_cli.commands import api_key, billing, config_cmd, dashboard, node, request, status
|
|
11
11
|
|
|
12
12
|
app = typer.Typer(
|
|
13
13
|
name="spacerouter",
|
|
@@ -18,7 +18,9 @@ app = typer.Typer(
|
|
|
18
18
|
|
|
19
19
|
app.add_typer(request.app, name="request", help="Make proxied HTTP requests")
|
|
20
20
|
app.add_typer(api_key.app, name="api-key", help="Manage API keys")
|
|
21
|
-
app.add_typer(node.app, name="node", help="
|
|
21
|
+
app.add_typer(node.app, name="node", help="Manage proxy nodes")
|
|
22
|
+
app.add_typer(billing.app, name="billing", help="Billing and checkout")
|
|
23
|
+
app.add_typer(dashboard.app, name="dashboard", help="Dashboard data")
|
|
22
24
|
app.add_typer(config_cmd.app, name="config", help="Configuration management")
|
|
23
25
|
app.command(name="status", help="Check service health")(status.status)
|
|
24
26
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Tests for ``spacerouter billing`` commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import MagicMock, patch
|
|
6
|
+
|
|
7
|
+
from spacerouter.models import BillingReissueResult, CheckoutSession
|
|
8
|
+
|
|
9
|
+
from spacerouter_cli.main import app
|
|
10
|
+
from tests.conftest import parse_json_output
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestCheckout:
|
|
14
|
+
@patch("spacerouter_cli.commands.billing.SpaceRouterAdmin")
|
|
15
|
+
def test_checkout_success(self, mock_admin_cls, runner, cli_env):
|
|
16
|
+
mock_admin = MagicMock()
|
|
17
|
+
mock_admin.__enter__ = MagicMock(return_value=mock_admin)
|
|
18
|
+
mock_admin.__exit__ = MagicMock(return_value=False)
|
|
19
|
+
mock_admin.create_checkout.return_value = CheckoutSession(
|
|
20
|
+
checkout_url="https://checkout.stripe.com/session"
|
|
21
|
+
)
|
|
22
|
+
mock_admin_cls.return_value = mock_admin
|
|
23
|
+
|
|
24
|
+
result = runner.invoke(app, [
|
|
25
|
+
"billing", "checkout", "--email", "user@example.com",
|
|
26
|
+
])
|
|
27
|
+
assert result.exit_code == 0
|
|
28
|
+
data = parse_json_output(result.output)
|
|
29
|
+
assert "stripe.com" in data["checkout_url"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestVerifyEmail:
|
|
33
|
+
@patch("spacerouter_cli.commands.billing.SpaceRouterAdmin")
|
|
34
|
+
def test_verify_success(self, mock_admin_cls, runner, cli_env):
|
|
35
|
+
mock_admin = MagicMock()
|
|
36
|
+
mock_admin.__enter__ = MagicMock(return_value=mock_admin)
|
|
37
|
+
mock_admin.__exit__ = MagicMock(return_value=False)
|
|
38
|
+
mock_admin_cls.return_value = mock_admin
|
|
39
|
+
|
|
40
|
+
result = runner.invoke(app, [
|
|
41
|
+
"billing", "verify", "--token", "tok-123",
|
|
42
|
+
])
|
|
43
|
+
assert result.exit_code == 0
|
|
44
|
+
data = parse_json_output(result.output)
|
|
45
|
+
assert data["ok"] is True
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TestReissue:
|
|
49
|
+
@patch("spacerouter_cli.commands.billing.SpaceRouterAdmin")
|
|
50
|
+
def test_reissue_success(self, mock_admin_cls, runner, cli_env):
|
|
51
|
+
mock_admin = MagicMock()
|
|
52
|
+
mock_admin.__enter__ = MagicMock(return_value=mock_admin)
|
|
53
|
+
mock_admin.__exit__ = MagicMock(return_value=False)
|
|
54
|
+
mock_admin.reissue_api_key.return_value = BillingReissueResult(
|
|
55
|
+
new_api_key="sr_live_new_key"
|
|
56
|
+
)
|
|
57
|
+
mock_admin_cls.return_value = mock_admin
|
|
58
|
+
|
|
59
|
+
result = runner.invoke(app, [
|
|
60
|
+
"billing", "reissue",
|
|
61
|
+
"--email", "user@example.com",
|
|
62
|
+
"--token", "tok-456",
|
|
63
|
+
])
|
|
64
|
+
assert result.exit_code == 0
|
|
65
|
+
data = parse_json_output(result.output)
|
|
66
|
+
assert data["new_api_key"] == "sr_live_new_key"
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Tests for ``spacerouter dashboard`` commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import MagicMock, patch
|
|
6
|
+
|
|
7
|
+
from spacerouter.models import CreditLineStatus, Transfer, TransferPage
|
|
8
|
+
|
|
9
|
+
from spacerouter_cli.main import app
|
|
10
|
+
from tests.conftest import parse_json_output
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestTransfers:
|
|
14
|
+
@patch("spacerouter_cli.commands.dashboard.SpaceRouterAdmin")
|
|
15
|
+
def test_transfers_with_identity_address(self, mock_admin_cls, runner, cli_env):
|
|
16
|
+
mock_admin = MagicMock()
|
|
17
|
+
mock_admin.__enter__ = MagicMock(return_value=mock_admin)
|
|
18
|
+
mock_admin.__exit__ = MagicMock(return_value=False)
|
|
19
|
+
mock_admin.get_transfers.return_value = TransferPage(
|
|
20
|
+
page=1,
|
|
21
|
+
total_pages=3,
|
|
22
|
+
total_bytes=2048,
|
|
23
|
+
transfers=[
|
|
24
|
+
Transfer(
|
|
25
|
+
request_id="req-1",
|
|
26
|
+
bytes=512,
|
|
27
|
+
method="GET",
|
|
28
|
+
target_host="example.com",
|
|
29
|
+
created_at="2025-01-01T00:00:00Z",
|
|
30
|
+
),
|
|
31
|
+
],
|
|
32
|
+
)
|
|
33
|
+
mock_admin_cls.return_value = mock_admin
|
|
34
|
+
|
|
35
|
+
result = runner.invoke(app, [
|
|
36
|
+
"dashboard", "transfers",
|
|
37
|
+
"--identity-address", "0xabc",
|
|
38
|
+
"--page", "1",
|
|
39
|
+
"--page-size", "10",
|
|
40
|
+
])
|
|
41
|
+
assert result.exit_code == 0
|
|
42
|
+
data = parse_json_output(result.output)
|
|
43
|
+
assert data["total_pages"] == 3
|
|
44
|
+
assert len(data["transfers"]) == 1
|
|
45
|
+
|
|
46
|
+
@patch("spacerouter_cli.commands.dashboard.SpaceRouterAdmin")
|
|
47
|
+
def test_transfers_legacy_wallet_address(self, mock_admin_cls, runner, cli_env):
|
|
48
|
+
mock_admin = MagicMock()
|
|
49
|
+
mock_admin.__enter__ = MagicMock(return_value=mock_admin)
|
|
50
|
+
mock_admin.__exit__ = MagicMock(return_value=False)
|
|
51
|
+
mock_admin.get_transfers.return_value = TransferPage(
|
|
52
|
+
page=1, total_pages=1, total_bytes=0, transfers=[],
|
|
53
|
+
)
|
|
54
|
+
mock_admin_cls.return_value = mock_admin
|
|
55
|
+
|
|
56
|
+
result = runner.invoke(app, [
|
|
57
|
+
"dashboard", "transfers", "--wallet-address", "0xabc",
|
|
58
|
+
])
|
|
59
|
+
assert result.exit_code == 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TestCreditLine:
|
|
63
|
+
@patch("spacerouter_cli.commands.dashboard.SpaceRouterAdmin")
|
|
64
|
+
def test_credit_line(self, mock_admin_cls, runner, cli_env):
|
|
65
|
+
mock_admin = MagicMock()
|
|
66
|
+
mock_admin.__enter__ = MagicMock(return_value=mock_admin)
|
|
67
|
+
mock_admin.__exit__ = MagicMock(return_value=False)
|
|
68
|
+
mock_admin.get_credit_line.return_value = CreditLineStatus(
|
|
69
|
+
address="0xabc",
|
|
70
|
+
credit_limit=1000.0,
|
|
71
|
+
used=250.0,
|
|
72
|
+
available=750.0,
|
|
73
|
+
status="active",
|
|
74
|
+
foundation_managed=True,
|
|
75
|
+
)
|
|
76
|
+
mock_admin_cls.return_value = mock_admin
|
|
77
|
+
|
|
78
|
+
result = runner.invoke(app, [
|
|
79
|
+
"dashboard", "credit-line", "--address", "0xabc",
|
|
80
|
+
])
|
|
81
|
+
assert result.exit_code == 0
|
|
82
|
+
data = parse_json_output(result.output)
|
|
83
|
+
assert data["available"] == 750.0
|
|
84
|
+
assert data["foundation_managed"] is True
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Integration tests for the SpaceRouter CLI.
|
|
2
|
+
|
|
3
|
+
These tests hit the **live** Coordination API and proxy gateway at
|
|
4
|
+
``gateway.spacerouter.org``. They require the ``SR_API_KEY`` environment
|
|
5
|
+
variable to be set to a billing-provisioned key:
|
|
6
|
+
|
|
7
|
+
SR_API_KEY=sr_live_xxx pytest tests/test_integration.py -v
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
from typer.testing import CliRunner
|
|
17
|
+
|
|
18
|
+
from spacerouter_cli.main import app
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
runner = CliRunner()
|
|
22
|
+
|
|
23
|
+
COORDINATION_URL = os.environ.get(
|
|
24
|
+
"SR_COORDINATION_API_URL", "https://coordination.spacerouter.org"
|
|
25
|
+
)
|
|
26
|
+
GATEWAY_URL = os.environ.get(
|
|
27
|
+
"SR_GATEWAY_URL", "https://gateway.spacerouter.org:8080"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# A billing-provisioned API key for proxy tests.
|
|
31
|
+
API_KEY = os.environ.get("SR_API_KEY")
|
|
32
|
+
|
|
33
|
+
pytestmark = pytest.mark.skipif(not API_KEY, reason="SR_API_KEY not set")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TestCLIIntegration:
|
|
37
|
+
"""End-to-end tests against the live Space Router infrastructure."""
|
|
38
|
+
|
|
39
|
+
def test_proxy_request(self):
|
|
40
|
+
"""Proxy a GET request through the gateway with a billing-provisioned key."""
|
|
41
|
+
cmd = [
|
|
42
|
+
"request", "get", "https://httpbin.org/ip",
|
|
43
|
+
"--api-key", API_KEY,
|
|
44
|
+
"--gateway-url", GATEWAY_URL,
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
result = runner.invoke(app, cmd)
|
|
48
|
+
assert result.exit_code == 0, f"request failed: {result.output}"
|
|
49
|
+
data = json.loads(result.output)
|
|
50
|
+
assert data["status_code"] == 200
|
|
51
|
+
assert "origin" in data["body"]
|
|
52
|
+
|
|
53
|
+
def test_api_key_crud(self):
|
|
54
|
+
"""Create, list, and revoke an API key via CLI."""
|
|
55
|
+
# Create
|
|
56
|
+
result = runner.invoke(app, [
|
|
57
|
+
"api-key", "create",
|
|
58
|
+
"--name", "integration-crud-cli",
|
|
59
|
+
"--coordination-url", COORDINATION_URL,
|
|
60
|
+
])
|
|
61
|
+
assert result.exit_code == 0
|
|
62
|
+
data = json.loads(result.output)
|
|
63
|
+
key_id = data["id"]
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
# List
|
|
67
|
+
result = runner.invoke(app, [
|
|
68
|
+
"api-key", "list",
|
|
69
|
+
"--coordination-url", COORDINATION_URL,
|
|
70
|
+
])
|
|
71
|
+
assert result.exit_code == 0
|
|
72
|
+
keys = json.loads(result.output)
|
|
73
|
+
ids = [k["id"] for k in keys]
|
|
74
|
+
assert key_id in ids
|
|
75
|
+
finally:
|
|
76
|
+
# Revoke
|
|
77
|
+
result = runner.invoke(app, [
|
|
78
|
+
"api-key", "revoke", key_id,
|
|
79
|
+
"--coordination-url", COORDINATION_URL,
|
|
80
|
+
])
|
|
81
|
+
assert result.exit_code == 0
|
|
82
|
+
|
|
83
|
+
def test_node_list(self):
|
|
84
|
+
"""List nodes via CLI."""
|
|
85
|
+
result = runner.invoke(app, [
|
|
86
|
+
"node", "list",
|
|
87
|
+
"--coordination-url", COORDINATION_URL,
|
|
88
|
+
])
|
|
89
|
+
assert result.exit_code == 0
|
|
90
|
+
data = json.loads(result.output)
|
|
91
|
+
assert isinstance(data, list)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Tests for ``spacerouter node`` commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import MagicMock, patch
|
|
6
|
+
|
|
7
|
+
from spacerouter.models import Node
|
|
8
|
+
|
|
9
|
+
from spacerouter_cli.main import app
|
|
10
|
+
from tests.conftest import parse_json_output
|
|
11
|
+
|
|
12
|
+
_SAMPLE_NODE = Node(
|
|
13
|
+
id="node-1",
|
|
14
|
+
endpoint_url="http://192.168.1.100:9090",
|
|
15
|
+
public_ip="73.162.1.1",
|
|
16
|
+
connectivity_type="direct",
|
|
17
|
+
node_type="residential",
|
|
18
|
+
status="online",
|
|
19
|
+
health_score=0.95,
|
|
20
|
+
region="US",
|
|
21
|
+
label=None,
|
|
22
|
+
ip_type="residential",
|
|
23
|
+
ip_region="US",
|
|
24
|
+
as_type="isp",
|
|
25
|
+
identity_address="0xabc",
|
|
26
|
+
staking_address="0xdef",
|
|
27
|
+
collection_address="0xabc",
|
|
28
|
+
created_at="2025-01-01T00:00:00Z",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestRegisterNode:
|
|
33
|
+
@patch("spacerouter_cli.commands.node._load_identity", return_value="0x" + "ab" * 32)
|
|
34
|
+
@patch("spacerouter_cli.commands.node.SpaceRouterAdmin")
|
|
35
|
+
def test_register(self, mock_admin_cls, mock_identity, runner, cli_env):
|
|
36
|
+
mock_admin = MagicMock()
|
|
37
|
+
mock_admin.__enter__ = MagicMock(return_value=mock_admin)
|
|
38
|
+
mock_admin.__exit__ = MagicMock(return_value=False)
|
|
39
|
+
mock_admin.register_node_with_identity.return_value = _SAMPLE_NODE
|
|
40
|
+
mock_admin_cls.return_value = mock_admin
|
|
41
|
+
|
|
42
|
+
result = runner.invoke(app, [
|
|
43
|
+
"node", "register",
|
|
44
|
+
"--endpoint-url", "http://192.168.1.100:9090",
|
|
45
|
+
"--staking-address", "0xdef",
|
|
46
|
+
])
|
|
47
|
+
assert result.exit_code == 0
|
|
48
|
+
data = parse_json_output(result.output)
|
|
49
|
+
assert data["id"] == "node-1"
|
|
50
|
+
assert data["identity_address"] == "0xabc"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TestListNodes:
|
|
54
|
+
@patch("spacerouter_cli.commands.node.SpaceRouterAdmin")
|
|
55
|
+
def test_list_success(self, mock_admin_cls, runner, cli_env):
|
|
56
|
+
mock_admin = MagicMock()
|
|
57
|
+
mock_admin.__enter__ = MagicMock(return_value=mock_admin)
|
|
58
|
+
mock_admin.__exit__ = MagicMock(return_value=False)
|
|
59
|
+
mock_admin.list_nodes.return_value = [_SAMPLE_NODE]
|
|
60
|
+
mock_admin_cls.return_value = mock_admin
|
|
61
|
+
|
|
62
|
+
result = runner.invoke(app, ["node", "list"])
|
|
63
|
+
assert result.exit_code == 0
|
|
64
|
+
data = parse_json_output(result.output)
|
|
65
|
+
assert len(data) == 1
|
|
66
|
+
assert data[0]["id"] == "node-1"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TestUpdateNodeStatus:
|
|
70
|
+
@patch("spacerouter_cli.commands.node._load_identity", return_value="0x" + "ab" * 32)
|
|
71
|
+
@patch("spacerouter_cli.commands.node.SpaceRouterAdmin")
|
|
72
|
+
def test_update_status(self, mock_admin_cls, mock_identity, runner, cli_env):
|
|
73
|
+
mock_admin = MagicMock()
|
|
74
|
+
mock_admin.__enter__ = MagicMock(return_value=mock_admin)
|
|
75
|
+
mock_admin.__exit__ = MagicMock(return_value=False)
|
|
76
|
+
mock_admin_cls.return_value = mock_admin
|
|
77
|
+
|
|
78
|
+
result = runner.invoke(app, [
|
|
79
|
+
"node", "update-status", "node-1", "--status", "draining",
|
|
80
|
+
])
|
|
81
|
+
assert result.exit_code == 0
|
|
82
|
+
data = parse_json_output(result.output)
|
|
83
|
+
assert data["ok"] is True
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TestRequestProbe:
|
|
87
|
+
@patch("spacerouter_cli.commands.node._load_identity", return_value="0x" + "ab" * 32)
|
|
88
|
+
@patch("spacerouter_cli.commands.node.SpaceRouterAdmin")
|
|
89
|
+
def test_request_probe(self, mock_admin_cls, mock_identity, runner, cli_env):
|
|
90
|
+
mock_admin = MagicMock()
|
|
91
|
+
mock_admin.__enter__ = MagicMock(return_value=mock_admin)
|
|
92
|
+
mock_admin.__exit__ = MagicMock(return_value=False)
|
|
93
|
+
mock_admin_cls.return_value = mock_admin
|
|
94
|
+
|
|
95
|
+
result = runner.invoke(app, ["node", "request-probe", "node-1"])
|
|
96
|
+
assert result.exit_code == 0
|
|
97
|
+
data = parse_json_output(result.output)
|
|
98
|
+
assert data["ok"] is True
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TestDeleteNode:
|
|
102
|
+
@patch("spacerouter_cli.commands.node._load_identity", return_value="0x" + "ab" * 32)
|
|
103
|
+
@patch("spacerouter_cli.commands.node.SpaceRouterAdmin")
|
|
104
|
+
def test_delete(self, mock_admin_cls, mock_identity, runner, cli_env):
|
|
105
|
+
mock_admin = MagicMock()
|
|
106
|
+
mock_admin.__enter__ = MagicMock(return_value=mock_admin)
|
|
107
|
+
mock_admin.__exit__ = MagicMock(return_value=False)
|
|
108
|
+
mock_admin_cls.return_value = mock_admin
|
|
109
|
+
|
|
110
|
+
result = runner.invoke(app, ["node", "delete", "node-1"])
|
|
111
|
+
assert result.exit_code == 0
|
|
112
|
+
data = parse_json_output(result.output)
|
|
113
|
+
assert data["ok"] is True
|
|
@@ -13,19 +13,15 @@ def _mock_proxy_response(
|
|
|
13
13
|
status_code: int = 200,
|
|
14
14
|
text: str = '{"origin": "73.162.1.1"}',
|
|
15
15
|
headers: dict | None = None,
|
|
16
|
-
node_id: str | None = "node-1",
|
|
17
16
|
request_id: str | None = "req-1",
|
|
18
17
|
):
|
|
19
18
|
resp = MagicMock()
|
|
20
19
|
resp.status_code = status_code
|
|
21
20
|
resp.text = text
|
|
22
21
|
all_headers = dict(headers or {})
|
|
23
|
-
if node_id:
|
|
24
|
-
all_headers["x-spacerouter-node"] = node_id
|
|
25
22
|
if request_id:
|
|
26
23
|
all_headers["x-spacerouter-request-id"] = request_id
|
|
27
24
|
resp.headers = all_headers
|
|
28
|
-
resp.node_id = node_id
|
|
29
25
|
resp.request_id = request_id
|
|
30
26
|
return resp
|
|
31
27
|
|
|
@@ -49,7 +45,6 @@ class TestGet:
|
|
|
49
45
|
assert result.exit_code == 0
|
|
50
46
|
data = parse_json_output(result.output)
|
|
51
47
|
assert data["status_code"] == 200
|
|
52
|
-
assert data["spacerouter"]["node_id"] == "node-1"
|
|
53
48
|
assert data["spacerouter"]["request_id"] == "req-1"
|
|
54
49
|
assert data["body"]["origin"] == "73.162.1.1"
|
|
55
50
|
|
|
@@ -168,9 +163,9 @@ class TestPost:
|
|
|
168
163
|
assert "JSON" in data["message"]
|
|
169
164
|
|
|
170
165
|
|
|
171
|
-
class
|
|
166
|
+
class TestRegion:
|
|
172
167
|
@patch("spacerouter_cli.commands.request.SpaceRouter")
|
|
173
|
-
def
|
|
168
|
+
def test_passes_region(self, mock_sr_cls, runner, cli_env):
|
|
174
169
|
mock_client = MagicMock()
|
|
175
170
|
mock_client.__enter__ = MagicMock(return_value=mock_client)
|
|
176
171
|
mock_client.__exit__ = MagicMock(return_value=False)
|
|
@@ -179,11 +174,46 @@ class TestIpTypeAndRegion:
|
|
|
179
174
|
|
|
180
175
|
result = runner.invoke(app, [
|
|
181
176
|
"request", "get", "http://example.com",
|
|
182
|
-
"--ip-type", "residential",
|
|
183
177
|
"--region", "US",
|
|
184
178
|
])
|
|
185
179
|
assert result.exit_code == 0
|
|
186
180
|
mock_sr_cls.assert_called_once()
|
|
187
181
|
call_kwargs = mock_sr_cls.call_args
|
|
182
|
+
assert call_kwargs[1]["region"] == "US"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class TestIpType:
|
|
186
|
+
@patch("spacerouter_cli.commands.request.SpaceRouter")
|
|
187
|
+
def test_passes_ip_type(self, mock_sr_cls, runner, cli_env):
|
|
188
|
+
mock_client = MagicMock()
|
|
189
|
+
mock_client.__enter__ = MagicMock(return_value=mock_client)
|
|
190
|
+
mock_client.__exit__ = MagicMock(return_value=False)
|
|
191
|
+
mock_client.request.return_value = _mock_proxy_response()
|
|
192
|
+
mock_sr_cls.return_value = mock_client
|
|
193
|
+
|
|
194
|
+
result = runner.invoke(app, [
|
|
195
|
+
"request", "get", "http://example.com",
|
|
196
|
+
"--ip-type", "residential",
|
|
197
|
+
])
|
|
198
|
+
assert result.exit_code == 0
|
|
199
|
+
mock_sr_cls.assert_called_once()
|
|
200
|
+
call_kwargs = mock_sr_cls.call_args
|
|
188
201
|
assert call_kwargs[1]["ip_type"] == "residential"
|
|
202
|
+
|
|
203
|
+
@patch("spacerouter_cli.commands.request.SpaceRouter")
|
|
204
|
+
def test_passes_region_and_ip_type(self, mock_sr_cls, runner, cli_env):
|
|
205
|
+
mock_client = MagicMock()
|
|
206
|
+
mock_client.__enter__ = MagicMock(return_value=mock_client)
|
|
207
|
+
mock_client.__exit__ = MagicMock(return_value=False)
|
|
208
|
+
mock_client.request.return_value = _mock_proxy_response()
|
|
209
|
+
mock_sr_cls.return_value = mock_client
|
|
210
|
+
|
|
211
|
+
result = runner.invoke(app, [
|
|
212
|
+
"request", "get", "http://example.com",
|
|
213
|
+
"--region", "US",
|
|
214
|
+
"--ip-type", "mobile",
|
|
215
|
+
])
|
|
216
|
+
assert result.exit_code == 0
|
|
217
|
+
call_kwargs = mock_sr_cls.call_args
|
|
189
218
|
assert call_kwargs[1]["region"] == "US"
|
|
219
|
+
assert call_kwargs[1]["ip_type"] == "mobile"
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
"""``spacerouter node`` — view node information."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from typing import Annotated, Optional
|
|
6
|
-
|
|
7
|
-
import httpx
|
|
8
|
-
import typer
|
|
9
|
-
|
|
10
|
-
from spacerouter_cli.config import resolve_config
|
|
11
|
-
from spacerouter_cli.output import cli_error_handler, print_json
|
|
12
|
-
|
|
13
|
-
app = typer.Typer(no_args_is_help=True)
|
|
14
|
-
|
|
15
|
-
CoordinationUrlOpt = Annotated[
|
|
16
|
-
Optional[str],
|
|
17
|
-
typer.Option("--coordination-url", help="Coordination API URL."),
|
|
18
|
-
]
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@app.command("list")
|
|
22
|
-
@cli_error_handler
|
|
23
|
-
def list_nodes(
|
|
24
|
-
coordination_url: CoordinationUrlOpt = None,
|
|
25
|
-
) -> None:
|
|
26
|
-
"""List all registered nodes."""
|
|
27
|
-
cfg = resolve_config(coordination_api_url=coordination_url)
|
|
28
|
-
response = httpx.get(f"{cfg.coordination_api_url}/nodes", timeout=10.0)
|
|
29
|
-
response.raise_for_status()
|
|
30
|
-
print_json(response.json())
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
"""Integration tests for the SpaceRouter CLI.
|
|
2
|
-
|
|
3
|
-
These tests hit the **live** Coordination API and proxy gateway at
|
|
4
|
-
``gateway.spacerouter.org``. They are gated behind the ``SR_INTEGRATION``
|
|
5
|
-
environment variable so they never run in normal CI:
|
|
6
|
-
|
|
7
|
-
SR_INTEGRATION=1 pytest tests/test_integration.py -v
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
from __future__ import annotations
|
|
11
|
-
|
|
12
|
-
import json
|
|
13
|
-
import os
|
|
14
|
-
|
|
15
|
-
import pytest
|
|
16
|
-
from typer.testing import CliRunner
|
|
17
|
-
|
|
18
|
-
_RUN = os.environ.get("SR_INTEGRATION", "") == "1"
|
|
19
|
-
pytestmark = pytest.mark.skipif(not _RUN, reason="SR_INTEGRATION not set")
|
|
20
|
-
|
|
21
|
-
from spacerouter_cli.main import app # noqa: E402
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
runner = CliRunner()
|
|
25
|
-
|
|
26
|
-
COORDINATION_URL = os.environ.get(
|
|
27
|
-
"SR_COORDINATION_API_URL", "https://coordination.spacerouter.org"
|
|
28
|
-
)
|
|
29
|
-
GATEWAY_URL = os.environ.get(
|
|
30
|
-
"SR_GATEWAY_URL", "http://gateway.spacerouter.org:8080"
|
|
31
|
-
)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
class TestCLIIntegration:
|
|
35
|
-
"""End-to-end: create key via CLI -> proxy request -> revoke."""
|
|
36
|
-
|
|
37
|
-
def test_full_lifecycle(self):
|
|
38
|
-
# 1. Create an ephemeral API key via the CLI.
|
|
39
|
-
result = runner.invoke(app, [
|
|
40
|
-
"api-key", "create",
|
|
41
|
-
"--name", "integration-test-cli",
|
|
42
|
-
"--coordination-url", COORDINATION_URL,
|
|
43
|
-
])
|
|
44
|
-
assert result.exit_code == 0, f"create failed: {result.output}"
|
|
45
|
-
data = json.loads(result.output)
|
|
46
|
-
api_key = data["api_key"]
|
|
47
|
-
key_id = data["id"]
|
|
48
|
-
assert api_key.startswith("sr_live_")
|
|
49
|
-
|
|
50
|
-
try:
|
|
51
|
-
# 2. Proxy a GET request through the gateway.
|
|
52
|
-
result = runner.invoke(app, [
|
|
53
|
-
"request", "get", "https://httpbin.org/ip",
|
|
54
|
-
"--api-key", api_key,
|
|
55
|
-
"--gateway-url", GATEWAY_URL,
|
|
56
|
-
])
|
|
57
|
-
assert result.exit_code == 0, f"request failed: {result.output}"
|
|
58
|
-
data = json.loads(result.output)
|
|
59
|
-
assert data["status_code"] == 200
|
|
60
|
-
assert "origin" in data["body"]
|
|
61
|
-
|
|
62
|
-
finally:
|
|
63
|
-
# 3. Cleanup: revoke the key.
|
|
64
|
-
result = runner.invoke(app, [
|
|
65
|
-
"api-key", "revoke", key_id,
|
|
66
|
-
"--coordination-url", COORDINATION_URL,
|
|
67
|
-
])
|
|
68
|
-
assert result.exit_code == 0
|
|
69
|
-
|
|
70
|
-
def test_api_key_crud(self):
|
|
71
|
-
"""Create, list, and revoke an API key via CLI."""
|
|
72
|
-
# Create
|
|
73
|
-
result = runner.invoke(app, [
|
|
74
|
-
"api-key", "create",
|
|
75
|
-
"--name", "integration-crud-cli",
|
|
76
|
-
"--coordination-url", COORDINATION_URL,
|
|
77
|
-
])
|
|
78
|
-
assert result.exit_code == 0
|
|
79
|
-
data = json.loads(result.output)
|
|
80
|
-
key_id = data["id"]
|
|
81
|
-
|
|
82
|
-
try:
|
|
83
|
-
# List
|
|
84
|
-
result = runner.invoke(app, [
|
|
85
|
-
"api-key", "list",
|
|
86
|
-
"--coordination-url", COORDINATION_URL,
|
|
87
|
-
])
|
|
88
|
-
assert result.exit_code == 0
|
|
89
|
-
keys = json.loads(result.output)
|
|
90
|
-
ids = [k["id"] for k in keys]
|
|
91
|
-
assert key_id in ids
|
|
92
|
-
finally:
|
|
93
|
-
# Revoke
|
|
94
|
-
result = runner.invoke(app, [
|
|
95
|
-
"api-key", "revoke", key_id,
|
|
96
|
-
"--coordination-url", COORDINATION_URL,
|
|
97
|
-
])
|
|
98
|
-
assert result.exit_code == 0
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
"""Tests for ``spacerouter node`` commands."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
from unittest.mock import patch, MagicMock
|
|
7
|
-
|
|
8
|
-
import httpx
|
|
9
|
-
|
|
10
|
-
from spacerouter_cli.main import app
|
|
11
|
-
from tests.conftest import parse_json_output
|
|
12
|
-
|
|
13
|
-
NODES_RESPONSE = [
|
|
14
|
-
{
|
|
15
|
-
"id": "node-1",
|
|
16
|
-
"endpoint_url": "http://192.168.1.100:9090",
|
|
17
|
-
"node_type": "residential",
|
|
18
|
-
"status": "online",
|
|
19
|
-
"health_score": 0.95,
|
|
20
|
-
"region": "us-west",
|
|
21
|
-
},
|
|
22
|
-
{
|
|
23
|
-
"id": "node-2",
|
|
24
|
-
"endpoint_url": "http://10.0.0.50:9090",
|
|
25
|
-
"node_type": "residential",
|
|
26
|
-
"status": "offline",
|
|
27
|
-
"health_score": 0.5,
|
|
28
|
-
"region": "eu-west",
|
|
29
|
-
},
|
|
30
|
-
]
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
class TestListNodes:
|
|
34
|
-
@patch("spacerouter_cli.commands.node.httpx.get")
|
|
35
|
-
def test_list_success(self, mock_get, runner, cli_env):
|
|
36
|
-
mock_resp = MagicMock()
|
|
37
|
-
mock_resp.status_code = 200
|
|
38
|
-
mock_resp.json.return_value = NODES_RESPONSE
|
|
39
|
-
mock_resp.raise_for_status = MagicMock()
|
|
40
|
-
mock_get.return_value = mock_resp
|
|
41
|
-
|
|
42
|
-
result = runner.invoke(app, ["node", "list"])
|
|
43
|
-
assert result.exit_code == 0
|
|
44
|
-
data = parse_json_output(result.output)
|
|
45
|
-
assert len(data) == 2
|
|
46
|
-
assert data[0]["id"] == "node-1"
|
|
47
|
-
assert data[1]["status"] == "offline"
|
|
48
|
-
|
|
49
|
-
@patch("spacerouter_cli.commands.node.httpx.get")
|
|
50
|
-
def test_list_connection_error(self, mock_get, runner, cli_env):
|
|
51
|
-
mock_get.side_effect = httpx.ConnectError("Connection refused")
|
|
52
|
-
|
|
53
|
-
result = runner.invoke(app, ["node", "list"])
|
|
54
|
-
assert result.exit_code == 5
|
|
55
|
-
data = parse_json_output(result.output)
|
|
56
|
-
assert data["error"] == "connection_error"
|
|
57
|
-
|
|
58
|
-
@patch("spacerouter_cli.commands.node.httpx.get")
|
|
59
|
-
def test_custom_coordination_url(self, mock_get, runner, cli_env):
|
|
60
|
-
mock_resp = MagicMock()
|
|
61
|
-
mock_resp.status_code = 200
|
|
62
|
-
mock_resp.json.return_value = []
|
|
63
|
-
mock_resp.raise_for_status = MagicMock()
|
|
64
|
-
mock_get.return_value = mock_resp
|
|
65
|
-
|
|
66
|
-
result = runner.invoke(app, [
|
|
67
|
-
"node", "list", "--coordination-url", "http://custom:9000"
|
|
68
|
-
])
|
|
69
|
-
assert result.exit_code == 0
|
|
70
|
-
mock_get.assert_called_once_with("http://custom:9000/nodes", timeout=10.0)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|