licos-platform-cli 0.2.10__tar.gz → 0.2.13__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.
- {licos_platform_cli-0.2.10 → licos_platform_cli-0.2.13}/.gitignore +2 -0
- {licos_platform_cli-0.2.10 → licos_platform_cli-0.2.13}/PKG-INFO +2 -2
- {licos_platform_cli-0.2.10 → licos_platform_cli-0.2.13}/pyproject.toml +2 -2
- {licos_platform_cli-0.2.10 → licos_platform_cli-0.2.13}/src/licos_platform_cli/help_commands.py +50 -0
- {licos_platform_cli-0.2.10 → licos_platform_cli-0.2.13}/src/licos_platform_cli/main.py +2 -0
- licos_platform_cli-0.2.13/src/licos_platform_cli/oauth_commands.py +252 -0
- {licos_platform_cli-0.2.10 → licos_platform_cli-0.2.13}/tests/test_cli.py +240 -2
- {licos_platform_cli-0.2.10 → licos_platform_cli-0.2.13}/src/licos_platform_cli/__init__.py +0 -0
- {licos_platform_cli-0.2.10 → licos_platform_cli-0.2.13}/src/licos_platform_cli/database_commands.py +0 -0
- {licos_platform_cli-0.2.10 → licos_platform_cli-0.2.13}/src/licos_platform_cli/knowledge_commands.py +0 -0
- {licos_platform_cli-0.2.10 → licos_platform_cli-0.2.13}/src/licos_platform_cli/storage_commands.py +0 -0
|
@@ -4,11 +4,11 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "licos-platform-cli"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.13"
|
|
8
8
|
description = "LICOS platform CLI"
|
|
9
9
|
requires-python = ">=3.10"
|
|
10
10
|
dependencies = [
|
|
11
|
-
"licos-platform-sdk>=0.
|
|
11
|
+
"licos-platform-sdk>=0.3.0",
|
|
12
12
|
]
|
|
13
13
|
|
|
14
14
|
[project.scripts]
|
{licos_platform_cli-0.2.10 → licos_platform_cli-0.2.13}/src/licos_platform_cli/help_commands.py
RENAMED
|
@@ -451,10 +451,58 @@ KNOWLEDGE_HELP = {
|
|
|
451
451
|
}
|
|
452
452
|
|
|
453
453
|
|
|
454
|
+
OAUTH_HELP = {
|
|
455
|
+
"topic": "oauth",
|
|
456
|
+
"description": "Manage LICOS OAuth2/OIDC login applications from trusted project runtime code.",
|
|
457
|
+
"runtime": RUNTIME_ENV,
|
|
458
|
+
"rules": [
|
|
459
|
+
"Use only when the user explicitly requests LICOS platform OAuth2/OIDC login.",
|
|
460
|
+
"Before configure-project, confirm auto-apply versus an existing app, login audience, and all callbacks.",
|
|
461
|
+
"Personal users use ALL. Enterprise users explicitly choose ALL or OWNER_TENANT.",
|
|
462
|
+
"Generated config uses PKCE and never contains a client secret.",
|
|
463
|
+
"Do not rotate a secret unless the user explicitly asks for rotation.",
|
|
464
|
+
],
|
|
465
|
+
"sdk": {
|
|
466
|
+
"python": "from licos_platform_sdk import oauth\napps = oauth.list_apps()\napp = oauth.get_app('app-id')",
|
|
467
|
+
"node": "import { oauth } from '@kmlckj/licos-platform-sdk';\nconst apps = await oauth.listApps();",
|
|
468
|
+
},
|
|
469
|
+
"commands": {
|
|
470
|
+
"apps": {
|
|
471
|
+
"description": "List OAuth2 applications manageable by the current project owner.",
|
|
472
|
+
"parameters": [],
|
|
473
|
+
"example": "licos-platform oauth apps",
|
|
474
|
+
},
|
|
475
|
+
"get": {
|
|
476
|
+
"description": "Get one manageable OAuth2 application.",
|
|
477
|
+
"parameters": [{"name": "app_id", "required": True, "description": "OAuth2 application ID."}],
|
|
478
|
+
"example": "licos-platform oauth get app-id",
|
|
479
|
+
},
|
|
480
|
+
"configure-project": {
|
|
481
|
+
"description": "After explicit confirmation, apply or update one OAuth2 app and write config/licos-oauth.json without a secret.",
|
|
482
|
+
"parameters": [
|
|
483
|
+
{"name": "--auto-apply / --app-id", "required": True, "description": "Explicit application selection mode."},
|
|
484
|
+
{"name": "--name", "required": True, "description": "Application display name."},
|
|
485
|
+
{"name": "--login-audience", "required": True, "description": "ALL or OWNER_TENANT."},
|
|
486
|
+
{"name": "--redirect", "required": False, "description": "Optional explicit callback override; otherwise derive stable dev/prod callbacks from project runtime metadata."},
|
|
487
|
+
{"name": "--public-base-url", "required": False, "description": "Optional public LICOS OAuth2 origin override."},
|
|
488
|
+
],
|
|
489
|
+
"example": "licos-platform oauth configure-project --auto-apply --name 'Project login' --login-audience ALL",
|
|
490
|
+
},
|
|
491
|
+
"delete": {"description": "Delete an OAuth2 application.", "parameters": [], "example": "licos-platform oauth delete app-id"},
|
|
492
|
+
"enable": {"description": "Enable an OAuth2 application.", "parameters": [], "example": "licos-platform oauth enable app-id"},
|
|
493
|
+
"disable": {"description": "Disable an OAuth2 application.", "parameters": [], "example": "licos-platform oauth disable app-id"},
|
|
494
|
+
"rotate-secret": {"description": "Explicitly rotate a confidential-client secret.", "parameters": [], "example": "licos-platform oauth rotate-secret app-id"},
|
|
495
|
+
"grants": {"description": "List application grants.", "parameters": [], "example": "licos-platform oauth grants app-id"},
|
|
496
|
+
"revoke-grant": {"description": "Revoke one grant.", "parameters": [], "example": "licos-platform oauth revoke-grant grant-id"},
|
|
497
|
+
},
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
|
|
454
501
|
HELP_TOPICS = {
|
|
455
502
|
"storage": STORAGE_HELP,
|
|
456
503
|
"database": DATABASE_HELP,
|
|
457
504
|
"knowledge": KNOWLEDGE_HELP,
|
|
505
|
+
"oauth": OAUTH_HELP,
|
|
458
506
|
}
|
|
459
507
|
|
|
460
508
|
|
|
@@ -475,6 +523,8 @@ def _cmd_help(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
475
523
|
"licos-platform help database studio-ensure",
|
|
476
524
|
"licos-platform help knowledge",
|
|
477
525
|
"licos-platform help knowledge search",
|
|
526
|
+
"licos-platform help oauth",
|
|
527
|
+
"licos-platform help oauth configure-project",
|
|
478
528
|
],
|
|
479
529
|
"topics": sorted(HELP_TOPICS),
|
|
480
530
|
}
|
|
@@ -10,6 +10,7 @@ from licos_platform_sdk import PlatformSdkError
|
|
|
10
10
|
from .database_commands import add_database_parser
|
|
11
11
|
from .help_commands import add_help_parser
|
|
12
12
|
from .knowledge_commands import add_knowledge_parser
|
|
13
|
+
from .oauth_commands import add_oauth_parser
|
|
13
14
|
from .storage_commands import add_storage_parser
|
|
14
15
|
|
|
15
16
|
|
|
@@ -32,6 +33,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
32
33
|
add_storage_parser(subparsers)
|
|
33
34
|
add_database_parser(subparsers)
|
|
34
35
|
add_knowledge_parser(subparsers)
|
|
36
|
+
add_oauth_parser(subparsers)
|
|
35
37
|
return parser
|
|
36
38
|
|
|
37
39
|
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
from urllib.parse import quote, urlsplit, urlunsplit
|
|
9
|
+
|
|
10
|
+
from licos_platform_sdk import oauth
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
Handler = Callable[[argparse.Namespace], Any]
|
|
14
|
+
DEFAULT_SCOPES = ["openid", "profile", "email"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _set_handler(parser: argparse.ArgumentParser, handler: Handler) -> None:
|
|
18
|
+
parser.set_defaults(handler=handler)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parse_http_url(value: str, *, option: str) -> str:
|
|
22
|
+
candidate = value.strip()
|
|
23
|
+
parsed = urlsplit(candidate)
|
|
24
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
25
|
+
raise argparse.ArgumentTypeError(f"{option} must be an absolute http(s) URL")
|
|
26
|
+
if parsed.fragment:
|
|
27
|
+
raise argparse.ArgumentTypeError(f"{option} must not contain a fragment")
|
|
28
|
+
return candidate
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_redirect(value: str) -> dict[str, str]:
|
|
32
|
+
environment = "prod"
|
|
33
|
+
redirect_uri = value
|
|
34
|
+
prefix, separator, remainder = value.partition("=")
|
|
35
|
+
if separator:
|
|
36
|
+
environment = prefix.strip().lower()
|
|
37
|
+
redirect_uri = remainder
|
|
38
|
+
if environment not in {"dev", "prod"}:
|
|
39
|
+
raise argparse.ArgumentTypeError("redirect environment must be dev or prod")
|
|
40
|
+
return {
|
|
41
|
+
"environment": environment,
|
|
42
|
+
"redirectUri": _parse_http_url(redirect_uri, option="--redirect"),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _public_base_url(value: str) -> str:
|
|
47
|
+
parsed = urlsplit(_parse_http_url(value, option="--public-base-url"))
|
|
48
|
+
path = parsed.path.rstrip("/")
|
|
49
|
+
if path.endswith("/api/v1"):
|
|
50
|
+
path = path[: -len("/api/v1")]
|
|
51
|
+
return urlunsplit((parsed.scheme, parsed.netloc, path, "", "")).rstrip("/")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _required_env(name: str) -> str:
|
|
55
|
+
value = (os.environ.get(name) or "").strip()
|
|
56
|
+
if not value:
|
|
57
|
+
raise argparse.ArgumentTypeError(
|
|
58
|
+
f"{name} is required when --redirect is not provided"
|
|
59
|
+
)
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _automatic_redirects() -> list[dict[str, str]]:
|
|
64
|
+
project_id = _required_env("AGENT_PROJECT_ID")
|
|
65
|
+
encoded_project_id = quote(project_id, safe="")
|
|
66
|
+
platform_public_base = _public_base_url(_required_env("LICOS_PLATFORM_PUBLIC_BASE_URL"))
|
|
67
|
+
deploy_public_base = _public_base_url(_required_env("LICOS_DEPLOY_PUBLIC_BASE_URL"))
|
|
68
|
+
return [
|
|
69
|
+
{
|
|
70
|
+
"environment": "dev",
|
|
71
|
+
"redirectUri": (
|
|
72
|
+
f"{platform_public_base}/agent/api/v1/projects/"
|
|
73
|
+
f"{encoded_project_id}/oauth/callback"
|
|
74
|
+
),
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"environment": "prod",
|
|
78
|
+
"redirectUri": (
|
|
79
|
+
f"{deploy_public_base}/p/{encoded_project_id}/api/auth/licos/callback"
|
|
80
|
+
),
|
|
81
|
+
},
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _project_root() -> Path:
|
|
86
|
+
return Path(os.environ.get("LICOS_PROJECT_PATH") or Path.cwd()).resolve()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _config_path(output: str) -> Path:
|
|
90
|
+
root = _project_root()
|
|
91
|
+
candidate = Path(output)
|
|
92
|
+
resolved = (candidate if candidate.is_absolute() else root / candidate).resolve()
|
|
93
|
+
try:
|
|
94
|
+
resolved.relative_to(root)
|
|
95
|
+
except ValueError as exc:
|
|
96
|
+
raise argparse.ArgumentTypeError("--out must stay inside LICOS_PROJECT_PATH") from exc
|
|
97
|
+
return resolved
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _without_secret(value: Any) -> Any:
|
|
101
|
+
if isinstance(value, dict):
|
|
102
|
+
return {
|
|
103
|
+
key: _without_secret(item)
|
|
104
|
+
for key, item in value.items()
|
|
105
|
+
if "secret" not in key.lower().replace("_", "")
|
|
106
|
+
}
|
|
107
|
+
if isinstance(value, list):
|
|
108
|
+
return [_without_secret(item) for item in value]
|
|
109
|
+
return value
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _cmd_apps(args: argparse.Namespace) -> Any:
|
|
113
|
+
return _without_secret(oauth.list_apps(page=args.page, page_size=args.page_size))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _cmd_get(args: argparse.Namespace) -> Any:
|
|
117
|
+
return _without_secret(oauth.get_app(args.app_id))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _cmd_delete(args: argparse.Namespace) -> Any:
|
|
121
|
+
return oauth.delete_app(args.app_id)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _cmd_set_enabled(args: argparse.Namespace) -> Any:
|
|
125
|
+
return _without_secret(oauth.set_enabled(args.app_id, args.enabled))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _cmd_rotate_secret(args: argparse.Namespace) -> Any:
|
|
129
|
+
return oauth.rotate_secret(args.app_id)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _cmd_grants(args: argparse.Namespace) -> Any:
|
|
133
|
+
return oauth.list_grants(args.app_id)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _cmd_revoke_grant(args: argparse.Namespace) -> Any:
|
|
137
|
+
return oauth.revoke_grant(args.grant_id)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _cmd_configure_project(args: argparse.Namespace) -> dict[str, Any]:
|
|
141
|
+
public_base_url = (
|
|
142
|
+
args.public_base_url
|
|
143
|
+
or os.environ.get("LICOS_PLATFORM_PUBLIC_BASE_URL")
|
|
144
|
+
or os.environ.get("LICOS_OAUTH_PUBLIC_BASE_URL")
|
|
145
|
+
)
|
|
146
|
+
if not public_base_url:
|
|
147
|
+
raise argparse.ArgumentTypeError(
|
|
148
|
+
"--public-base-url, LICOS_PLATFORM_PUBLIC_BASE_URL, or LICOS_OAUTH_PUBLIC_BASE_URL is required"
|
|
149
|
+
)
|
|
150
|
+
normalized_public_base_url = _public_base_url(public_base_url)
|
|
151
|
+
redirects = args.redirects or _automatic_redirects()
|
|
152
|
+
path = _config_path(args.out)
|
|
153
|
+
app = oauth.ensure_project_app(
|
|
154
|
+
app_id=args.app_id,
|
|
155
|
+
name=args.name,
|
|
156
|
+
description=args.description,
|
|
157
|
+
login_audience=args.login_audience,
|
|
158
|
+
redirect_uri_configs=redirects,
|
|
159
|
+
allowed_scopes=args.scopes or DEFAULT_SCOPES,
|
|
160
|
+
)
|
|
161
|
+
app_id = str(app.get("id") or "").strip()
|
|
162
|
+
if not app_id:
|
|
163
|
+
raise argparse.ArgumentTypeError("platform OAuth2 app response is missing id")
|
|
164
|
+
client_id = str(app.get("clientId") or "").strip()
|
|
165
|
+
if not client_id:
|
|
166
|
+
raise argparse.ArgumentTypeError("platform OAuth2 app response is missing clientId")
|
|
167
|
+
|
|
168
|
+
config = {
|
|
169
|
+
"provider": "licos-oauth2",
|
|
170
|
+
"appId": app_id,
|
|
171
|
+
"appCode": app.get("appCode"),
|
|
172
|
+
"clientId": client_id,
|
|
173
|
+
"loginAudience": args.login_audience,
|
|
174
|
+
"scopes": args.scopes or DEFAULT_SCOPES,
|
|
175
|
+
"redirectUriConfigs": redirects,
|
|
176
|
+
"pkce": True,
|
|
177
|
+
"publicBaseUrl": normalized_public_base_url,
|
|
178
|
+
"endpoints": {
|
|
179
|
+
"authorization": "/oauth2/authorize",
|
|
180
|
+
"token": "/oauth2/token",
|
|
181
|
+
"userinfo": "/oauth2/userinfo",
|
|
182
|
+
"revoke": "/oauth2/revoke",
|
|
183
|
+
"discovery": "/.well-known/openid-configuration",
|
|
184
|
+
"jwks": "/oauth2/jwks",
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
188
|
+
path.write_text(json.dumps(config, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
189
|
+
return {
|
|
190
|
+
"app": _without_secret(app),
|
|
191
|
+
"configPath": str(path),
|
|
192
|
+
"selectionMode": "existing" if args.app_id else "auto-apply",
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def add_oauth_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
197
|
+
parser = subparsers.add_parser("oauth", help="Manage LICOS platform OAuth2 applications")
|
|
198
|
+
commands = parser.add_subparsers(dest="oauth_command", required=True)
|
|
199
|
+
|
|
200
|
+
apps = commands.add_parser("apps", help="List manageable OAuth2 applications")
|
|
201
|
+
apps.add_argument("--page", type=int, default=1)
|
|
202
|
+
apps.add_argument("--page-size", type=int, default=100)
|
|
203
|
+
_set_handler(apps, _cmd_apps)
|
|
204
|
+
|
|
205
|
+
get_app = commands.add_parser("get", help="Get one OAuth2 application")
|
|
206
|
+
get_app.add_argument("app_id")
|
|
207
|
+
_set_handler(get_app, _cmd_get)
|
|
208
|
+
|
|
209
|
+
configure = commands.add_parser(
|
|
210
|
+
"configure-project",
|
|
211
|
+
help="Configure a confirmed OAuth2 application and write public PKCE project settings",
|
|
212
|
+
)
|
|
213
|
+
selection = configure.add_mutually_exclusive_group(required=True)
|
|
214
|
+
selection.add_argument("--auto-apply", action="store_true", help="Create or update the app for this project ID")
|
|
215
|
+
selection.add_argument("--app-id", default=None, help="Use and update an existing manageable OAuth2 app")
|
|
216
|
+
configure.add_argument("--name", required=True)
|
|
217
|
+
configure.add_argument("--description", default=None)
|
|
218
|
+
configure.add_argument("--login-audience", required=True, choices=["ALL", "OWNER_TENANT"])
|
|
219
|
+
configure.add_argument(
|
|
220
|
+
"--redirect",
|
|
221
|
+
action="append",
|
|
222
|
+
dest="redirects",
|
|
223
|
+
type=_parse_redirect,
|
|
224
|
+
default=None,
|
|
225
|
+
help="Explicit callback override as dev=URL, prod=URL, or URL (defaults to prod); can repeat",
|
|
226
|
+
)
|
|
227
|
+
configure.add_argument("--scope", action="append", dest="scopes", default=None, help="OAuth2 scope; can repeat")
|
|
228
|
+
configure.add_argument("--public-base-url", default=None, help="Public LICOS OAuth2 origin; /api/v1 is removed")
|
|
229
|
+
configure.add_argument("--out", default="config/licos-oauth.json", help="Config path under LICOS_PROJECT_PATH")
|
|
230
|
+
_set_handler(configure, _cmd_configure_project)
|
|
231
|
+
|
|
232
|
+
delete = commands.add_parser("delete", help="Delete an OAuth2 application")
|
|
233
|
+
delete.add_argument("app_id")
|
|
234
|
+
_set_handler(delete, _cmd_delete)
|
|
235
|
+
|
|
236
|
+
for name, enabled in (("enable", True), ("disable", False)):
|
|
237
|
+
command = commands.add_parser(name, help=f"{name.capitalize()} an OAuth2 application")
|
|
238
|
+
command.add_argument("app_id")
|
|
239
|
+
command.set_defaults(enabled=enabled)
|
|
240
|
+
_set_handler(command, _cmd_set_enabled)
|
|
241
|
+
|
|
242
|
+
rotate = commands.add_parser("rotate-secret", help="Explicitly rotate and print a new confidential-client secret")
|
|
243
|
+
rotate.add_argument("app_id")
|
|
244
|
+
_set_handler(rotate, _cmd_rotate_secret)
|
|
245
|
+
|
|
246
|
+
grants = commands.add_parser("grants", help="List grants for an OAuth2 application")
|
|
247
|
+
grants.add_argument("app_id")
|
|
248
|
+
_set_handler(grants, _cmd_grants)
|
|
249
|
+
|
|
250
|
+
revoke = commands.add_parser("revoke-grant", help="Revoke one OAuth2 grant")
|
|
251
|
+
revoke.add_argument("grant_id")
|
|
252
|
+
_set_handler(revoke, _cmd_revoke_grant)
|
|
@@ -14,7 +14,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "licos-platform-sdk
|
|
|
14
14
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
15
15
|
|
|
16
16
|
from licos_platform_cli import main as cli_main
|
|
17
|
-
from licos_platform_cli import database_commands, knowledge_commands, storage_commands
|
|
17
|
+
from licos_platform_cli import database_commands, knowledge_commands, oauth_commands, storage_commands
|
|
18
18
|
from licos_platform_sdk import ApiError
|
|
19
19
|
|
|
20
20
|
|
|
@@ -46,7 +46,7 @@ class PlatformCliTests(unittest.TestCase):
|
|
|
46
46
|
self.assertEqual(code, 0)
|
|
47
47
|
self.assertEqual(stderr, "")
|
|
48
48
|
payload = json.loads(stdout)
|
|
49
|
-
self.assertEqual(payload["topics"], ["database", "knowledge", "storage"])
|
|
49
|
+
self.assertEqual(payload["topics"], ["database", "knowledge", "oauth", "storage"])
|
|
50
50
|
self.assertIn("licos-platform help storage", payload["usage"])
|
|
51
51
|
|
|
52
52
|
def test_structured_help_storage_command_describes_parameters(self) -> None:
|
|
@@ -148,6 +148,244 @@ class PlatformCliTests(unittest.TestCase):
|
|
|
148
148
|
self.assertEqual(stdout, "")
|
|
149
149
|
self.assertIn("platform failed", stderr)
|
|
150
150
|
|
|
151
|
+
def test_oauth_configure_project_requires_explicit_app_selection(self) -> None:
|
|
152
|
+
code, stdout, stderr = run_cli(
|
|
153
|
+
"oauth",
|
|
154
|
+
"configure-project",
|
|
155
|
+
"--name",
|
|
156
|
+
"Project login",
|
|
157
|
+
"--login-audience",
|
|
158
|
+
"ALL",
|
|
159
|
+
"--redirect",
|
|
160
|
+
"prod=https://app.example/callback",
|
|
161
|
+
"--public-base-url",
|
|
162
|
+
"https://platform.example",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
self.assertEqual(code, 2)
|
|
166
|
+
self.assertEqual(stdout, "")
|
|
167
|
+
self.assertIn("--auto-apply", stderr)
|
|
168
|
+
self.assertIn("--app-id", stderr)
|
|
169
|
+
|
|
170
|
+
def test_oauth_configure_project_requires_explicit_audience(self) -> None:
|
|
171
|
+
code, stdout, stderr = run_cli(
|
|
172
|
+
"oauth",
|
|
173
|
+
"configure-project",
|
|
174
|
+
"--auto-apply",
|
|
175
|
+
"--name",
|
|
176
|
+
"Project login",
|
|
177
|
+
"--redirect",
|
|
178
|
+
"prod=https://app.example/callback",
|
|
179
|
+
"--public-base-url",
|
|
180
|
+
"https://platform.example",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
self.assertEqual(code, 2)
|
|
184
|
+
self.assertEqual(stdout, "")
|
|
185
|
+
self.assertIn("--login-audience", stderr)
|
|
186
|
+
|
|
187
|
+
def test_oauth_list_never_prints_secret_fields(self) -> None:
|
|
188
|
+
result = {
|
|
189
|
+
"list": [
|
|
190
|
+
{
|
|
191
|
+
"id": "app-1",
|
|
192
|
+
"secret": "secret-1",
|
|
193
|
+
"clientSecret": "secret-2",
|
|
194
|
+
"client_secret": "secret-3",
|
|
195
|
+
}
|
|
196
|
+
]
|
|
197
|
+
}
|
|
198
|
+
with mock.patch.object(oauth_commands.oauth, "list_apps", return_value=result):
|
|
199
|
+
code, stdout, stderr = run_cli("oauth", "apps")
|
|
200
|
+
|
|
201
|
+
self.assertEqual(code, 0)
|
|
202
|
+
self.assertEqual(stderr, "")
|
|
203
|
+
self.assertEqual(json.loads(stdout), {"list": [{"id": "app-1"}]})
|
|
204
|
+
|
|
205
|
+
def test_oauth_configure_existing_app_writes_pkce_config_without_secret(self) -> None:
|
|
206
|
+
app = {
|
|
207
|
+
"id": "app-1",
|
|
208
|
+
"appCode": "project-project-1",
|
|
209
|
+
"clientId": "client-1",
|
|
210
|
+
"secret": "must-not-be-written",
|
|
211
|
+
}
|
|
212
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
213
|
+
with mock.patch.dict(os.environ, {"LICOS_PROJECT_PATH": tmp}, clear=False):
|
|
214
|
+
with mock.patch.object(oauth_commands.oauth, "ensure_project_app", return_value=app) as ensure:
|
|
215
|
+
code, stdout, stderr = run_cli(
|
|
216
|
+
"oauth",
|
|
217
|
+
"configure-project",
|
|
218
|
+
"--app-id",
|
|
219
|
+
"app-1",
|
|
220
|
+
"--name",
|
|
221
|
+
"Project login",
|
|
222
|
+
"--login-audience",
|
|
223
|
+
"OWNER_TENANT",
|
|
224
|
+
"--redirect",
|
|
225
|
+
"dev=http://localhost:3000/auth/callback",
|
|
226
|
+
"--redirect",
|
|
227
|
+
"prod=https://app.example/auth/callback",
|
|
228
|
+
"--public-base-url",
|
|
229
|
+
"https://platform.example/api/v1",
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
self.assertEqual(code, 0)
|
|
233
|
+
self.assertEqual(stderr, "")
|
|
234
|
+
payload = json.loads(stdout)
|
|
235
|
+
config_path = Path(payload["configPath"])
|
|
236
|
+
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
237
|
+
self.assertEqual(config["clientId"], "client-1")
|
|
238
|
+
self.assertEqual(config["loginAudience"], "OWNER_TENANT")
|
|
239
|
+
self.assertEqual(config["publicBaseUrl"], "https://platform.example")
|
|
240
|
+
self.assertTrue(config["pkce"])
|
|
241
|
+
self.assertNotIn("secret", config)
|
|
242
|
+
ensure.assert_called_once_with(
|
|
243
|
+
app_id="app-1",
|
|
244
|
+
name="Project login",
|
|
245
|
+
description=None,
|
|
246
|
+
login_audience="OWNER_TENANT",
|
|
247
|
+
redirect_uri_configs=[
|
|
248
|
+
{"environment": "dev", "redirectUri": "http://localhost:3000/auth/callback"},
|
|
249
|
+
{"environment": "prod", "redirectUri": "https://app.example/auth/callback"},
|
|
250
|
+
],
|
|
251
|
+
allowed_scopes=["openid", "profile", "email"],
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
def test_oauth_configure_auto_apply_uses_environment_public_base(self) -> None:
|
|
255
|
+
app = {
|
|
256
|
+
"id": "app-2",
|
|
257
|
+
"appCode": "project-project-1",
|
|
258
|
+
"clientId": "client-2",
|
|
259
|
+
}
|
|
260
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
261
|
+
with mock.patch.dict(
|
|
262
|
+
os.environ,
|
|
263
|
+
{"LICOS_PROJECT_PATH": tmp, "LICOS_OAUTH_PUBLIC_BASE_URL": "https://platform.example/api/v1"},
|
|
264
|
+
clear=False,
|
|
265
|
+
):
|
|
266
|
+
with mock.patch.object(oauth_commands.oauth, "ensure_project_app", return_value=app) as ensure:
|
|
267
|
+
code, stdout, stderr = run_cli(
|
|
268
|
+
"oauth",
|
|
269
|
+
"configure-project",
|
|
270
|
+
"--auto-apply",
|
|
271
|
+
"--name",
|
|
272
|
+
"Project login",
|
|
273
|
+
"--login-audience",
|
|
274
|
+
"ALL",
|
|
275
|
+
"--redirect",
|
|
276
|
+
"https://app.example/auth/callback",
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
self.assertEqual(code, 0)
|
|
280
|
+
self.assertEqual(stderr, "")
|
|
281
|
+
config = json.loads((Path(tmp) / "config" / "licos-oauth.json").read_text(encoding="utf-8"))
|
|
282
|
+
self.assertEqual(config["publicBaseUrl"], "https://platform.example")
|
|
283
|
+
self.assertEqual(config["redirectUriConfigs"][0]["environment"], "prod")
|
|
284
|
+
self.assertIsNone(ensure.call_args.kwargs["app_id"])
|
|
285
|
+
self.assertEqual(json.loads(stdout)["selectionMode"], "auto-apply")
|
|
286
|
+
|
|
287
|
+
def test_oauth_configure_auto_generates_stable_project_callbacks(self) -> None:
|
|
288
|
+
app = {
|
|
289
|
+
"id": "app-3",
|
|
290
|
+
"appCode": "project-project-1",
|
|
291
|
+
"clientId": "client-3",
|
|
292
|
+
}
|
|
293
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
294
|
+
env = {
|
|
295
|
+
"LICOS_PROJECT_PATH": tmp,
|
|
296
|
+
"AGENT_PROJECT_ID": "project/1",
|
|
297
|
+
"LICOS_PLATFORM_PUBLIC_BASE_URL": "https://platform.example",
|
|
298
|
+
"LICOS_DEPLOY_PUBLIC_BASE_URL": "https://deploy.example",
|
|
299
|
+
}
|
|
300
|
+
with mock.patch.dict(os.environ, env, clear=False):
|
|
301
|
+
with mock.patch.object(oauth_commands.oauth, "ensure_project_app", return_value=app) as ensure:
|
|
302
|
+
code, stdout, stderr = run_cli(
|
|
303
|
+
"oauth",
|
|
304
|
+
"configure-project",
|
|
305
|
+
"--auto-apply",
|
|
306
|
+
"--name",
|
|
307
|
+
"Project login",
|
|
308
|
+
"--login-audience",
|
|
309
|
+
"ALL",
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
self.assertEqual(code, 0)
|
|
313
|
+
self.assertEqual(stderr, "")
|
|
314
|
+
redirects = ensure.call_args.kwargs["redirect_uri_configs"]
|
|
315
|
+
self.assertEqual(
|
|
316
|
+
redirects,
|
|
317
|
+
[
|
|
318
|
+
{
|
|
319
|
+
"environment": "dev",
|
|
320
|
+
"redirectUri": (
|
|
321
|
+
"https://platform.example/agent/api/v1/projects/"
|
|
322
|
+
"project%2F1/oauth/callback"
|
|
323
|
+
),
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
"environment": "prod",
|
|
327
|
+
"redirectUri": (
|
|
328
|
+
"https://deploy.example/p/project%2F1/api/auth/licos/callback"
|
|
329
|
+
),
|
|
330
|
+
},
|
|
331
|
+
],
|
|
332
|
+
)
|
|
333
|
+
config = json.loads((Path(tmp) / "config" / "licos-oauth.json").read_text(encoding="utf-8"))
|
|
334
|
+
self.assertEqual(config["redirectUriConfigs"], redirects)
|
|
335
|
+
self.assertEqual(config["publicBaseUrl"], "https://platform.example")
|
|
336
|
+
self.assertEqual(json.loads(stdout)["selectionMode"], "auto-apply")
|
|
337
|
+
|
|
338
|
+
def test_oauth_configure_without_redirect_requires_stable_project_metadata(self) -> None:
|
|
339
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
340
|
+
env = {
|
|
341
|
+
"LICOS_PROJECT_PATH": tmp,
|
|
342
|
+
"LICOS_PLATFORM_PUBLIC_BASE_URL": "https://platform.example",
|
|
343
|
+
"LICOS_DEPLOY_PUBLIC_BASE_URL": "https://deploy.example",
|
|
344
|
+
"AGENT_PROJECT_ID": "",
|
|
345
|
+
}
|
|
346
|
+
with mock.patch.dict(os.environ, env, clear=False):
|
|
347
|
+
with mock.patch.object(oauth_commands.oauth, "ensure_project_app") as ensure:
|
|
348
|
+
code, stdout, stderr = run_cli(
|
|
349
|
+
"oauth",
|
|
350
|
+
"configure-project",
|
|
351
|
+
"--auto-apply",
|
|
352
|
+
"--name",
|
|
353
|
+
"Project login",
|
|
354
|
+
"--login-audience",
|
|
355
|
+
"ALL",
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
self.assertEqual(code, 2)
|
|
359
|
+
self.assertEqual(stdout, "")
|
|
360
|
+
self.assertIn("AGENT_PROJECT_ID", stderr)
|
|
361
|
+
ensure.assert_not_called()
|
|
362
|
+
|
|
363
|
+
def test_oauth_invalid_output_path_does_not_mutate_platform(self) -> None:
|
|
364
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
365
|
+
outside = str(Path(tmp).parent / "outside.json")
|
|
366
|
+
with mock.patch.dict(os.environ, {"LICOS_PROJECT_PATH": tmp}, clear=False):
|
|
367
|
+
with mock.patch.object(oauth_commands.oauth, "ensure_project_app") as ensure:
|
|
368
|
+
code, stdout, stderr = run_cli(
|
|
369
|
+
"oauth",
|
|
370
|
+
"configure-project",
|
|
371
|
+
"--auto-apply",
|
|
372
|
+
"--name",
|
|
373
|
+
"Project login",
|
|
374
|
+
"--login-audience",
|
|
375
|
+
"ALL",
|
|
376
|
+
"--redirect",
|
|
377
|
+
"prod=https://app.example/auth/callback",
|
|
378
|
+
"--public-base-url",
|
|
379
|
+
"https://platform.example",
|
|
380
|
+
"--out",
|
|
381
|
+
outside,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
self.assertEqual(code, 2)
|
|
385
|
+
self.assertEqual(stdout, "")
|
|
386
|
+
self.assertIn("must stay inside", stderr)
|
|
387
|
+
ensure.assert_not_called()
|
|
388
|
+
|
|
151
389
|
def test_database_query_forwards_runtime_options(self) -> None:
|
|
152
390
|
with mock.patch.object(database_commands.database, "query", return_value={"rows": []}) as query:
|
|
153
391
|
code, stdout, stderr = run_cli(
|
|
File without changes
|
{licos_platform_cli-0.2.10 → licos_platform_cli-0.2.13}/src/licos_platform_cli/database_commands.py
RENAMED
|
File without changes
|
{licos_platform_cli-0.2.10 → licos_platform_cli-0.2.13}/src/licos_platform_cli/knowledge_commands.py
RENAMED
|
File without changes
|
{licos_platform_cli-0.2.10 → licos_platform_cli-0.2.13}/src/licos_platform_cli/storage_commands.py
RENAMED
|
File without changes
|