hookstride-cli 0.3.0__tar.gz → 0.3.1__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.
- {hookstride_cli-0.3.0 → hookstride_cli-0.3.1}/PKG-INFO +32 -3
- {hookstride_cli-0.3.0 → hookstride_cli-0.3.1}/README.md +31 -2
- {hookstride_cli-0.3.0 → hookstride_cli-0.3.1}/pyproject.toml +1 -1
- {hookstride_cli-0.3.0 → hookstride_cli-0.3.1}/src/hookstride_cli/__init__.py +1 -1
- {hookstride_cli-0.3.0 → hookstride_cli-0.3.1}/src/hookstride_cli/console.py +17 -5
- {hookstride_cli-0.3.0 → hookstride_cli-0.3.1}/src/hookstride_cli/main.py +107 -2
- {hookstride_cli-0.3.0 → hookstride_cli-0.3.1}/tests/test_console.py +60 -0
- hookstride_cli-0.3.1/tests/test_event_send.py +174 -0
- {hookstride_cli-0.3.0 → hookstride_cli-0.3.1}/uv.lock +1 -1
- {hookstride_cli-0.3.0 → hookstride_cli-0.3.1}/.gitignore +0 -0
- {hookstride_cli-0.3.0 → hookstride_cli-0.3.1}/scripts/check.sh +0 -0
- {hookstride_cli-0.3.0 → hookstride_cli-0.3.1}/tests/test_cli.py +0 -0
- {hookstride_cli-0.3.0 → hookstride_cli-0.3.1}/tests/test_mutations.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hookstride-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Manage Hookstride projects and destinations, inspect webhook activity and billing.
|
|
5
5
|
License: Proprietary
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -15,10 +15,11 @@ Description-Content-Type: text/markdown
|
|
|
15
15
|
|
|
16
16
|
Hookstride CLI lets you manage projects and webhook destinations, inspect delivery activity, and view billing records. It includes an interactive console with the same selected-project commands.
|
|
17
17
|
|
|
18
|
-
Version 0.3.
|
|
18
|
+
Version 0.3.1 adds signed test-event sending with an existing project's delivery API key, and optional delivery-status polling. Project and destination management remain available; billing and credential changes, delivery replay, and destructive operations remain unavailable.
|
|
19
19
|
|
|
20
20
|
## Installation
|
|
21
21
|
|
|
22
|
+
|
|
22
23
|
Requires Python 3.11 or later.
|
|
23
24
|
|
|
24
25
|
```bash
|
|
@@ -76,6 +77,7 @@ hookstride destinations update DESTINATION_UUID --resume
|
|
|
76
77
|
hookstride keys list
|
|
77
78
|
hookstride events list --status failed
|
|
78
79
|
hookstride events show EVENT_UUID
|
|
80
|
+
hookstride events send DESTINATION_UUID --type webhook.test --wait
|
|
79
81
|
hookstride stats
|
|
80
82
|
|
|
81
83
|
hookstride incoming sources
|
|
@@ -102,7 +104,7 @@ hookstride --project PROJECT_UUID events list
|
|
|
102
104
|
hookstride console
|
|
103
105
|
```
|
|
104
106
|
|
|
105
|
-
Use `help` to view commands, Tab for completion, and `exit` or Ctrl+D to leave. The console also supports `show projects`, `show events`, `show destinations`, `use PROJECT_UUID`, `back`, and `set env dev|production`. The same `projects create/update
|
|
107
|
+
Use `help` to view commands, Tab for completion, and `exit` or Ctrl+D to leave. The console also supports `show projects`, `show events`, `show destinations`, `use PROJECT_UUID`, `back`, and `set env dev|production`. The same `projects create/update`, `destinations create/update`, and `events send` commands work inside the console. Commands with customer-provided names and URLs are not saved to console history.
|
|
106
108
|
|
|
107
109
|
### JSON output
|
|
108
110
|
|
|
@@ -116,6 +118,33 @@ hookstride --env production --json billing history
|
|
|
116
118
|
|
|
117
119
|
Errors go to standard error; unsuccessful requests return a nonzero exit code. When creating a destination, the server returns a signing secret **once**. Save it securely immediately. Both terminal output and `--json` deliberately include that one-time secret, so avoid piping creation output into logs or public CI artifacts.
|
|
118
120
|
|
|
121
|
+
### Send a test event
|
|
122
|
+
|
|
123
|
+
Use an existing active destination from `hookstride destinations list`.
|
|
124
|
+
The command securely prompts for a project delivery API key (`whrk_`). It cannot use a
|
|
125
|
+
Control API token (`whrctl_`); these credentials have different permissions.
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
hookstride --env production projects use PROJECT_UUID
|
|
129
|
+
hookstride --env production events send DESTINATION_UUID --wait
|
|
130
|
+
hookstride --env production events show EVENT_UUID
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The event type defaults to `webhook.test`, with a harmless JSON payload:
|
|
134
|
+
`{"message":"Hookstride CLI delivery test","source":"cli"}`.
|
|
135
|
+
To send a different JSON object without placing its contents in shell history:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
hookstride --env production events send DESTINATION_UUID --type demo.delivery --payload-file ./demo-payload.json --wait
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
To avoid the hidden prompt, set `HOOKSTRIDE_PRODUCTION_API_KEY` or
|
|
142
|
+
`HOOKSTRIDE_DEV_API_KEY` for the matching environment only.
|
|
143
|
+
Never add keys to CLI arguments, commit them to Git, or paste them into a public CI log.
|
|
144
|
+
The CLI does not save delivery keys or their prompts in its configuration or history.
|
|
145
|
+
The `--wait` flag polls for a terminal status and exits nonzero if delivery fails.
|
|
146
|
+
A non-delivered event can be examined using `events show` with the usual Control token.
|
|
147
|
+
|
|
119
148
|
## Environments and local settings
|
|
120
149
|
|
|
121
150
|
The CLI connects to the production API by default: `https://api.hookstride.com`.
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
Hookstride CLI lets you manage projects and webhook destinations, inspect delivery activity, and view billing records. It includes an interactive console with the same selected-project commands.
|
|
4
4
|
|
|
5
|
-
Version 0.3.
|
|
5
|
+
Version 0.3.1 adds signed test-event sending with an existing project's delivery API key, and optional delivery-status polling. Project and destination management remain available; billing and credential changes, delivery replay, and destructive operations remain unavailable.
|
|
6
6
|
|
|
7
7
|
## Installation
|
|
8
8
|
|
|
9
|
+
|
|
9
10
|
Requires Python 3.11 or later.
|
|
10
11
|
|
|
11
12
|
```bash
|
|
@@ -63,6 +64,7 @@ hookstride destinations update DESTINATION_UUID --resume
|
|
|
63
64
|
hookstride keys list
|
|
64
65
|
hookstride events list --status failed
|
|
65
66
|
hookstride events show EVENT_UUID
|
|
67
|
+
hookstride events send DESTINATION_UUID --type webhook.test --wait
|
|
66
68
|
hookstride stats
|
|
67
69
|
|
|
68
70
|
hookstride incoming sources
|
|
@@ -89,7 +91,7 @@ hookstride --project PROJECT_UUID events list
|
|
|
89
91
|
hookstride console
|
|
90
92
|
```
|
|
91
93
|
|
|
92
|
-
Use `help` to view commands, Tab for completion, and `exit` or Ctrl+D to leave. The console also supports `show projects`, `show events`, `show destinations`, `use PROJECT_UUID`, `back`, and `set env dev|production`. The same `projects create/update
|
|
94
|
+
Use `help` to view commands, Tab for completion, and `exit` or Ctrl+D to leave. The console also supports `show projects`, `show events`, `show destinations`, `use PROJECT_UUID`, `back`, and `set env dev|production`. The same `projects create/update`, `destinations create/update`, and `events send` commands work inside the console. Commands with customer-provided names and URLs are not saved to console history.
|
|
93
95
|
|
|
94
96
|
### JSON output
|
|
95
97
|
|
|
@@ -103,6 +105,33 @@ hookstride --env production --json billing history
|
|
|
103
105
|
|
|
104
106
|
Errors go to standard error; unsuccessful requests return a nonzero exit code. When creating a destination, the server returns a signing secret **once**. Save it securely immediately. Both terminal output and `--json` deliberately include that one-time secret, so avoid piping creation output into logs or public CI artifacts.
|
|
105
107
|
|
|
108
|
+
### Send a test event
|
|
109
|
+
|
|
110
|
+
Use an existing active destination from `hookstride destinations list`.
|
|
111
|
+
The command securely prompts for a project delivery API key (`whrk_`). It cannot use a
|
|
112
|
+
Control API token (`whrctl_`); these credentials have different permissions.
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
hookstride --env production projects use PROJECT_UUID
|
|
116
|
+
hookstride --env production events send DESTINATION_UUID --wait
|
|
117
|
+
hookstride --env production events show EVENT_UUID
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The event type defaults to `webhook.test`, with a harmless JSON payload:
|
|
121
|
+
`{"message":"Hookstride CLI delivery test","source":"cli"}`.
|
|
122
|
+
To send a different JSON object without placing its contents in shell history:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
hookstride --env production events send DESTINATION_UUID --type demo.delivery --payload-file ./demo-payload.json --wait
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
To avoid the hidden prompt, set `HOOKSTRIDE_PRODUCTION_API_KEY` or
|
|
129
|
+
`HOOKSTRIDE_DEV_API_KEY` for the matching environment only.
|
|
130
|
+
Never add keys to CLI arguments, commit them to Git, or paste them into a public CI log.
|
|
131
|
+
The CLI does not save delivery keys or their prompts in its configuration or history.
|
|
132
|
+
The `--wait` flag polls for a terminal status and exits nonzero if delivery fails.
|
|
133
|
+
A non-delivered event can be examined using `events show` with the usual Control token.
|
|
134
|
+
|
|
106
135
|
## Environments and local settings
|
|
107
136
|
|
|
108
137
|
The CLI connects to the production API by default: `https://api.hookstride.com`.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hookstride-cli"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "Manage Hookstride projects and destinations, inspect webhook activity and billing."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -30,7 +30,7 @@ ALLOWED_COMMANDS: dict[str, set[str]] = {
|
|
|
30
30
|
"projects": {"list", "use", "create", "update"},
|
|
31
31
|
"destinations": {"list", "create", "update"},
|
|
32
32
|
"keys": {"list"},
|
|
33
|
-
"events": {"list", "show"},
|
|
33
|
+
"events": {"list", "show", "send"},
|
|
34
34
|
"billing": {"status", "history"},
|
|
35
35
|
"incoming": {"sources", "routes", "bindings", "registration-keys"},
|
|
36
36
|
"config": {"show"},
|
|
@@ -69,7 +69,7 @@ SAFE_HISTORY_COMMANDS = frozenset(
|
|
|
69
69
|
COMMAND_COMPLETIONS = sorted(
|
|
70
70
|
{
|
|
71
71
|
*SAFE_HISTORY_COMMANDS,
|
|
72
|
-
"use ", "projects use ", "events show ", "events list --status ",
|
|
72
|
+
"use ", "projects use ", "events show ", "events send ", "events list --status ",
|
|
73
73
|
"events list --origin ", "events list --page ", "set project ",
|
|
74
74
|
"projects create --name ", "projects update ",
|
|
75
75
|
"destinations create --name ", "destinations update ",
|
|
@@ -210,6 +210,7 @@ class HookstrideConsole:
|
|
|
210
210
|
"[bold]show events[/bold] View recent events\n"
|
|
211
211
|
"[bold]events list --status failed[/bold] Filter delivery history\n"
|
|
212
212
|
"[bold]events show UUID[/bold] Inspect a delivery and attempts\n"
|
|
213
|
+
"[bold]events send DEST_UUID --wait[/bold] Send one signed delivery test\n"
|
|
213
214
|
"[bold]show billing[/bold] View plan and credits\n"
|
|
214
215
|
"[bold]show payments[/bold] View payment history\n"
|
|
215
216
|
"[bold]show sources[/bold] Inspect incoming sources\n"
|
|
@@ -307,6 +308,8 @@ class HookstrideConsole:
|
|
|
307
308
|
raise cli.CLIError("No project selected. Run 'show projects' then 'use UUID'.")
|
|
308
309
|
if _requires_token(group, action) and not self._current_token():
|
|
309
310
|
raise cli.CLIError("No management token for this environment. Run 'auth login'.")
|
|
311
|
+
if args[:2] == ["events", "show"] and len(args) == 2:
|
|
312
|
+
raise cli.CLIError("Usage: events show EVENT_UUID.")
|
|
310
313
|
options = ["--env", self.environment, "--api-url", self.session.origin]
|
|
311
314
|
if self.project:
|
|
312
315
|
options += ["--project", self.project]
|
|
@@ -320,11 +323,19 @@ class HookstrideConsole:
|
|
|
320
323
|
standalone_mode=False,
|
|
321
324
|
)
|
|
322
325
|
except click.exceptions.Exit as exc:
|
|
323
|
-
#
|
|
324
|
-
|
|
326
|
+
# The nested CLI must never terminate the interactive console.
|
|
327
|
+
if exc.exit_code == 0:
|
|
328
|
+
return
|
|
329
|
+
# 'status' already displayed a red Offline panel.
|
|
325
330
|
if group == "status" and not json_output and exc.exit_code == 1:
|
|
326
331
|
return
|
|
327
332
|
raise cli.CLIError("Invalid command arguments. Type 'help'.") from None
|
|
333
|
+
except SystemExit as exc:
|
|
334
|
+
# Some Typer/Click error paths raise SystemExit instead of Exit.
|
|
335
|
+
# Do not let them unwind run() and clear in-memory authentication.
|
|
336
|
+
if exc.code in (0, None):
|
|
337
|
+
return
|
|
338
|
+
raise cli.CLIError("Invalid command arguments. Type 'help'.") from None
|
|
328
339
|
except (click.ClickException, click.Abort):
|
|
329
340
|
# Click errors can quote user-provided arguments; don't echo them.
|
|
330
341
|
raise cli.CLIError("Invalid command arguments. Type 'help'.") from None
|
|
@@ -447,7 +458,8 @@ def _requires_project(group: str, action: str | None) -> bool:
|
|
|
447
458
|
|
|
448
459
|
|
|
449
460
|
def _requires_token(group: str, action: str | None) -> bool:
|
|
450
|
-
return group != "status" and not (group == "config" and action == "show")
|
|
461
|
+
return (group != "status" and not (group == "config" and action == "show")
|
|
462
|
+
and not (group == "events" and action == "send"))
|
|
451
463
|
|
|
452
464
|
|
|
453
465
|
def run_console(session: cli.Session) -> None:
|
|
@@ -6,12 +6,15 @@ Commands use Control API tokens. This module never persists a token.
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
8
|
import contextlib
|
|
9
|
+
import getpass
|
|
9
10
|
import json
|
|
10
11
|
import os
|
|
11
12
|
import sys
|
|
12
13
|
import tempfile
|
|
14
|
+
import time
|
|
13
15
|
import unicodedata
|
|
14
16
|
import uuid
|
|
17
|
+
import warnings
|
|
15
18
|
from pathlib import Path
|
|
16
19
|
from typing import Any
|
|
17
20
|
from urllib.parse import urlsplit
|
|
@@ -40,7 +43,7 @@ app = typer.Typer(
|
|
|
40
43
|
auth_app = typer.Typer(help="Check Control API authentication.")
|
|
41
44
|
projects_app = typer.Typer(help="List, create, update and select workspace projects.")
|
|
42
45
|
destinations_app = typer.Typer(help="List, create and update webhook destinations.")
|
|
43
|
-
events_app = typer.Typer(help="
|
|
46
|
+
events_app = typer.Typer(help="Send test events and inspect delivery history.")
|
|
44
47
|
billing_app = typer.Typer(help="Inspect project-isolated billing.")
|
|
45
48
|
incoming_app = typer.Typer(help="Inspect incoming webhook sources.")
|
|
46
49
|
keys_app = typer.Typer(help="Inspect masked project API keys.")
|
|
@@ -196,12 +199,13 @@ def _request(
|
|
|
196
199
|
params: dict[str, Any] | None = None,
|
|
197
200
|
method: str = "GET",
|
|
198
201
|
json_body: dict[str, Any] | None = None,
|
|
202
|
+
auth_token: str | None = None,
|
|
199
203
|
) -> Any:
|
|
200
204
|
if method not in {"GET", "POST", "PATCH"}:
|
|
201
205
|
raise CLIError("Unsupported API request method.")
|
|
202
206
|
headers = {"Accept": "application/json"}
|
|
203
207
|
if authenticate:
|
|
204
|
-
headers["Authorization"] = "Bearer " + _token()
|
|
208
|
+
headers["Authorization"] = "Bearer " + (auth_token if auth_token is not None else _token())
|
|
205
209
|
try:
|
|
206
210
|
with httpx.Client(
|
|
207
211
|
base_url=session.origin,
|
|
@@ -219,6 +223,8 @@ def _request(
|
|
|
219
223
|
"Unable to reach the Hookstride API. Check the URL and connection."
|
|
220
224
|
) from None
|
|
221
225
|
if response.status_code == 401:
|
|
226
|
+
if auth_token is not None:
|
|
227
|
+
raise CLIError("Delivery API key rejected. Check that the project's delivery key is valid.")
|
|
222
228
|
raise CLIError(
|
|
223
229
|
"Authentication rejected. Check that your control token is valid."
|
|
224
230
|
)
|
|
@@ -816,6 +822,105 @@ def events_show(ctx: typer.Context, event_id: str = typer.Argument(...)) -> None
|
|
|
816
822
|
)
|
|
817
823
|
|
|
818
824
|
|
|
825
|
+
def _delivery_key(session: Session) -> str:
|
|
826
|
+
"""Read a project-scoped delivery key without saving or echoing it."""
|
|
827
|
+
env_name = f"HOOKSTRIDE_{session.environment.upper()}_API_KEY"
|
|
828
|
+
token = os.environ.get(env_name, "").strip()
|
|
829
|
+
if not token:
|
|
830
|
+
try:
|
|
831
|
+
# Never fall back to visibly echoed input on a noninteractive terminal.
|
|
832
|
+
with warnings.catch_warnings():
|
|
833
|
+
warnings.simplefilter("error", getpass.GetPassWarning)
|
|
834
|
+
token = getpass.getpass("Project delivery API key (hidden): ").strip()
|
|
835
|
+
except (getpass.GetPassWarning, KeyboardInterrupt, EOFError):
|
|
836
|
+
raise CLIError(
|
|
837
|
+
f"Cannot read a hidden delivery key. Set {env_name} in your shell instead."
|
|
838
|
+
) from None
|
|
839
|
+
if (
|
|
840
|
+
not token.startswith("whrk_")
|
|
841
|
+
or not 16 <= len(token) <= 512
|
|
842
|
+
or any(ch.isspace() for ch in token)
|
|
843
|
+
):
|
|
844
|
+
raise CLIError("Expected a valid project delivery API key (whrk_...).")
|
|
845
|
+
return token
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
@events_app.command("send")
|
|
849
|
+
def events_send(
|
|
850
|
+
ctx: typer.Context,
|
|
851
|
+
destination_id: str = typer.Argument(..., help="Existing destination UUID."),
|
|
852
|
+
event_type: str = typer.Option("webhook.test", "--type", help="Event type."),
|
|
853
|
+
payload_file: Path | None = typer.Option(
|
|
854
|
+
None, "--payload-file", help="Optional JSON object file, never passed on the command line."
|
|
855
|
+
),
|
|
856
|
+
wait: bool = typer.Option(False, "--wait", help="Wait for delivered or failed status."),
|
|
857
|
+
timeout: int = typer.Option(120, "--timeout", min=5, max=600, help="Maximum wait in seconds."),
|
|
858
|
+
) -> None:
|
|
859
|
+
"""Send one real event through Hookstride using a project delivery API key."""
|
|
860
|
+
_project(ctx) # Explicit project selection prevents accidental context-free writes.
|
|
861
|
+
destination_id = _uuid(destination_id)
|
|
862
|
+
event_type = event_type.strip()
|
|
863
|
+
if not event_type or len(event_type) > 160 or any(ord(ch) < 33 or ord(ch) > 126 for ch in event_type):
|
|
864
|
+
raise CLIError("Event type must contain 1–160 visible ASCII characters without spaces.")
|
|
865
|
+
payload: dict[str, Any] = {"message": "Hookstride CLI delivery test", "source": "cli"}
|
|
866
|
+
if payload_file is not None:
|
|
867
|
+
try:
|
|
868
|
+
if payload_file.stat().st_size > 65536:
|
|
869
|
+
raise CLIError("Payload file exceeds the 64 KB CLI limit.")
|
|
870
|
+
value = json.loads(payload_file.read_text(encoding="utf-8"))
|
|
871
|
+
except (OSError, UnicodeError, ValueError):
|
|
872
|
+
raise CLIError("Cannot read payload file as JSON.") from None
|
|
873
|
+
if not isinstance(value, dict):
|
|
874
|
+
raise CLIError("Payload file must contain a JSON object.")
|
|
875
|
+
payload = value
|
|
876
|
+
session = _session(ctx)
|
|
877
|
+
token = _delivery_key(session)
|
|
878
|
+
data = _request(
|
|
879
|
+
session,
|
|
880
|
+
"/api/v1/events/",
|
|
881
|
+
method="POST",
|
|
882
|
+
json_body={"endpoint": destination_id, "event_type": event_type, "payload": payload},
|
|
883
|
+
auth_token=token,
|
|
884
|
+
)
|
|
885
|
+
if not isinstance(data, dict) or not isinstance(data.get("id"), str):
|
|
886
|
+
raise CLIError("Unexpected event creation response.")
|
|
887
|
+
event_id = _uuid(data["id"])
|
|
888
|
+
if not session.json_output:
|
|
889
|
+
session.console.print(f"[green]✓[/green] Event accepted: {_escaped(event_id)}")
|
|
890
|
+
if wait:
|
|
891
|
+
deadline = time.monotonic() + timeout
|
|
892
|
+
previous = None
|
|
893
|
+
while True:
|
|
894
|
+
data = _request(session, f"/api/v1/events/{event_id}/", auth_token=token)
|
|
895
|
+
if not isinstance(data, dict):
|
|
896
|
+
raise CLIError("Unexpected event status response.")
|
|
897
|
+
status = data.get("status")
|
|
898
|
+
if not session.json_output and status != previous:
|
|
899
|
+
session.console.print(" Status:", _colored_status(status))
|
|
900
|
+
previous = status
|
|
901
|
+
if status in {"delivered", "failed"}:
|
|
902
|
+
break
|
|
903
|
+
if time.monotonic() >= deadline:
|
|
904
|
+
if session.json_output:
|
|
905
|
+
_print(ctx, {"id": event_id, "status": status, "timed_out": True})
|
|
906
|
+
raise CLIError(f"Delivery is still {status or 'pending'} for event {event_id}; inspect it with 'events show'.")
|
|
907
|
+
time.sleep(min(2, max(0, deadline - time.monotonic())))
|
|
908
|
+
if session.json_output:
|
|
909
|
+
_print(ctx, {"id": event_id, "status": data.get("status"), "attempt_count": data.get("attempt_count")})
|
|
910
|
+
elif data.get("status") == "delivered":
|
|
911
|
+
session.console.print(f"[green]✓[/green] Delivered: {_escaped(event_id)}")
|
|
912
|
+
elif data.get("status") == "failed":
|
|
913
|
+
safe_error = _safe_terminal_text(data.get("last_error") or "Unknown delivery failure.")
|
|
914
|
+
session.console.print(" Error:", Text(safe_error))
|
|
915
|
+
for attempt in data.get("attempts", []):
|
|
916
|
+
session.console.print(
|
|
917
|
+
f" Attempt {attempt.get('attempt_number', '?')}: "
|
|
918
|
+
f"HTTP {_escaped(attempt.get('status_code') or 'no response')}"
|
|
919
|
+
)
|
|
920
|
+
if data.get("status") == "failed":
|
|
921
|
+
raise CLIError(f"Event {event_id} failed. Inspect 'events show {event_id}'.")
|
|
922
|
+
|
|
923
|
+
|
|
819
924
|
@billing_app.command("status")
|
|
820
925
|
def billing_status(ctx: typer.Context) -> None:
|
|
821
926
|
"""Show the selected project's plan and Flex credit balance."""
|
|
@@ -439,3 +439,63 @@ def test_console_status_offline_returns_to_prompt_without_duplicate_error(
|
|
|
439
439
|
assert "Invalid command arguments" not in shell.test_output.getvalue()
|
|
440
440
|
assert "Unable to connect" not in shell.test_output.getvalue()
|
|
441
441
|
assert shell.execute_line("help")
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def test_events_show_missing_id_keeps_console_authenticated(shell, monkeypatch):
|
|
445
|
+
shell.project = PROJECT
|
|
446
|
+
shell.session.project = PROJECT
|
|
447
|
+
shell.tokens["dev"] = DEV_TOKEN
|
|
448
|
+
monkeypatch.setattr(
|
|
449
|
+
cli, "_request", lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
|
450
|
+
AssertionError("Missing event ID must not call the API")
|
|
451
|
+
),
|
|
452
|
+
)
|
|
453
|
+
assert shell.execute_line("events show") is True
|
|
454
|
+
assert shell._current_token() == DEV_TOKEN
|
|
455
|
+
assert shell.project == PROJECT
|
|
456
|
+
assert "Usage: events show EVENT_UUID." in shell.test_output.getvalue()
|
|
457
|
+
assert "Console closed" not in shell.test_output.getvalue()
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def test_nested_command_systemexit_does_not_destroy_console_session(shell, monkeypatch):
|
|
461
|
+
shell.project = PROJECT
|
|
462
|
+
shell.session.project = PROJECT
|
|
463
|
+
shell.tokens["dev"] = DEV_TOKEN
|
|
464
|
+
|
|
465
|
+
def usage_exit(*_args, **_kwargs):
|
|
466
|
+
raise SystemExit(2)
|
|
467
|
+
|
|
468
|
+
monkeypatch.setattr(shell.click_command, "main", usage_exit)
|
|
469
|
+
assert shell.execute_line("events list") is True
|
|
470
|
+
assert shell._current_token() == DEV_TOKEN
|
|
471
|
+
assert shell.project == PROJECT
|
|
472
|
+
assert "Invalid command arguments" in shell.test_output.getvalue()
|
|
473
|
+
assert "Console closed" not in shell.test_output.getvalue()
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def test_interactive_console_continues_after_missing_event_id(shell, monkeypatch):
|
|
477
|
+
shell.project = PROJECT
|
|
478
|
+
shell.session.project = PROJECT
|
|
479
|
+
shell.tokens["dev"] = DEV_TOKEN
|
|
480
|
+
commands = iter(["events show", "auth status", "exit"])
|
|
481
|
+
seen = []
|
|
482
|
+
|
|
483
|
+
class FakePromptSession:
|
|
484
|
+
def __init__(self, **_kwargs):
|
|
485
|
+
pass
|
|
486
|
+
|
|
487
|
+
def prompt(self, _prompt):
|
|
488
|
+
return next(commands)
|
|
489
|
+
|
|
490
|
+
def request(_session, path, **_kwargs):
|
|
491
|
+
seen.append((path, shell._current_token()))
|
|
492
|
+
return {"user": {"email": "developer@example.test"}}
|
|
493
|
+
|
|
494
|
+
monkeypatch.setattr(shell_module, "PromptSession", FakePromptSession)
|
|
495
|
+
monkeypatch.setattr(cli, "_request", request)
|
|
496
|
+
shell.run()
|
|
497
|
+
output = shell.test_output.getvalue()
|
|
498
|
+
assert "Usage: events show EVENT_UUID." in output
|
|
499
|
+
assert seen == [("/control/v1/me/", DEV_TOKEN)]
|
|
500
|
+
assert output.count("Console closed") == 1
|
|
501
|
+
assert shell.tokens == {}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Offline tests for sending real delivery events from the CLI."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from typer.testing import CliRunner
|
|
9
|
+
|
|
10
|
+
from hookstride_cli import main as cli
|
|
11
|
+
|
|
12
|
+
runner = CliRunner()
|
|
13
|
+
PROJECT = str(uuid.UUID("11111111-1111-4111-8111-111111111111"))
|
|
14
|
+
DESTINATION = str(uuid.UUID("22222222-2222-4222-8222-222222222222"))
|
|
15
|
+
EVENT = str(uuid.UUID("33333333-3333-4333-8333-333333333333"))
|
|
16
|
+
KEY = "whrk_test_project_api_key_not_real"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def setup(monkeypatch, tmp_path):
|
|
20
|
+
monkeypatch.setenv("HOOKSTRIDE_PRODUCTION_API_KEY", KEY)
|
|
21
|
+
monkeypatch.setenv("HOOKSTRIDE_CONFIG_FILE", str(tmp_path / "config.json"))
|
|
22
|
+
monkeypatch.delenv("HOOKSTRIDE_CONTROL_TOKEN", raising=False)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_send_uses_delivery_key_not_control_token(monkeypatch, tmp_path):
|
|
26
|
+
setup(monkeypatch, tmp_path)
|
|
27
|
+
requests = []
|
|
28
|
+
|
|
29
|
+
def fake_request(session, path, **kwargs):
|
|
30
|
+
requests.append((session.environment, path, kwargs))
|
|
31
|
+
return {"id": EVENT, "status": "pending"}
|
|
32
|
+
|
|
33
|
+
monkeypatch.setattr(cli, "_request", fake_request)
|
|
34
|
+
result = runner.invoke(cli.app, ["--project", PROJECT, "events", "send", DESTINATION])
|
|
35
|
+
assert result.exit_code == 0, result.output
|
|
36
|
+
assert requests == [("production", "/api/v1/events/", {
|
|
37
|
+
"method": "POST",
|
|
38
|
+
"json_body": {
|
|
39
|
+
"endpoint": DESTINATION,
|
|
40
|
+
"event_type": "webhook.test",
|
|
41
|
+
"payload": {"message": "Hookstride CLI delivery test", "source": "cli"},
|
|
42
|
+
},
|
|
43
|
+
"auth_token": KEY,
|
|
44
|
+
})]
|
|
45
|
+
assert EVENT in result.output
|
|
46
|
+
assert KEY not in result.output
|
|
47
|
+
assert not (tmp_path / "config.json").exists()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_wait_polls_until_delivered_and_json_output(monkeypatch, tmp_path):
|
|
51
|
+
setup(monkeypatch, tmp_path)
|
|
52
|
+
calls = []
|
|
53
|
+
|
|
54
|
+
def fake_request(_session, path, **kwargs):
|
|
55
|
+
calls.append(path)
|
|
56
|
+
if path == "/api/v1/events/":
|
|
57
|
+
return {"id": EVENT, "status": "pending"}
|
|
58
|
+
if len(calls) == 2:
|
|
59
|
+
return {"id": EVENT, "status": "delivering"}
|
|
60
|
+
return {"id": EVENT, "status": "delivered", "attempt_count": 1}
|
|
61
|
+
|
|
62
|
+
monkeypatch.setattr(cli, "_request", fake_request)
|
|
63
|
+
monkeypatch.setattr(cli.time, "sleep", lambda _seconds: None)
|
|
64
|
+
result = runner.invoke(cli.app, ["--project", PROJECT, "--json", "events", "send",
|
|
65
|
+
DESTINATION, "--wait"])
|
|
66
|
+
assert result.exit_code == 0, result.output
|
|
67
|
+
assert json.loads(result.stdout) == {"id": EVENT, "status": "delivered", "attempt_count": 1}
|
|
68
|
+
assert calls == ["/api/v1/events/", f"/api/v1/events/{EVENT}/", f"/api/v1/events/{EVENT}/"]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_failed_delivery_returns_failure_and_http_status(monkeypatch, tmp_path):
|
|
72
|
+
setup(monkeypatch, tmp_path)
|
|
73
|
+
|
|
74
|
+
def fake_request(_session, path, **_kw):
|
|
75
|
+
return ({"id": EVENT, "status": "pending"} if path.endswith("/events/")
|
|
76
|
+
else {"id": EVENT, "status": "failed", "last_error": "Destination returned HTTP 401.",
|
|
77
|
+
"attempts": [{"attempt_number": 1, "status_code": 401}]})
|
|
78
|
+
|
|
79
|
+
monkeypatch.setattr(cli, "_request", fake_request)
|
|
80
|
+
result = runner.invoke(cli.app, ["--project", PROJECT, "events", "send",
|
|
81
|
+
DESTINATION, "--wait"])
|
|
82
|
+
assert result.exit_code != 0
|
|
83
|
+
assert "HTTP 401" in result.output
|
|
84
|
+
assert "failed" in result.output.lower()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_invalid_payload_and_destination_refuse_network(monkeypatch, tmp_path):
|
|
88
|
+
setup(monkeypatch, tmp_path)
|
|
89
|
+
called = []
|
|
90
|
+
monkeypatch.setattr(cli, "_request", lambda *a, **kw: called.append((a, kw)))
|
|
91
|
+
filename = tmp_path / "payload.json"
|
|
92
|
+
for value in ("not json", "[1, 2]"):
|
|
93
|
+
filename.write_text(value)
|
|
94
|
+
result = runner.invoke(cli.app, ["--project", PROJECT, "events", "send",
|
|
95
|
+
DESTINATION, "--payload-file", str(filename)])
|
|
96
|
+
assert result.exit_code != 0
|
|
97
|
+
result = runner.invoke(cli.app, ["--project", PROJECT, "events", "send",
|
|
98
|
+
"bad-id", "--wait"])
|
|
99
|
+
assert result.exit_code != 0
|
|
100
|
+
assert called == []
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_payload_file_and_custom_type(monkeypatch, tmp_path):
|
|
104
|
+
setup(monkeypatch, tmp_path)
|
|
105
|
+
payload = tmp_path / "data.json"
|
|
106
|
+
payload.write_text('{"source":"product-hunt","message":"test"}')
|
|
107
|
+
seen = []
|
|
108
|
+
|
|
109
|
+
def fake_request(_session, _path, **kw):
|
|
110
|
+
seen.append(kw)
|
|
111
|
+
return {"id": EVENT, "status": "pending"}
|
|
112
|
+
|
|
113
|
+
monkeypatch.setattr(cli, "_request", fake_request)
|
|
114
|
+
result = runner.invoke(cli.app, ["--project", PROJECT, "events", "send",
|
|
115
|
+
DESTINATION, "--type", "demo.delivery",
|
|
116
|
+
"--payload-file", str(payload)])
|
|
117
|
+
assert result.exit_code == 0, result.output
|
|
118
|
+
assert seen[0]["json_body"]["payload"] == {"source": "product-hunt", "message": "test"}
|
|
119
|
+
assert seen[0]["json_body"]["event_type"] == "demo.delivery"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_delivery_key_is_sent_only_as_bearer_header(monkeypatch):
|
|
123
|
+
original = httpx.Client
|
|
124
|
+
requests = []
|
|
125
|
+
|
|
126
|
+
def handler(request):
|
|
127
|
+
requests.append(request)
|
|
128
|
+
return httpx.Response(201, json={"id": EVENT, "status": "pending"})
|
|
129
|
+
|
|
130
|
+
monkeypatch.setattr(cli.httpx, "Client",
|
|
131
|
+
lambda **kwargs: original(transport=httpx.MockTransport(handler), **kwargs))
|
|
132
|
+
session = cli.Session("production", None, PROJECT, True, True)
|
|
133
|
+
response = cli._request(session, "/api/v1/events/", method="POST",
|
|
134
|
+
json_body={"endpoint": DESTINATION, "event_type": "webhook.test",
|
|
135
|
+
"payload": {}}, auth_token=KEY)
|
|
136
|
+
assert response["id"] == EVENT
|
|
137
|
+
request = requests[0]
|
|
138
|
+
assert request.method == "POST"
|
|
139
|
+
assert request.headers["Authorization"] == f"Bearer {KEY}"
|
|
140
|
+
assert KEY not in str(request.url)
|
|
141
|
+
assert KEY not in request.content.decode("utf8")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_dev_api_key_is_not_sent_to_production(monkeypatch, tmp_path):
|
|
145
|
+
setup(monkeypatch, tmp_path)
|
|
146
|
+
monkeypatch.delenv("HOOKSTRIDE_PRODUCTION_API_KEY")
|
|
147
|
+
monkeypatch.setenv("HOOKSTRIDE_DEV_API_KEY", "whrk_dev_key_should_not_leak")
|
|
148
|
+
monkeypatch.setattr(cli.getpass, "getpass", lambda _prompt: (_ for _ in ()).throw(EOFError()))
|
|
149
|
+
called = []
|
|
150
|
+
monkeypatch.setattr(cli, "_request", lambda *a, **kw: called.append(a))
|
|
151
|
+
result = runner.invoke(cli.app, ["--env", "production", "--project", PROJECT,
|
|
152
|
+
"events", "send", DESTINATION])
|
|
153
|
+
assert result.exit_code != 0
|
|
154
|
+
assert not called
|
|
155
|
+
assert "whrk_dev_key_should_not_leak" not in result.output
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_401_delivery_auth_error_does_not_reveal_key(monkeypatch):
|
|
159
|
+
original = httpx.Client
|
|
160
|
+
|
|
161
|
+
def handler(_request):
|
|
162
|
+
return httpx.Response(401, json={"detail": KEY})
|
|
163
|
+
|
|
164
|
+
monkeypatch.setattr(cli.httpx, "Client",
|
|
165
|
+
lambda **kwargs: original(transport=httpx.MockTransport(handler), **kwargs))
|
|
166
|
+
session = cli.Session("production", None, PROJECT, False, True)
|
|
167
|
+
try:
|
|
168
|
+
cli._request(session, "/api/v1/events/", method="POST",
|
|
169
|
+
json_body={"endpoint": DESTINATION}, auth_token=KEY)
|
|
170
|
+
except cli.CLIError as exc:
|
|
171
|
+
assert "Delivery API key rejected" in str(exc)
|
|
172
|
+
assert KEY not in str(exc)
|
|
173
|
+
else:
|
|
174
|
+
raise AssertionError("Expected authentication failure")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|