pipecatcloud 0.4.3__tar.gz → 0.4.4__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.
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/CHANGELOG.md +41 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/PKG-INFO +27 -1
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/README.md +26 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/api.py +4 -1
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/cli/commands/auth.py +61 -7
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/cli/commands/secrets.py +25 -21
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/cli/config.py +29 -10
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/cli/entry_point.py +13 -3
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud.egg-info/PKG-INFO +27 -1
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud.egg-info/SOURCES.txt +1 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/tests/test_api_client.py +48 -0
- pipecatcloud-0.4.4/tests/test_auth.py +303 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/.github/workflows/format.yml +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/.github/workflows/publish-pypi.yml +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/.github/workflows/publish-test.yml +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/.github/workflows/tests.yml +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/.gitignore +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/.pre-commit-config.yaml +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/LICENSE +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/pipecat-cloud.png +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/pyproject.toml +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/pytest.ini +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/scripts/fix-ruff.sh +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/scripts/pre-commit.sh +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/setup.cfg +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/__init__.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/__main__.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/__version__.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/_utils/__init__.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/_utils/async_utils.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/_utils/auth_utils.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/_utils/build_utils.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/_utils/console_utils.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/_utils/deploy_utils.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/_utils/regions.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/agent.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/cli/__init__.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/cli/api.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/cli/commands/__init__.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/cli/commands/agent.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/cli/commands/build.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/cli/commands/deploy.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/cli/commands/docker.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/cli/commands/organizations.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/cli/commands/regions.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/config.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/constants.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/exception.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/py.typed +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/session.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud/smallwebrtc/session_manager.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud.egg-info/dependency_links.txt +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud.egg-info/entry_points.txt +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud.egg-info/requires.txt +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/src/pipecatcloud.egg-info/top_level.txt +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/tests/__init__.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/tests/conftest.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/tests/test_agent_sessions.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/tests/test_agent_stop.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/tests/test_config_file.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/tests/test_deployment_status.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/tests/test_docker_commands.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/tests/test_krisp_viva.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/tests/test_pat_auth.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/tests/test_regions.py +0 -0
- {pipecatcloud-0.4.3 → pipecatcloud-0.4.4}/uv.lock +0 -0
|
@@ -5,6 +5,47 @@ All notable changes to **Pipecat Cloud** will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.4.4] - 2026-04-10
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `pcc secrets image-pull-secret` now supports updating existing image pull
|
|
13
|
+
secrets. Previously, updating required deleting and recreating the secret.
|
|
14
|
+
The command prompts for confirmation before overwriting; use `--skip` to
|
|
15
|
+
bypass the prompt in scripts.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- `--show-cli-config` now redacts `token` and `refresh_token` values in output.
|
|
20
|
+
A type prefix (e.g. `pcc_pat_****`) is shown for diagnostics. Users who need
|
|
21
|
+
raw values can read the config file directly.
|
|
22
|
+
|
|
23
|
+
- `pcc auth use-pat` now reads the token from a secure hidden prompt instead of
|
|
24
|
+
a positional argument. The positional form still works but emits a deprecation
|
|
25
|
+
warning (tokens passed as arguments are visible in shell history and process
|
|
26
|
+
listings).
|
|
27
|
+
|
|
28
|
+
- OAuth callback server now binds to `127.0.0.1` instead of `localhost` to
|
|
29
|
+
prevent DNS rebinding attacks.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- `pcc auth logout` no longer claims "session revoked" when server-side
|
|
34
|
+
revocation was not confirmed. It now accurately reports what succeeded.
|
|
35
|
+
|
|
36
|
+
- `pcc auth logout` no longer crashes if the credentials file is already absent.
|
|
37
|
+
|
|
38
|
+
- Fixed `organizations_current()` fallback returning the wrong data shape when
|
|
39
|
+
no specific org is requested.
|
|
40
|
+
|
|
41
|
+
- OIDC discovery metadata is now validated before use: issuer must match
|
|
42
|
+
expected value, endpoints must use HTTPS, and PKCE S256 support is verified
|
|
43
|
+
when advertised.
|
|
44
|
+
|
|
45
|
+
- Credentials are now written atomically (write to temp file, fsync, rename)
|
|
46
|
+
to prevent corruption from interrupted writes. File permissions are
|
|
47
|
+
restrictive from creation.
|
|
48
|
+
|
|
8
49
|
## [0.4.3] - 2026-04-02
|
|
9
50
|
|
|
10
51
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pipecatcloud
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.4
|
|
4
4
|
Summary: Cloud hosting for Pipecat AI applications
|
|
5
5
|
License: BSD 2-Clause License
|
|
6
6
|
Project-URL: Website, https://docs.pipecat.daily.co/
|
|
@@ -97,6 +97,32 @@ if __name__ == "__main__":
|
|
|
97
97
|
asyncio.run(main())
|
|
98
98
|
```
|
|
99
99
|
|
|
100
|
+
## Troubleshooting
|
|
101
|
+
|
|
102
|
+
### SSL certificate errors on macOS
|
|
103
|
+
|
|
104
|
+
If `pcc auth login` fails with an SSL certificate verification error, your Python
|
|
105
|
+
installation may not have access to the macOS system certificate store. This is
|
|
106
|
+
common with Python installed via pyenv, conda, or the python.org installer.
|
|
107
|
+
|
|
108
|
+
To diagnose:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
import ssl, sys, os
|
|
112
|
+
print(ssl.get_default_verify_paths())
|
|
113
|
+
print(os.path.realpath(sys.executable))
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
To fix, install `certifi` and point Python to its certificates:
|
|
117
|
+
|
|
118
|
+
```shell
|
|
119
|
+
pip install certifi
|
|
120
|
+
export SSL_CERT_FILE=$(python -c "import certifi; print(certifi.where())")
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
For the python.org installer, you can also run the bundled
|
|
124
|
+
`Install Certificates.command` script found in `/Applications/Python X.Y/`.
|
|
125
|
+
|
|
100
126
|
## 🛠️ Contributing
|
|
101
127
|
|
|
102
128
|
### Setup Steps
|
|
@@ -74,6 +74,32 @@ if __name__ == "__main__":
|
|
|
74
74
|
asyncio.run(main())
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
+
## Troubleshooting
|
|
78
|
+
|
|
79
|
+
### SSL certificate errors on macOS
|
|
80
|
+
|
|
81
|
+
If `pcc auth login` fails with an SSL certificate verification error, your Python
|
|
82
|
+
installation may not have access to the macOS system certificate store. This is
|
|
83
|
+
common with Python installed via pyenv, conda, or the python.org installer.
|
|
84
|
+
|
|
85
|
+
To diagnose:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
import ssl, sys, os
|
|
89
|
+
print(ssl.get_default_verify_paths())
|
|
90
|
+
print(os.path.realpath(sys.executable))
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
To fix, install `certifi` and point Python to its certificates:
|
|
94
|
+
|
|
95
|
+
```shell
|
|
96
|
+
pip install certifi
|
|
97
|
+
export SSL_CERT_FILE=$(python -c "import certifi; print(certifi.where())")
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
For the python.org installer, you can also run the bundled
|
|
101
|
+
`Install Certificates.command` script found in `/Applications/Python X.Y/`.
|
|
102
|
+
|
|
77
103
|
## 🛠️ Contributing
|
|
78
104
|
|
|
79
105
|
### Setup Steps
|
|
@@ -233,7 +233,10 @@ class _API:
|
|
|
233
233
|
return {"name": o["name"], "verbose_name": o["verboseName"]}
|
|
234
234
|
|
|
235
235
|
# Default to first organization if active_org not found or not specified
|
|
236
|
-
return {
|
|
236
|
+
return {
|
|
237
|
+
"name": results["organizations"][0]["name"],
|
|
238
|
+
"verbose_name": results["organizations"][0]["verboseName"],
|
|
239
|
+
}
|
|
237
240
|
|
|
238
241
|
@property
|
|
239
242
|
def organizations_current(self):
|
|
@@ -40,6 +40,7 @@ USER_AGENT = f"PipecatCloudCLI/{_cli_version}"
|
|
|
40
40
|
# Ports to try for the localhost OAuth callback server.
|
|
41
41
|
# Our audience is developers who may have services running on common ports.
|
|
42
42
|
# We try a range and use the first available.
|
|
43
|
+
CALLBACK_HOST = "127.0.0.1"
|
|
43
44
|
CALLBACK_PORTS = [8400, 8401, 8402, 8403, 8404]
|
|
44
45
|
CALLBACK_PATH = "/oauth_callback"
|
|
45
46
|
|
|
@@ -63,17 +64,49 @@ async def _fetch_oauth_config() -> dict:
|
|
|
63
64
|
|
|
64
65
|
|
|
65
66
|
async def _fetch_oidc_discovery(issuer: str) -> dict:
|
|
66
|
-
"""Fetch OIDC discovery document from the OAuth issuer (RFC 8414).
|
|
67
|
+
"""Fetch and validate OIDC discovery document from the OAuth issuer (RFC 8414).
|
|
67
68
|
|
|
68
69
|
This gives us the authorization and token endpoints without
|
|
69
70
|
hardcoding any provider-specific URLs.
|
|
71
|
+
|
|
72
|
+
Validates the discovery metadata per RFC 8414 §3.1 and RFC 9207 §2.4:
|
|
73
|
+
- issuer must exactly match the expected value
|
|
74
|
+
- authorization_endpoint and token_endpoint must use HTTPS
|
|
75
|
+
- code_challenge_methods_supported must include S256 (when present)
|
|
76
|
+
- response_types_supported must include "code" (when present)
|
|
70
77
|
"""
|
|
71
78
|
url = f"{issuer}/.well-known/openid-configuration"
|
|
72
79
|
async with aiohttp.ClientSession() as session:
|
|
73
80
|
async with session.get(url, headers={"User-Agent": USER_AGENT}) as resp:
|
|
74
81
|
if resp.status != 200:
|
|
75
82
|
raise RuntimeError(f"OIDC discovery failed ({resp.status}) at {url}")
|
|
76
|
-
|
|
83
|
+
doc = await resp.json()
|
|
84
|
+
|
|
85
|
+
# RFC 8414 §3.1: issuer in metadata MUST exactly match the expected issuer.
|
|
86
|
+
if doc.get("issuer") != issuer:
|
|
87
|
+
raise RuntimeError(f"OIDC issuer mismatch: expected {issuer!r}, got {doc.get('issuer')!r}")
|
|
88
|
+
|
|
89
|
+
# Endpoints must use HTTPS to prevent credential interception.
|
|
90
|
+
for key in ("authorization_endpoint", "token_endpoint"):
|
|
91
|
+
endpoint = doc.get(key, "")
|
|
92
|
+
if not endpoint.startswith("https://"):
|
|
93
|
+
raise RuntimeError(f"OIDC {key} must use HTTPS, got {endpoint!r}")
|
|
94
|
+
|
|
95
|
+
# If the server advertises supported challenge methods, verify S256 is included.
|
|
96
|
+
challenge_methods = doc.get("code_challenge_methods_supported")
|
|
97
|
+
if challenge_methods is not None and "S256" not in challenge_methods:
|
|
98
|
+
raise RuntimeError(
|
|
99
|
+
f"OIDC server does not support S256 PKCE (supported: {challenge_methods})"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# If the server advertises supported response types, verify "code" is included.
|
|
103
|
+
response_types = doc.get("response_types_supported")
|
|
104
|
+
if response_types is not None and "code" not in response_types:
|
|
105
|
+
raise RuntimeError(
|
|
106
|
+
f"OIDC server does not support 'code' response type (supported: {response_types})"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
return doc
|
|
77
110
|
|
|
78
111
|
|
|
79
112
|
# ---- Helpers ----
|
|
@@ -241,7 +274,7 @@ async def _start_callback_server() -> Tuple[Any, int, "asyncio.Future"]:
|
|
|
241
274
|
# so we use a less-common range and try multiple.
|
|
242
275
|
for port in CALLBACK_PORTS:
|
|
243
276
|
try:
|
|
244
|
-
site = web.TCPSite(runner,
|
|
277
|
+
site = web.TCPSite(runner, CALLBACK_HOST, port)
|
|
245
278
|
await site.start()
|
|
246
279
|
return runner, port, result_future
|
|
247
280
|
except OSError:
|
|
@@ -343,7 +376,7 @@ async def login():
|
|
|
343
376
|
console.error(str(e))
|
|
344
377
|
return
|
|
345
378
|
|
|
346
|
-
redirect_uri = f"http://
|
|
379
|
+
redirect_uri = f"http://{CALLBACK_HOST}:{port}{CALLBACK_PATH}"
|
|
347
380
|
|
|
348
381
|
try:
|
|
349
382
|
# Generate PKCE verifier + challenge
|
|
@@ -442,6 +475,7 @@ async def login():
|
|
|
442
475
|
@requires_login
|
|
443
476
|
async def logout():
|
|
444
477
|
refresh_token = config.get("refresh_token")
|
|
478
|
+
revocation_succeeded = False
|
|
445
479
|
|
|
446
480
|
# If this is an OAuth session, attempt server-side token revocation (PCC-678).
|
|
447
481
|
# The /auth/logout endpoint proxies revocation to Clerk with the client_secret.
|
|
@@ -464,7 +498,7 @@ async def logout():
|
|
|
464
498
|
"Token will expire within 24 hours.[/yellow]"
|
|
465
499
|
)
|
|
466
500
|
elif resp.ok:
|
|
467
|
-
|
|
501
|
+
revocation_succeeded = True
|
|
468
502
|
else:
|
|
469
503
|
logger.debug(f"Logout revocation returned {resp.status}")
|
|
470
504
|
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
|
|
@@ -477,8 +511,13 @@ async def logout():
|
|
|
477
511
|
with console.status("[dim]Removing credentials...[/dim]", spinner="dots"):
|
|
478
512
|
remove_user_config()
|
|
479
513
|
|
|
480
|
-
if
|
|
514
|
+
if revocation_succeeded:
|
|
481
515
|
console.success("Logged out and session revoked.")
|
|
516
|
+
elif refresh_token:
|
|
517
|
+
console.success(
|
|
518
|
+
"Local credentials removed. Server-side revocation could not be confirmed.\n"
|
|
519
|
+
"[dim]The token will expire automatically within 24 hours.[/dim]"
|
|
520
|
+
)
|
|
482
521
|
else:
|
|
483
522
|
console.success(
|
|
484
523
|
"User credentials for Pipecat Cloud removed. Please sign out via dashboard to fully revoke session.",
|
|
@@ -528,8 +567,23 @@ async def _use_pat_impl(token: str):
|
|
|
528
567
|
@auth_cli.command(name="use-pat", help="Authenticate with a Personal Access Token")
|
|
529
568
|
@synchronizer.create_blocking
|
|
530
569
|
async def use_pat(
|
|
531
|
-
token: str = typer.Argument(
|
|
570
|
+
token: Optional[str] = typer.Argument(
|
|
571
|
+
None, help="[deprecated] PAT as argument (leaks to shell history)"
|
|
572
|
+
),
|
|
532
573
|
):
|
|
574
|
+
if token is not None:
|
|
575
|
+
console.print(
|
|
576
|
+
"[yellow]Warning: Passing tokens as arguments is deprecated — they are visible in "
|
|
577
|
+
"shell history and process listings. Next time, omit the argument to use the "
|
|
578
|
+
"secure prompt.[/yellow]"
|
|
579
|
+
)
|
|
580
|
+
else:
|
|
581
|
+
import getpass
|
|
582
|
+
|
|
583
|
+
token = getpass.getpass("Personal Access Token: ")
|
|
584
|
+
if not token:
|
|
585
|
+
console.error("No token provided.")
|
|
586
|
+
return
|
|
533
587
|
await _use_pat_impl(token)
|
|
534
588
|
|
|
535
589
|
|
|
@@ -15,7 +15,6 @@ import questionary
|
|
|
15
15
|
import typer
|
|
16
16
|
from loguru import logger
|
|
17
17
|
from rich import box
|
|
18
|
-
from rich.live import Live
|
|
19
18
|
from rich.panel import Panel
|
|
20
19
|
from rich.table import Table
|
|
21
20
|
|
|
@@ -492,7 +491,7 @@ async def delete(
|
|
|
492
491
|
|
|
493
492
|
|
|
494
493
|
@secrets_cli.command(
|
|
495
|
-
name="image-pull-secret", help="Create an image pull secret for active organization."
|
|
494
|
+
name="image-pull-secret", help="Create or update an image pull secret for active organization."
|
|
496
495
|
)
|
|
497
496
|
@synchronizer.create_blocking
|
|
498
497
|
@requires_login
|
|
@@ -509,6 +508,12 @@ async def image_pull_secret(
|
|
|
509
508
|
base64encode: bool = typer.Option(
|
|
510
509
|
True, "--encode", "-e", help="base64 encode credentials for added security"
|
|
511
510
|
),
|
|
511
|
+
skip_confirm: boolean = typer.Option(
|
|
512
|
+
False,
|
|
513
|
+
"--skip",
|
|
514
|
+
"-s",
|
|
515
|
+
help="Skip confirmations / force creation or update",
|
|
516
|
+
),
|
|
512
517
|
organization: str = typer.Option(
|
|
513
518
|
None,
|
|
514
519
|
"--organization",
|
|
@@ -556,13 +561,10 @@ async def image_pull_secret(
|
|
|
556
561
|
return typer.Exit(1)
|
|
557
562
|
|
|
558
563
|
# Check if secret already exists
|
|
559
|
-
|
|
560
|
-
with
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
),
|
|
564
|
-
refresh_per_second=4,
|
|
565
|
-
) as live:
|
|
564
|
+
existing_secret = None
|
|
565
|
+
with console.status(
|
|
566
|
+
f"[dim]Checking if image pull secret '{name}' already exists[/dim]", spinner="dots"
|
|
567
|
+
):
|
|
566
568
|
data, error = await API.secrets_list(org=org)
|
|
567
569
|
|
|
568
570
|
if error:
|
|
@@ -572,19 +574,20 @@ async def image_pull_secret(
|
|
|
572
574
|
existing_secret = next(
|
|
573
575
|
(s for s in data if s["name"] == name and s["type"] == "imagePullSecret"), None
|
|
574
576
|
)
|
|
575
|
-
if existing_secret:
|
|
576
|
-
live.stop()
|
|
577
|
-
console.error(
|
|
578
|
-
f"Image pull secret '[bold]{name}'[/bold] already exists. Please choose a different name or delete the existing one first."
|
|
579
|
-
)
|
|
580
|
-
return typer.Exit(1)
|
|
581
577
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
578
|
+
if existing_secret and not skip_confirm:
|
|
579
|
+
confirm = await questionary.confirm(
|
|
580
|
+
f"Image pull secret '{name}' already exists. Update it?"
|
|
581
|
+
).ask_async()
|
|
582
|
+
if not confirm:
|
|
583
|
+
console.print("[bold red]Update cancelled[/bold red]")
|
|
584
|
+
return typer.Exit(1)
|
|
587
585
|
|
|
586
|
+
used_region = None
|
|
587
|
+
with console.status(
|
|
588
|
+
f"[dim]{'Updating' if existing_secret else 'Creating'} image pull secret [bold]'{name}'[/bold][/dim]",
|
|
589
|
+
spinner="dots",
|
|
590
|
+
):
|
|
588
591
|
data, error = await API.secrets_upsert(
|
|
589
592
|
data={"isImagePullSecret": True, "secretValue": credentials, "host": host},
|
|
590
593
|
set_name=name,
|
|
@@ -600,6 +603,7 @@ async def image_pull_secret(
|
|
|
600
603
|
used_region = data["region"]
|
|
601
604
|
|
|
602
605
|
region_info = f" in [bold cyan]{used_region}[/bold cyan]" if used_region else ""
|
|
606
|
+
action = "updated" if existing_secret else "created"
|
|
603
607
|
console.success(
|
|
604
|
-
f"Image pull secret [bold green]'{name}'[/bold green] for [bold green]{host}[/bold green]
|
|
608
|
+
f"Image pull secret [bold green]'{name}'[/bold green] for [bold green]{host}[/bold green] {action} successfully{region_info}.",
|
|
605
609
|
)
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import os
|
|
8
8
|
import stat
|
|
9
9
|
import sys
|
|
10
|
+
import tempfile
|
|
10
11
|
from typing import Optional
|
|
11
12
|
|
|
12
13
|
import toml
|
|
@@ -80,20 +81,38 @@ user_config = _read_user_config()
|
|
|
80
81
|
|
|
81
82
|
def _write_user_config(new_config):
|
|
82
83
|
dir_path = os.path.dirname(user_config_path)
|
|
83
|
-
os.makedirs(dir_path, exist_ok=True)
|
|
84
|
+
os.makedirs(dir_path, mode=0o700, exist_ok=True)
|
|
84
85
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
86
|
+
# Atomic write: serialize to a temp file in the same directory, fsync,
|
|
87
|
+
# then rename into place. This prevents a crash mid-write from leaving
|
|
88
|
+
# a truncated or empty credentials file (RFC 6749 §10.3).
|
|
89
|
+
fd = None
|
|
90
|
+
tmp_path = None
|
|
91
|
+
try:
|
|
92
|
+
fd, tmp_path = tempfile.mkstemp(dir=dir_path, prefix=".pipecatcloud_", suffix=".tmp")
|
|
93
|
+
# mkstemp opens with 0o600 by default — restrictive from creation.
|
|
94
|
+
with os.fdopen(fd, "w") as f:
|
|
95
|
+
fd = None # os.fdopen takes ownership of the fd
|
|
96
|
+
toml.dump(new_config, f)
|
|
97
|
+
f.flush()
|
|
98
|
+
os.fsync(f.fileno())
|
|
99
|
+
os.replace(tmp_path, user_config_path)
|
|
100
|
+
tmp_path = None # rename succeeded, nothing to clean up
|
|
101
|
+
finally:
|
|
102
|
+
if fd is not None:
|
|
103
|
+
os.close(fd)
|
|
104
|
+
if tmp_path is not None:
|
|
105
|
+
try:
|
|
106
|
+
os.unlink(tmp_path)
|
|
107
|
+
except OSError:
|
|
108
|
+
pass
|
|
93
109
|
|
|
94
110
|
|
|
95
111
|
def remove_user_config():
|
|
96
|
-
|
|
112
|
+
try:
|
|
113
|
+
os.remove(user_config_path)
|
|
114
|
+
except FileNotFoundError:
|
|
115
|
+
pass
|
|
97
116
|
|
|
98
117
|
|
|
99
118
|
def update_user_config(
|
|
@@ -42,8 +42,18 @@ def show_config_callback(value: bool):
|
|
|
42
42
|
from pipecatcloud._utils.deploy_utils import load_deploy_config_file
|
|
43
43
|
from pipecatcloud.cli.config import deploy_config_path
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
config_dict = config.to_dict()
|
|
46
|
+
|
|
47
|
+
# Redact credential values — users who need raw values can read the
|
|
48
|
+
# config file directly (~/.config/pipecatcloud/pipecatcloud.toml).
|
|
49
|
+
for key in ("token", "refresh_token"):
|
|
50
|
+
if config_dict.get(key):
|
|
51
|
+
val = str(config_dict[key])
|
|
52
|
+
# Show token type prefix (e.g. "pcc_pat_****") for diagnostics
|
|
53
|
+
prefix = val[:8] if len(val) > 12 else ""
|
|
54
|
+
config_dict[key] = f"{prefix}****"
|
|
55
|
+
|
|
56
|
+
pprint(config_dict)
|
|
47
57
|
|
|
48
58
|
# Check for deploy config
|
|
49
59
|
try:
|
|
@@ -76,7 +86,7 @@ def cli(
|
|
|
76
86
|
None,
|
|
77
87
|
"--show-cli-config",
|
|
78
88
|
callback=show_config_callback,
|
|
79
|
-
help="Show CLI internal configuration (credentials
|
|
89
|
+
help="Show CLI internal configuration (credentials redacted)",
|
|
80
90
|
),
|
|
81
91
|
):
|
|
82
92
|
pass
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pipecatcloud
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.4
|
|
4
4
|
Summary: Cloud hosting for Pipecat AI applications
|
|
5
5
|
License: BSD 2-Clause License
|
|
6
6
|
Project-URL: Website, https://docs.pipecat.daily.co/
|
|
@@ -97,6 +97,32 @@ if __name__ == "__main__":
|
|
|
97
97
|
asyncio.run(main())
|
|
98
98
|
```
|
|
99
99
|
|
|
100
|
+
## Troubleshooting
|
|
101
|
+
|
|
102
|
+
### SSL certificate errors on macOS
|
|
103
|
+
|
|
104
|
+
If `pcc auth login` fails with an SSL certificate verification error, your Python
|
|
105
|
+
installation may not have access to the macOS system certificate store. This is
|
|
106
|
+
common with Python installed via pyenv, conda, or the python.org installer.
|
|
107
|
+
|
|
108
|
+
To diagnose:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
import ssl, sys, os
|
|
112
|
+
print(ssl.get_default_verify_paths())
|
|
113
|
+
print(os.path.realpath(sys.executable))
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
To fix, install `certifi` and point Python to its certificates:
|
|
117
|
+
|
|
118
|
+
```shell
|
|
119
|
+
pip install certifi
|
|
120
|
+
export SSL_CERT_FILE=$(python -c "import certifi; print(certifi.where())")
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
For the python.org installer, you can also run the bundled
|
|
124
|
+
`Install Certificates.command` script found in `/Applications/Python X.Y/`.
|
|
125
|
+
|
|
100
126
|
## 🛠️ Contributing
|
|
101
127
|
|
|
102
128
|
### Setup Steps
|
|
@@ -345,3 +345,51 @@ class TestAPIProperties:
|
|
|
345
345
|
# Verify payload
|
|
346
346
|
payload = mock_request.call_args[1]["json"]
|
|
347
347
|
assert payload == {"defaultRegion": "eu-central"}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class TestOrganizationsCurrent:
|
|
351
|
+
"""Test organizations_current fallback behavior."""
|
|
352
|
+
|
|
353
|
+
@pytest.fixture
|
|
354
|
+
def api_client(self):
|
|
355
|
+
return _API(token="test-token", is_cli=True)
|
|
356
|
+
|
|
357
|
+
@pytest.mark.asyncio
|
|
358
|
+
async def test_fallback_returns_first_org(self, api_client):
|
|
359
|
+
"""Regression: fallback path must index into results['organizations'], not results."""
|
|
360
|
+
with patch.object(api_client, "_base_request", new_callable=AsyncMock) as mock_request:
|
|
361
|
+
mock_request.return_value = {
|
|
362
|
+
"organizations": [
|
|
363
|
+
{"name": "my-org", "verboseName": "My Organization"},
|
|
364
|
+
{"name": "other-org", "verboseName": "Other Org"},
|
|
365
|
+
]
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
result = await api_client._organizations_current(org=None)
|
|
369
|
+
|
|
370
|
+
assert result == {"name": "my-org", "verbose_name": "My Organization"}
|
|
371
|
+
|
|
372
|
+
@pytest.mark.asyncio
|
|
373
|
+
async def test_matching_org_returned(self, api_client):
|
|
374
|
+
"""When org is specified, the matching org should be returned."""
|
|
375
|
+
with patch.object(api_client, "_base_request", new_callable=AsyncMock) as mock_request:
|
|
376
|
+
mock_request.return_value = {
|
|
377
|
+
"organizations": [
|
|
378
|
+
{"name": "first-org", "verboseName": "First Org"},
|
|
379
|
+
{"name": "target-org", "verboseName": "Target Org"},
|
|
380
|
+
]
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
result = await api_client._organizations_current(org="target-org")
|
|
384
|
+
|
|
385
|
+
assert result == {"name": "target-org", "verbose_name": "Target Org"}
|
|
386
|
+
|
|
387
|
+
@pytest.mark.asyncio
|
|
388
|
+
async def test_no_orgs_returns_none(self, api_client):
|
|
389
|
+
"""When the user has no organizations, return None."""
|
|
390
|
+
with patch.object(api_client, "_base_request", new_callable=AsyncMock) as mock_request:
|
|
391
|
+
mock_request.return_value = {"organizations": []}
|
|
392
|
+
|
|
393
|
+
result = await api_client._organizations_current(org=None)
|
|
394
|
+
|
|
395
|
+
assert result is None
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for auth flow internals.
|
|
3
|
+
|
|
4
|
+
Covers PKCE generation, callback server edge cases, token refresh,
|
|
5
|
+
discovery validation, and logout resilience.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
17
|
+
|
|
18
|
+
from pipecatcloud.cli.commands.auth import (
|
|
19
|
+
_generate_code_challenge,
|
|
20
|
+
_generate_code_verifier,
|
|
21
|
+
_fetch_oidc_discovery,
|
|
22
|
+
_start_callback_server,
|
|
23
|
+
refresh_access_token,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---- PKCE (RFC 7636) ----
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestPKCE:
|
|
31
|
+
"""Test PKCE code verifier and challenge generation."""
|
|
32
|
+
|
|
33
|
+
def test_rfc7636_appendix_b_vector(self):
|
|
34
|
+
"""RFC 7636 Appendix B reference vector."""
|
|
35
|
+
verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
|
36
|
+
expected_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
|
37
|
+
assert _generate_code_challenge(verifier) == expected_challenge
|
|
38
|
+
|
|
39
|
+
def test_verifier_length_in_range(self):
|
|
40
|
+
"""RFC 7636 §4.1: verifier must be 43-128 characters."""
|
|
41
|
+
verifier = _generate_code_verifier()
|
|
42
|
+
assert 43 <= len(verifier) <= 128
|
|
43
|
+
|
|
44
|
+
def test_verifier_is_url_safe(self):
|
|
45
|
+
"""Verifier must only contain unreserved URI characters."""
|
|
46
|
+
import re
|
|
47
|
+
|
|
48
|
+
verifier = _generate_code_verifier()
|
|
49
|
+
assert re.fullmatch(r"[A-Za-z0-9_-]+", verifier)
|
|
50
|
+
|
|
51
|
+
def test_challenge_has_no_padding(self):
|
|
52
|
+
"""BASE64URL encoding must strip '=' padding per RFC 7636 §4.2."""
|
|
53
|
+
challenge = _generate_code_challenge("test-verifier-string-that-is-long-enough")
|
|
54
|
+
assert "=" not in challenge
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---- Callback server ----
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TestCallbackServer:
|
|
61
|
+
"""Test the localhost OAuth callback server edge cases."""
|
|
62
|
+
|
|
63
|
+
@pytest.mark.asyncio
|
|
64
|
+
async def test_callback_error_sets_none_result(self):
|
|
65
|
+
"""OAuth error in callback should resolve the future with (None, None)."""
|
|
66
|
+
runner, port, result_future = await _start_callback_server()
|
|
67
|
+
try:
|
|
68
|
+
async with __import__("aiohttp").ClientSession() as session:
|
|
69
|
+
await session.get(f"http://127.0.0.1:{port}/oauth_callback?error=access_denied")
|
|
70
|
+
code, state = result_future.result()
|
|
71
|
+
assert code is None
|
|
72
|
+
assert state is None
|
|
73
|
+
finally:
|
|
74
|
+
await runner.cleanup()
|
|
75
|
+
|
|
76
|
+
@pytest.mark.asyncio
|
|
77
|
+
async def test_callback_missing_code_returns_none_code(self):
|
|
78
|
+
"""Callback with no code or error should return (None, state)."""
|
|
79
|
+
runner, port, result_future = await _start_callback_server()
|
|
80
|
+
try:
|
|
81
|
+
async with __import__("aiohttp").ClientSession() as session:
|
|
82
|
+
await session.get(f"http://127.0.0.1:{port}/oauth_callback?state=test-state")
|
|
83
|
+
code, state = result_future.result()
|
|
84
|
+
# code is None because query param "code" is absent
|
|
85
|
+
assert code is None
|
|
86
|
+
assert state == "test-state"
|
|
87
|
+
finally:
|
|
88
|
+
await runner.cleanup()
|
|
89
|
+
|
|
90
|
+
@pytest.mark.asyncio
|
|
91
|
+
async def test_callback_success_returns_code_and_state(self):
|
|
92
|
+
"""Successful callback should return the code and state."""
|
|
93
|
+
runner, port, result_future = await _start_callback_server()
|
|
94
|
+
try:
|
|
95
|
+
async with __import__("aiohttp").ClientSession() as session:
|
|
96
|
+
await session.get(
|
|
97
|
+
f"http://127.0.0.1:{port}/oauth_callback?code=auth-code-123&state=state-456"
|
|
98
|
+
)
|
|
99
|
+
code, state = result_future.result()
|
|
100
|
+
assert code == "auth-code-123"
|
|
101
|
+
assert state == "state-456"
|
|
102
|
+
finally:
|
|
103
|
+
await runner.cleanup()
|
|
104
|
+
|
|
105
|
+
@pytest.mark.asyncio
|
|
106
|
+
async def test_callback_timeout(self):
|
|
107
|
+
"""The future should raise TimeoutError if no callback arrives."""
|
|
108
|
+
runner, _port, result_future = await _start_callback_server()
|
|
109
|
+
try:
|
|
110
|
+
with pytest.raises(asyncio.TimeoutError):
|
|
111
|
+
await asyncio.wait_for(result_future, timeout=0.1)
|
|
112
|
+
finally:
|
|
113
|
+
await runner.cleanup()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---- Discovery validation ----
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class TestOIDCDiscoveryValidation:
|
|
120
|
+
"""Test OIDC discovery metadata validation."""
|
|
121
|
+
|
|
122
|
+
def _make_valid_doc(self, issuer="https://auth.example.com"):
|
|
123
|
+
return {
|
|
124
|
+
"issuer": issuer,
|
|
125
|
+
"authorization_endpoint": "https://auth.example.com/authorize",
|
|
126
|
+
"token_endpoint": "https://auth.example.com/token",
|
|
127
|
+
"code_challenge_methods_supported": ["S256"],
|
|
128
|
+
"response_types_supported": ["code"],
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
def _mock_fetch(self, doc):
|
|
132
|
+
"""Create a mock aiohttp response returning the given doc."""
|
|
133
|
+
mock_resp = AsyncMock()
|
|
134
|
+
mock_resp.status = 200
|
|
135
|
+
mock_resp.json = AsyncMock(return_value=doc)
|
|
136
|
+
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
|
137
|
+
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
|
138
|
+
|
|
139
|
+
mock_session = AsyncMock()
|
|
140
|
+
mock_session.get = MagicMock(return_value=mock_resp)
|
|
141
|
+
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
|
142
|
+
mock_session.__aexit__ = AsyncMock(return_value=False)
|
|
143
|
+
|
|
144
|
+
return patch(
|
|
145
|
+
"pipecatcloud.cli.commands.auth.aiohttp.ClientSession", return_value=mock_session
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
@pytest.mark.asyncio
|
|
149
|
+
async def test_valid_document_passes(self):
|
|
150
|
+
issuer = "https://auth.example.com"
|
|
151
|
+
doc = self._make_valid_doc(issuer)
|
|
152
|
+
with self._mock_fetch(doc):
|
|
153
|
+
result = await _fetch_oidc_discovery(issuer)
|
|
154
|
+
assert result["authorization_endpoint"] == "https://auth.example.com/authorize"
|
|
155
|
+
|
|
156
|
+
@pytest.mark.asyncio
|
|
157
|
+
async def test_issuer_mismatch_raises(self):
|
|
158
|
+
doc = self._make_valid_doc("https://wrong-issuer.example.com")
|
|
159
|
+
with self._mock_fetch(doc):
|
|
160
|
+
with pytest.raises(RuntimeError, match="issuer mismatch"):
|
|
161
|
+
await _fetch_oidc_discovery("https://auth.example.com")
|
|
162
|
+
|
|
163
|
+
@pytest.mark.asyncio
|
|
164
|
+
async def test_http_endpoint_raises(self):
|
|
165
|
+
doc = self._make_valid_doc()
|
|
166
|
+
doc["token_endpoint"] = "http://auth.example.com/token" # HTTP, not HTTPS
|
|
167
|
+
with self._mock_fetch(doc):
|
|
168
|
+
with pytest.raises(RuntimeError, match="must use HTTPS"):
|
|
169
|
+
await _fetch_oidc_discovery("https://auth.example.com")
|
|
170
|
+
|
|
171
|
+
@pytest.mark.asyncio
|
|
172
|
+
async def test_missing_s256_raises(self):
|
|
173
|
+
doc = self._make_valid_doc()
|
|
174
|
+
doc["code_challenge_methods_supported"] = ["plain"]
|
|
175
|
+
with self._mock_fetch(doc):
|
|
176
|
+
with pytest.raises(RuntimeError, match="S256"):
|
|
177
|
+
await _fetch_oidc_discovery("https://auth.example.com")
|
|
178
|
+
|
|
179
|
+
@pytest.mark.asyncio
|
|
180
|
+
async def test_missing_code_response_type_raises(self):
|
|
181
|
+
doc = self._make_valid_doc()
|
|
182
|
+
doc["response_types_supported"] = ["token"]
|
|
183
|
+
with self._mock_fetch(doc):
|
|
184
|
+
with pytest.raises(RuntimeError, match="code"):
|
|
185
|
+
await _fetch_oidc_discovery("https://auth.example.com")
|
|
186
|
+
|
|
187
|
+
@pytest.mark.asyncio
|
|
188
|
+
async def test_absent_optional_fields_passes(self):
|
|
189
|
+
"""When code_challenge_methods_supported / response_types_supported
|
|
190
|
+
are absent, validation should pass (they're optional in the spec)."""
|
|
191
|
+
doc = self._make_valid_doc()
|
|
192
|
+
del doc["code_challenge_methods_supported"]
|
|
193
|
+
del doc["response_types_supported"]
|
|
194
|
+
with self._mock_fetch(doc):
|
|
195
|
+
result = await _fetch_oidc_discovery("https://auth.example.com")
|
|
196
|
+
assert result["issuer"] == "https://auth.example.com"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ---- Token refresh ----
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class TestTokenRefresh:
|
|
203
|
+
"""Test refresh_access_token success and failure paths."""
|
|
204
|
+
|
|
205
|
+
def _mock_oauth_and_oidc(self):
|
|
206
|
+
"""Patch both discovery fetches to return valid config."""
|
|
207
|
+
oauth_config = {
|
|
208
|
+
"issuer": "https://auth.example.com",
|
|
209
|
+
"client_id": "test-client-id",
|
|
210
|
+
"scopes": "openid profile",
|
|
211
|
+
}
|
|
212
|
+
oidc_doc = {
|
|
213
|
+
"issuer": "https://auth.example.com",
|
|
214
|
+
"authorization_endpoint": "https://auth.example.com/authorize",
|
|
215
|
+
"token_endpoint": "https://auth.example.com/token",
|
|
216
|
+
}
|
|
217
|
+
return (
|
|
218
|
+
patch(
|
|
219
|
+
"pipecatcloud.cli.commands.auth._fetch_oauth_config",
|
|
220
|
+
new_callable=AsyncMock,
|
|
221
|
+
return_value=oauth_config,
|
|
222
|
+
),
|
|
223
|
+
patch(
|
|
224
|
+
"pipecatcloud.cli.commands.auth._fetch_oidc_discovery",
|
|
225
|
+
new_callable=AsyncMock,
|
|
226
|
+
return_value=oidc_doc,
|
|
227
|
+
),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
@pytest.mark.asyncio
|
|
231
|
+
async def test_refresh_success(self):
|
|
232
|
+
mock_oauth, mock_oidc = self._mock_oauth_and_oidc()
|
|
233
|
+
token_response = {
|
|
234
|
+
"access_token": "new-token",
|
|
235
|
+
"refresh_token": "new-refresh",
|
|
236
|
+
"expires_in": 3600,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
mock_resp = AsyncMock()
|
|
240
|
+
mock_resp.status = 200
|
|
241
|
+
mock_resp.json = AsyncMock(return_value=token_response)
|
|
242
|
+
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
|
243
|
+
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
|
244
|
+
|
|
245
|
+
mock_session = AsyncMock()
|
|
246
|
+
mock_session.post = MagicMock(return_value=mock_resp)
|
|
247
|
+
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
|
248
|
+
mock_session.__aexit__ = AsyncMock(return_value=False)
|
|
249
|
+
|
|
250
|
+
with mock_oauth, mock_oidc:
|
|
251
|
+
with patch(
|
|
252
|
+
"pipecatcloud.cli.commands.auth.aiohttp.ClientSession", return_value=mock_session
|
|
253
|
+
):
|
|
254
|
+
result = await refresh_access_token("old-refresh-token")
|
|
255
|
+
|
|
256
|
+
assert result["access_token"] == "new-token"
|
|
257
|
+
|
|
258
|
+
@pytest.mark.asyncio
|
|
259
|
+
async def test_refresh_failure_returns_none(self):
|
|
260
|
+
mock_oauth, mock_oidc = self._mock_oauth_and_oidc()
|
|
261
|
+
|
|
262
|
+
mock_resp = AsyncMock()
|
|
263
|
+
mock_resp.status = 401
|
|
264
|
+
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
|
265
|
+
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
|
266
|
+
|
|
267
|
+
mock_session = AsyncMock()
|
|
268
|
+
mock_session.post = MagicMock(return_value=mock_resp)
|
|
269
|
+
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
|
270
|
+
mock_session.__aexit__ = AsyncMock(return_value=False)
|
|
271
|
+
|
|
272
|
+
with mock_oauth, mock_oidc:
|
|
273
|
+
with patch(
|
|
274
|
+
"pipecatcloud.cli.commands.auth.aiohttp.ClientSession", return_value=mock_session
|
|
275
|
+
):
|
|
276
|
+
result = await refresh_access_token("bad-refresh-token")
|
|
277
|
+
|
|
278
|
+
assert result is None
|
|
279
|
+
|
|
280
|
+
@pytest.mark.asyncio
|
|
281
|
+
async def test_refresh_config_fetch_failure_returns_none(self):
|
|
282
|
+
with patch(
|
|
283
|
+
"pipecatcloud.cli.commands.auth._fetch_oauth_config",
|
|
284
|
+
new_callable=AsyncMock,
|
|
285
|
+
side_effect=RuntimeError("unreachable"),
|
|
286
|
+
):
|
|
287
|
+
result = await refresh_access_token("some-refresh-token")
|
|
288
|
+
|
|
289
|
+
assert result is None
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ---- Logout without config file ----
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class TestLogoutWithoutConfig:
|
|
296
|
+
"""Test that remove_user_config is idempotent."""
|
|
297
|
+
|
|
298
|
+
def test_remove_missing_config_does_not_raise(self):
|
|
299
|
+
from pipecatcloud.cli.config import remove_user_config
|
|
300
|
+
|
|
301
|
+
with patch("pipecatcloud.cli.config.user_config_path", "/nonexistent/path/config.toml"):
|
|
302
|
+
# Should not raise
|
|
303
|
+
remove_user_config()
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|