stkit 17.0.0__py3-none-any.whl
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.
- stk/__init__.py +19 -0
- stk/__main__.py +6 -0
- stk/agent_login.py +78 -0
- stk/app.py +323 -0
- stk/checks.py +634 -0
- stk/cli/__init__.py +49 -0
- stk/cli/agent.py +267 -0
- stk/cli/base.py +79 -0
- stk/cli/database.py +199 -0
- stk/cli/develop.py +63 -0
- stk/cli/doctor.py +262 -0
- stk/cli/main.py +101 -0
- stk/cli/reports.py +296 -0
- stk/cli/smoke.py +399 -0
- stk/cli/users.py +259 -0
- stk/cli/watch.py +68 -0
- stk/extensions.py +29 -0
- stk/migrations/__init__.py +130 -0
- stk/migrations/env.py +58 -0
- stk/migrations/script.py.mako +17 -0
- stk/migrations/versions/stk_0001_initial_schema.py +213 -0
- stk/portal/views.py +34 -0
- stk/public/__init__.py +0 -0
- stk/public/models.py +0 -0
- stk/public/views.py +344 -0
- stk/scaffold/__init__.py +1 -0
- stk/scaffold/app/CLAUDE.md +65 -0
- stk/scaffold/app/Dockerfile +41 -0
- stk/scaffold/app/README.md +16 -0
- stk/scaffold/app/__app__/__init__.py +1 -0
- stk/scaffold/app/__app__/app.py +8 -0
- stk/scaffold/app/__app__/settings.py +7 -0
- stk/scaffold/app/claude/skills/stk-api/SKILL.md +52 -0
- stk/scaffold/app/claude/skills/stk-blueprint/SKILL.md +48 -0
- stk/scaffold/app/claude/skills/stk-deploy/SKILL.md +61 -0
- stk/scaffold/app/claude/skills/stk-lessons/SKILL.md +58 -0
- stk/scaffold/app/claude/skills/stk-migrate/SKILL.md +96 -0
- stk/scaffold/app/docker-compose.yml +88 -0
- stk/scaffold/app/dockerignore +13 -0
- stk/scaffold/app/env-sample +78 -0
- stk/scaffold/app/gitignore +8 -0
- stk/scaffold/app/nginx/nginx.conf +46 -0
- stk/scaffold/app/nginx/stk.conf +36 -0
- stk/scaffold/app/postgres/init-db.sh +8 -0
- stk/scaffold/app/pyproject.toml +23 -0
- stk/scaffold/app/tests/__init__.py +0 -0
- stk/scaffold/app/tests/test_app.py +23 -0
- stk/scaffold/generator.py +209 -0
- stk/scaffold/templates.py +437 -0
- stk/security.py +145 -0
- stk/settings.py +147 -0
- stk/shell.py +284 -0
- stk/static/VERSIONS.txt +4 -0
- stk/static/css/app.css +229 -0
- stk/static/css/layout.css +330 -0
- stk/static/css/vuetify.min.css +6 -0
- stk/static/icons/fonts/tabler-icons.woff2 +0 -0
- stk/static/icons/tabler-icons.min.css +4 -0
- stk/static/img/auth-bg.webp +0 -0
- stk/static/img/favicon.ico +0 -0
- stk/static/img/stk.svg +1664 -0
- stk/static/js/axios.min.js +5 -0
- stk/static/js/components/NotificationDropdown.js +156 -0
- stk/static/js/components/ThemeSwitcher.js +68 -0
- stk/static/js/components/TransitionExpand.js +64 -0
- stk/static/js/components/VerticalNavGroup.js +119 -0
- stk/static/js/components/VerticalNavLink.js +36 -0
- stk/static/js/components/VerticalNavSectionTitle.js +21 -0
- stk/static/js/components/index.js +47 -0
- stk/static/js/config.js +150 -0
- stk/static/js/vue.min.js +13 -0
- stk/static/js/vuetify.min.js +3148 -0
- stk/static/robots.txt +1 -0
- stk/tasks/__init__.py +78 -0
- stk/templates/401.html +14 -0
- stk/templates/404.html +12 -0
- stk/templates/500.html +13 -0
- stk/templates/_brand.html +1 -0
- stk/templates/auth_layout.html +26 -0
- stk/templates/cms/activities.html +135 -0
- stk/templates/cms/roles.html +215 -0
- stk/templates/cms/users.html +312 -0
- stk/templates/dashboard.html +132 -0
- stk/templates/index.html +52 -0
- stk/templates/layout.html +328 -0
- stk/templates/login-layout.html +54 -0
- stk/templates/security/_macros.html +39 -0
- stk/templates/security/_messages.html +12 -0
- stk/templates/security/change_password.html +100 -0
- stk/templates/security/confirm_email.html +44 -0
- stk/templates/security/email/change_notice.html +4 -0
- stk/templates/security/email/change_notice.txt +5 -0
- stk/templates/security/email/confirmation_instructions.html +3 -0
- stk/templates/security/email/confirmation_instructions.txt +3 -0
- stk/templates/security/email/login_instructions.html +5 -0
- stk/templates/security/email/login_instructions.txt +5 -0
- stk/templates/security/email/reset_instructions.html +1 -0
- stk/templates/security/email/reset_instructions.txt +3 -0
- stk/templates/security/email/reset_notice.html +1 -0
- stk/templates/security/email/reset_notice.txt +1 -0
- stk/templates/security/email/welcome.html +7 -0
- stk/templates/security/email/welcome.txt +7 -0
- stk/templates/security/forgot_password.html +63 -0
- stk/templates/security/login_user.html +206 -0
- stk/templates/security/mf_recovery.html +67 -0
- stk/templates/security/mf_recovery_codes.html +119 -0
- stk/templates/security/register_user.html +263 -0
- stk/templates/security/reset_password.html +53 -0
- stk/templates/security/send_confirmation.html +48 -0
- stk/templates/security/send_login.html +39 -0
- stk/templates/security/two_factor_select.html +78 -0
- stk/templates/security/two_factor_setup.html +166 -0
- stk/templates/security/two_factor_verify_code.html +71 -0
- stk/templates/security/wan_register.html +179 -0
- stk/templates/security/wan_signin.html +123 -0
- stk/templates/security/wan_verify.html +109 -0
- stk/user/__init__.py +0 -0
- stk/user/forms.py +10 -0
- stk/user/models.py +308 -0
- stk/user/views.py +339 -0
- stk/utils/email.py +32 -0
- stk/utils/ratelimit.py +67 -0
- stk/websocket.py +133 -0
- stkit-17.0.0.dist-info/METADATA +135 -0
- stkit-17.0.0.dist-info/RECORD +128 -0
- stkit-17.0.0.dist-info/WHEEL +4 -0
- stkit-17.0.0.dist-info/entry_points.txt +2 -0
- stkit-17.0.0.dist-info/licenses/LICENSE +21 -0
stk/cli/smoke.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""Real-browser smoke check: boot a throwaway app, drive it with Playwright."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import secrets
|
|
6
|
+
import socket
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from stk.agent_login import create_agent_login_token
|
|
16
|
+
from stk.cli.base import cli_app, run_async
|
|
17
|
+
|
|
18
|
+
SMOKE_EMAIL = "smoke@example.com"
|
|
19
|
+
SMOKE_PASSWORD = "SmokePassword123!"
|
|
20
|
+
SMOKE_SCREENSHOT = Path(".stk/smoke/dashboard.png")
|
|
21
|
+
|
|
22
|
+
# Text the same colour as what it sits on renders invisible without raising a
|
|
23
|
+
# console error, so the browser has to measure it. 3:1 is the WCAG floor for
|
|
24
|
+
# large/bold text; below ~1.5 the element is effectively gone.
|
|
25
|
+
MIN_CONTRAST_RATIO = 3.0
|
|
26
|
+
_CONTRAST_TEMPLATE = """() => {
|
|
27
|
+
const parse = (value) => {
|
|
28
|
+
const parts = value.match(/[\\d.]+/g);
|
|
29
|
+
if (!parts) return null;
|
|
30
|
+
const nums = parts.map(Number);
|
|
31
|
+
const scale = value.startsWith('color(') ? 255 : 1;
|
|
32
|
+
return {r: nums[0] * scale, g: nums[1] * scale, b: nums[2] * scale,
|
|
33
|
+
a: nums.length > 3 ? nums[3] : 1};
|
|
34
|
+
};
|
|
35
|
+
const luminance = ({r, g, b}) => {
|
|
36
|
+
const channel = (v) => {
|
|
37
|
+
v /= 255;
|
|
38
|
+
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
|
39
|
+
};
|
|
40
|
+
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
41
|
+
};
|
|
42
|
+
const flatten = (fg, bg) => ({
|
|
43
|
+
r: fg.r * fg.a + bg.r * (1 - fg.a),
|
|
44
|
+
g: fg.g * fg.a + bg.g * (1 - fg.a),
|
|
45
|
+
b: fg.b * fg.a + bg.b * (1 - fg.a),
|
|
46
|
+
a: 1,
|
|
47
|
+
});
|
|
48
|
+
const backdrop = (element) => {
|
|
49
|
+
for (let node = element; node; node = node.parentElement) {
|
|
50
|
+
const background = parse(getComputedStyle(node).backgroundColor);
|
|
51
|
+
if (background && background.a > 0.5) return background;
|
|
52
|
+
}
|
|
53
|
+
return {r: 255, g: 255, b: 255, a: 1};
|
|
54
|
+
};
|
|
55
|
+
const findings = [];
|
|
56
|
+
for (const element of document.querySelectorAll('.v-btn, .v-chip, .v-alert')) {
|
|
57
|
+
if (!element.offsetParent || !element.innerText.trim()) continue;
|
|
58
|
+
const foreground = parse(getComputedStyle(element).color);
|
|
59
|
+
if (!foreground) continue;
|
|
60
|
+
const background = backdrop(element);
|
|
61
|
+
const text = flatten(foreground, background);
|
|
62
|
+
const [a, b] = [luminance(text), luminance(background)];
|
|
63
|
+
const ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|
64
|
+
if (ratio < __MIN_RATIO__) {
|
|
65
|
+
findings.push({label: element.innerText.trim().slice(0, 32),
|
|
66
|
+
ratio: Math.round(ratio * 100) / 100});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return findings;
|
|
70
|
+
}"""
|
|
71
|
+
CONTRAST_JS = _CONTRAST_TEMPLATE.replace("__MIN_RATIO__", str(MIN_CONTRAST_RATIO))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
FIXED_SMOKE_PATHS = ("/", "/login", "/dashboard/", "/users/")
|
|
75
|
+
# Prefixes that are not pages, or whose GET starts an auth flow or redirect
|
|
76
|
+
# (OAuth logins, agent login).
|
|
77
|
+
_SKIP_PREFIXES = ("/api/", "/static", "/_test", "/login", "/logout")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def machine_paths(app):
|
|
81
|
+
"""Paths a browser never opens as a page: WebSockets and @csrf_exempt webhooks."""
|
|
82
|
+
return {
|
|
83
|
+
rule.rule
|
|
84
|
+
for rule in app.url_map.iter_rules()
|
|
85
|
+
if rule.websocket
|
|
86
|
+
or getattr(app.view_functions.get(rule.endpoint), "_stk_csrf_exempt", False)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def discover_smoke_paths(routes, skip=frozenset()):
|
|
91
|
+
"""Pick parameterless GET pages from build_routes_report that smoke should visit."""
|
|
92
|
+
return sorted(
|
|
93
|
+
route["rule"]
|
|
94
|
+
for route in routes
|
|
95
|
+
if "GET" in route["methods"]
|
|
96
|
+
and not route["arguments"]
|
|
97
|
+
and "<" not in route["rule"]
|
|
98
|
+
and route["blueprint"] != "security"
|
|
99
|
+
and not route["rule"].startswith(_SKIP_PREFIXES)
|
|
100
|
+
and route["rule"] not in FIXED_SMOKE_PATHS
|
|
101
|
+
and route["rule"] not in skip
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def build_smoke_report(pages, dashboard_screenshot):
|
|
106
|
+
"""Return a browser smoke report with per-page failure reasons."""
|
|
107
|
+
page_reports = []
|
|
108
|
+
for page in pages:
|
|
109
|
+
problems = []
|
|
110
|
+
status_code = page.get("status")
|
|
111
|
+
if status_code is None:
|
|
112
|
+
problems.append("no document response")
|
|
113
|
+
elif status_code >= 400:
|
|
114
|
+
problems.append(f"HTTP {status_code}")
|
|
115
|
+
|
|
116
|
+
for entry in page.get("console", []):
|
|
117
|
+
if entry["type"] == "error":
|
|
118
|
+
problems.append(f"console error: {entry['text']}")
|
|
119
|
+
elif entry["type"] == "pageerror":
|
|
120
|
+
problems.append(f"uncaught error: {entry['text']}")
|
|
121
|
+
|
|
122
|
+
for request in page.get("failed_requests", []):
|
|
123
|
+
failure = request.get("failure") or "unknown"
|
|
124
|
+
problems.append(f"request failed: {request['url']} {failure}")
|
|
125
|
+
|
|
126
|
+
for finding in page.get("low_contrast", []):
|
|
127
|
+
problems.append(
|
|
128
|
+
f'invisible text: "{finding["label"]}" contrast {finding["ratio"]}:1'
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
page_reports.append(
|
|
132
|
+
{
|
|
133
|
+
"name": page["name"],
|
|
134
|
+
"path": page["path"],
|
|
135
|
+
"status_code": status_code,
|
|
136
|
+
"status": "failed" if problems else "passed",
|
|
137
|
+
"console": page.get("console", []),
|
|
138
|
+
"failed_requests": page.get("failed_requests", []),
|
|
139
|
+
"low_contrast": page.get("low_contrast", []),
|
|
140
|
+
"problems": problems,
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
status = (
|
|
145
|
+
"passed"
|
|
146
|
+
if all(page["status"] == "passed" for page in page_reports)
|
|
147
|
+
else "failed"
|
|
148
|
+
)
|
|
149
|
+
return {
|
|
150
|
+
"status": status,
|
|
151
|
+
"dashboard_screenshot": str(dashboard_screenshot),
|
|
152
|
+
"pages": page_reports,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def smoke_exit_code(report):
|
|
157
|
+
return 0 if report["status"] == "passed" else 1
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _free_localhost_port():
|
|
161
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
162
|
+
sock.bind(("127.0.0.1", 0))
|
|
163
|
+
return sock.getsockname()[1]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _smoke_env(database_uri):
|
|
167
|
+
env = os.environ.copy()
|
|
168
|
+
env.update(
|
|
169
|
+
{
|
|
170
|
+
"SECRET_KEY": secrets.token_urlsafe(32),
|
|
171
|
+
"SECURITY_PASSWORD_SALT": secrets.token_urlsafe(32),
|
|
172
|
+
"SQLALCHEMY_DATABASE_URI": database_uri,
|
|
173
|
+
"STK_ENV": "development",
|
|
174
|
+
"STK_ENABLE_AGENT_LOGIN": "true",
|
|
175
|
+
"SESSION_COOKIE_SECURE": "False",
|
|
176
|
+
}
|
|
177
|
+
)
|
|
178
|
+
env.pop("REDIS_URL", None)
|
|
179
|
+
env.pop("REDIS_SESSION", None)
|
|
180
|
+
return env
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _run_smoke_setup(env):
|
|
184
|
+
commands = [
|
|
185
|
+
[sys.executable, "-m", "stk", "db", "upgrade"],
|
|
186
|
+
[
|
|
187
|
+
sys.executable,
|
|
188
|
+
"-m",
|
|
189
|
+
"stk",
|
|
190
|
+
"install",
|
|
191
|
+
"--email",
|
|
192
|
+
SMOKE_EMAIL,
|
|
193
|
+
"--password",
|
|
194
|
+
SMOKE_PASSWORD,
|
|
195
|
+
],
|
|
196
|
+
]
|
|
197
|
+
for command_args in commands:
|
|
198
|
+
result = subprocess.run(
|
|
199
|
+
command_args,
|
|
200
|
+
capture_output=True,
|
|
201
|
+
text=True,
|
|
202
|
+
check=False,
|
|
203
|
+
env=env,
|
|
204
|
+
)
|
|
205
|
+
if result.returncode != 0:
|
|
206
|
+
raise click.ClickException(
|
|
207
|
+
"smoke setup failed for "
|
|
208
|
+
f"{' '.join(command_args)}\n{result.stderr.strip()}"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _prepare_smoke(app, secret_key):
|
|
213
|
+
"""Return an agent login token and the extra page paths to visit."""
|
|
214
|
+
from stk.cli.reports import build_routes_report
|
|
215
|
+
|
|
216
|
+
async def _create_token():
|
|
217
|
+
async with app.app_context():
|
|
218
|
+
# Signed with the key the smoke server runs with, not the app's own.
|
|
219
|
+
app.config["SECRET_KEY"] = secret_key
|
|
220
|
+
token = create_agent_login_token(SMOKE_EMAIL, "/dashboard/")
|
|
221
|
+
skip = machine_paths(app) | set(app.config.get("STK_SMOKE_SKIP", ()))
|
|
222
|
+
paths = discover_smoke_paths(build_routes_report(app), skip)
|
|
223
|
+
return token, paths
|
|
224
|
+
|
|
225
|
+
return run_async(_create_token())
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _start_smoke_server(port, env):
|
|
229
|
+
return subprocess.Popen(
|
|
230
|
+
[
|
|
231
|
+
sys.executable,
|
|
232
|
+
"-m",
|
|
233
|
+
"stk",
|
|
234
|
+
"run",
|
|
235
|
+
"--host",
|
|
236
|
+
"127.0.0.1",
|
|
237
|
+
"--port",
|
|
238
|
+
str(port),
|
|
239
|
+
"--no-reload",
|
|
240
|
+
],
|
|
241
|
+
env=env,
|
|
242
|
+
stdout=subprocess.PIPE,
|
|
243
|
+
stderr=subprocess.PIPE,
|
|
244
|
+
text=True,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _wait_for_smoke_server(base_url, process):
|
|
249
|
+
deadline = time.monotonic() + 20
|
|
250
|
+
while time.monotonic() < deadline:
|
|
251
|
+
if process.poll() is not None:
|
|
252
|
+
stdout, stderr = process.communicate(timeout=1)
|
|
253
|
+
raise click.ClickException(
|
|
254
|
+
"smoke server exited before accepting requests\n"
|
|
255
|
+
f"{stdout.strip()}\n{stderr.strip()}"
|
|
256
|
+
)
|
|
257
|
+
try:
|
|
258
|
+
response = httpx.get(f"{base_url}/login", timeout=1)
|
|
259
|
+
if response.status_code < 500:
|
|
260
|
+
return
|
|
261
|
+
except httpx.HTTPError:
|
|
262
|
+
pass
|
|
263
|
+
time.sleep(0.2)
|
|
264
|
+
raise click.ClickException("smoke server did not start within 20 seconds")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _stop_smoke_server(process):
|
|
268
|
+
if process.poll() is not None:
|
|
269
|
+
return
|
|
270
|
+
process.terminate()
|
|
271
|
+
try:
|
|
272
|
+
process.communicate(timeout=5)
|
|
273
|
+
except subprocess.TimeoutExpired:
|
|
274
|
+
process.kill()
|
|
275
|
+
process.communicate(timeout=5)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
async def _visit_smoke_page(context, base_url, name, path, screenshot_path=None):
|
|
279
|
+
page = await context.new_page()
|
|
280
|
+
console_entries = []
|
|
281
|
+
failed_requests = []
|
|
282
|
+
|
|
283
|
+
def _record_console(message):
|
|
284
|
+
if message.type in {"error", "warning"}:
|
|
285
|
+
console_entries.append({"type": message.type, "text": message.text})
|
|
286
|
+
|
|
287
|
+
def _record_failed_request(request):
|
|
288
|
+
failure = request.failure
|
|
289
|
+
if callable(failure):
|
|
290
|
+
failure = failure()
|
|
291
|
+
failed_requests.append({"url": request.url, "failure": failure or ""})
|
|
292
|
+
|
|
293
|
+
def _record_page_error(error):
|
|
294
|
+
console_entries.append({"type": "pageerror", "text": str(error)})
|
|
295
|
+
|
|
296
|
+
page.on("console", _record_console)
|
|
297
|
+
page.on("pageerror", _record_page_error)
|
|
298
|
+
page.on("requestfailed", _record_failed_request)
|
|
299
|
+
response = await page.goto(f"{base_url}{path}", wait_until="networkidle")
|
|
300
|
+
low_contrast = await page.evaluate(CONTRAST_JS)
|
|
301
|
+
if screenshot_path is not None:
|
|
302
|
+
screenshot_path.parent.mkdir(parents=True, exist_ok=True)
|
|
303
|
+
await page.screenshot(path=str(screenshot_path), full_page=True)
|
|
304
|
+
await page.close()
|
|
305
|
+
return {
|
|
306
|
+
"name": name,
|
|
307
|
+
"path": path,
|
|
308
|
+
"status": response.status if response else None,
|
|
309
|
+
"console": console_entries,
|
|
310
|
+
"failed_requests": failed_requests,
|
|
311
|
+
"low_contrast": low_contrast,
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
async def _run_playwright_smoke(base_url, token, extra_paths):
|
|
316
|
+
try:
|
|
317
|
+
from playwright.async_api import Error as PlaywrightError
|
|
318
|
+
from playwright.async_api import async_playwright
|
|
319
|
+
except ImportError as exc:
|
|
320
|
+
raise click.ClickException(
|
|
321
|
+
"Playwright is not installed. Run `uv sync --extra dev`."
|
|
322
|
+
) from exc
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
async with async_playwright() as playwright:
|
|
326
|
+
browser = await playwright.chromium.launch()
|
|
327
|
+
context = await browser.new_context()
|
|
328
|
+
pages = [
|
|
329
|
+
await _visit_smoke_page(context, base_url, "home", "/"),
|
|
330
|
+
await _visit_smoke_page(context, base_url, "login", "/login"),
|
|
331
|
+
await _visit_smoke_page(
|
|
332
|
+
context,
|
|
333
|
+
base_url,
|
|
334
|
+
"agent-login",
|
|
335
|
+
f"/_test/login?token={token}",
|
|
336
|
+
),
|
|
337
|
+
await _visit_smoke_page(
|
|
338
|
+
context,
|
|
339
|
+
base_url,
|
|
340
|
+
"dashboard",
|
|
341
|
+
"/dashboard/",
|
|
342
|
+
SMOKE_SCREENSHOT,
|
|
343
|
+
),
|
|
344
|
+
await _visit_smoke_page(context, base_url, "admin-users", "/users/"),
|
|
345
|
+
]
|
|
346
|
+
for path in extra_paths:
|
|
347
|
+
pages.append(await _visit_smoke_page(context, base_url, path, path))
|
|
348
|
+
await context.close()
|
|
349
|
+
await browser.close()
|
|
350
|
+
except PlaywrightError as exc:
|
|
351
|
+
message = str(exc)
|
|
352
|
+
if "Executable doesn't exist" in message or "playwright install" in message:
|
|
353
|
+
raise click.ClickException(
|
|
354
|
+
"Chromium is not installed. Run `uv run playwright install chromium`."
|
|
355
|
+
) from exc
|
|
356
|
+
raise
|
|
357
|
+
|
|
358
|
+
for page in pages:
|
|
359
|
+
if page["name"] == "agent-login":
|
|
360
|
+
page["path"] = "/_test/login?token=<redacted>"
|
|
361
|
+
return build_smoke_report(pages, SMOKE_SCREENSHOT)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def run_smoke():
|
|
365
|
+
"""Boot a throwaway app on a free port and drive it with a real browser."""
|
|
366
|
+
from stk.migrations import throwaway_database_url
|
|
367
|
+
|
|
368
|
+
app = cli_app()
|
|
369
|
+
with throwaway_database_url(app.config["SQLALCHEMY_DATABASE_URI"]) as database_uri:
|
|
370
|
+
env = _smoke_env(database_uri)
|
|
371
|
+
_run_smoke_setup(env)
|
|
372
|
+
|
|
373
|
+
token, extra_paths = _prepare_smoke(app, env["SECRET_KEY"])
|
|
374
|
+
port = _free_localhost_port()
|
|
375
|
+
base_url = f"http://127.0.0.1:{port}"
|
|
376
|
+
server = _start_smoke_server(port, env)
|
|
377
|
+
try:
|
|
378
|
+
_wait_for_smoke_server(base_url, server)
|
|
379
|
+
return asyncio.run(_run_playwright_smoke(base_url, token, extra_paths))
|
|
380
|
+
finally:
|
|
381
|
+
_stop_smoke_server(server)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def print_smoke_report(report):
|
|
385
|
+
click.echo(f"Smoke status: {report['status']}")
|
|
386
|
+
click.echo(f"Dashboard screenshot: {report['dashboard_screenshot']}")
|
|
387
|
+
for page in report["pages"]:
|
|
388
|
+
click.echo(f"{page['status'].upper()} {page['name']} {page['path']}")
|
|
389
|
+
click.echo(f" HTTP: {page['status_code']}")
|
|
390
|
+
for entry in page["console"]:
|
|
391
|
+
click.echo(f" console {entry['type']}: {entry['text']}")
|
|
392
|
+
for finding in page.get("low_contrast", []):
|
|
393
|
+
click.echo(f" invisible text: {finding['label']!r} {finding['ratio']}:1")
|
|
394
|
+
for request in page["failed_requests"]:
|
|
395
|
+
click.echo(
|
|
396
|
+
f" request failed: {request['url']} {request.get('failure') or ''}"
|
|
397
|
+
)
|
|
398
|
+
for problem in page["problems"]:
|
|
399
|
+
click.echo(f" problem: {problem}")
|
stk/cli/users.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""User, role, and session commands."""
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
import string
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from email_validator import EmailNotValidError, validate_email
|
|
10
|
+
from quart_security import hash_password
|
|
11
|
+
from sqlalchemy import select
|
|
12
|
+
|
|
13
|
+
import stk.extensions as ext
|
|
14
|
+
from stk.agent_login import create_agent_login_token
|
|
15
|
+
from stk.cli.base import cli_app, console, run_async
|
|
16
|
+
from stk.user.models import User
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _check_email(email):
|
|
20
|
+
"""Reject addresses the login form would refuse, such as reserved domains."""
|
|
21
|
+
try:
|
|
22
|
+
validate_email(email, check_deliverability=False)
|
|
23
|
+
except EmailNotValidError as exc:
|
|
24
|
+
raise click.BadParameter(str(exc), param_hint="email") from exc
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@click.group("browser-token")
|
|
28
|
+
def browser_token():
|
|
29
|
+
"""Create test-only browser login URLs."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@browser_token.command("create")
|
|
33
|
+
@click.option("--user", "email", required=True, help="Test user email.")
|
|
34
|
+
@click.option("--ttl", default=60, type=int, help="Token TTL in seconds.")
|
|
35
|
+
@click.option("--next", "next_path", default="/dashboard/", help="Local redirect path.")
|
|
36
|
+
def browser_token_create(email, ttl, next_path):
|
|
37
|
+
"""Create a signed test-only browser login URL."""
|
|
38
|
+
app = cli_app()
|
|
39
|
+
if ttl > app.config["STK_AGENT_LOGIN_MAX_TTL_SECONDS"]:
|
|
40
|
+
raise click.ClickException("ttl exceeds STK_AGENT_LOGIN_MAX_TTL_SECONDS")
|
|
41
|
+
if not app.config["STK_ENABLE_AGENT_LOGIN"]:
|
|
42
|
+
raise click.ClickException("agent login is disabled")
|
|
43
|
+
|
|
44
|
+
async def _create_token():
|
|
45
|
+
async with app.app_context():
|
|
46
|
+
return create_agent_login_token(email, next_path)
|
|
47
|
+
|
|
48
|
+
token = run_async(_create_token())
|
|
49
|
+
click.echo(f"/_test/login?token={token}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@click.command()
|
|
53
|
+
@click.option("-e", "--email", default=None, help="Admin email")
|
|
54
|
+
@click.option("-p", "--password", default=None, help="Admin password")
|
|
55
|
+
def install(email, password):
|
|
56
|
+
"""Install a default admin user and add an admin role to it."""
|
|
57
|
+
app = cli_app()
|
|
58
|
+
|
|
59
|
+
async def _run():
|
|
60
|
+
from stk.user.models import Role
|
|
61
|
+
|
|
62
|
+
async with ext.async_session_factory() as session:
|
|
63
|
+
admin_role = (
|
|
64
|
+
await session.execute(select(Role).where(Role.name == "admin"))
|
|
65
|
+
).scalar_one_or_none()
|
|
66
|
+
if not admin_role:
|
|
67
|
+
admin_role = Role(name="admin")
|
|
68
|
+
session.add(admin_role)
|
|
69
|
+
await session.commit()
|
|
70
|
+
|
|
71
|
+
admin_user = (
|
|
72
|
+
await session.execute(
|
|
73
|
+
select(User).where(User.roles.any(Role.name == "admin"))
|
|
74
|
+
)
|
|
75
|
+
).scalar_one_or_none()
|
|
76
|
+
if admin_user:
|
|
77
|
+
console.print(
|
|
78
|
+
f"[yellow]An admin user already exists:[/] [blue]{admin_user.email}[/]"
|
|
79
|
+
)
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
nonlocal email, password
|
|
83
|
+
if not email:
|
|
84
|
+
email = click.prompt("Admin email", default="admin@example.com")
|
|
85
|
+
_check_email(email)
|
|
86
|
+
|
|
87
|
+
generated = False
|
|
88
|
+
if not password:
|
|
89
|
+
password = "".join(
|
|
90
|
+
secrets.choice(string.ascii_letters + string.digits + "@#$%^&*")
|
|
91
|
+
for _ in range(32)
|
|
92
|
+
)
|
|
93
|
+
generated = True
|
|
94
|
+
|
|
95
|
+
user = User(
|
|
96
|
+
email=email,
|
|
97
|
+
name="Super Admin",
|
|
98
|
+
password=hash_password(password, app=app),
|
|
99
|
+
active=True,
|
|
100
|
+
confirmed_at=datetime.now(),
|
|
101
|
+
)
|
|
102
|
+
user.roles.append(admin_role)
|
|
103
|
+
session.add(user)
|
|
104
|
+
await session.commit()
|
|
105
|
+
|
|
106
|
+
console.print("\n[green]✓[/] Admin user created successfully!")
|
|
107
|
+
console.print(f"[blue]Email:[/] {email}")
|
|
108
|
+
if generated:
|
|
109
|
+
console.print(f"[blue]Password:[/] [red]{password}[/]")
|
|
110
|
+
console.print(
|
|
111
|
+
"\n[yellow]⚠️ Please save this password securely - you will not see it again![/]"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
run_async(_run())
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@click.command()
|
|
118
|
+
@click.option("-e", "--email", prompt=True, default=None)
|
|
119
|
+
@click.option("-p", "--password", prompt=True, default=None)
|
|
120
|
+
def create(email, password):
|
|
121
|
+
"""Creates a user using an email."""
|
|
122
|
+
_check_email(email)
|
|
123
|
+
app = cli_app()
|
|
124
|
+
|
|
125
|
+
async def _run():
|
|
126
|
+
async with ext.async_session_factory() as session:
|
|
127
|
+
existing = (
|
|
128
|
+
await session.execute(select(User).where(User.email == email))
|
|
129
|
+
).scalar_one_or_none()
|
|
130
|
+
if existing is not None:
|
|
131
|
+
console.print("[yellow]User already exists![/]")
|
|
132
|
+
else:
|
|
133
|
+
user = User(
|
|
134
|
+
email=email,
|
|
135
|
+
password=hash_password(password, app=app),
|
|
136
|
+
active=True,
|
|
137
|
+
confirmed_at=datetime.now(),
|
|
138
|
+
)
|
|
139
|
+
session.add(user)
|
|
140
|
+
await session.commit()
|
|
141
|
+
|
|
142
|
+
run_async(_run())
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@click.command()
|
|
146
|
+
@click.option("-e", "--email", prompt=True, default=None)
|
|
147
|
+
@click.option("-r", "--role", prompt=True, default="admin")
|
|
148
|
+
def add_role(email, role):
|
|
149
|
+
"""Adds a role to the specified user."""
|
|
150
|
+
|
|
151
|
+
async def _run():
|
|
152
|
+
from stk.user.models import Role
|
|
153
|
+
|
|
154
|
+
async with ext.async_session_factory() as session:
|
|
155
|
+
u = (
|
|
156
|
+
await session.execute(select(User).where(User.email == email))
|
|
157
|
+
).scalar_one_or_none()
|
|
158
|
+
|
|
159
|
+
if u is None:
|
|
160
|
+
console.print("[red]Sorry, this user does not exist![/]")
|
|
161
|
+
else:
|
|
162
|
+
r = (
|
|
163
|
+
await session.execute(select(Role).where(Role.name == role))
|
|
164
|
+
).scalar_one_or_none()
|
|
165
|
+
if r is None:
|
|
166
|
+
console.print("[yellow]Sorry, this role does not exist![/]")
|
|
167
|
+
answer = click.prompt(
|
|
168
|
+
"Would you like to create one? Y/N", default="N"
|
|
169
|
+
)
|
|
170
|
+
if answer.lower() == "y":
|
|
171
|
+
r = Role(name=role)
|
|
172
|
+
try:
|
|
173
|
+
session.add(r)
|
|
174
|
+
await session.commit()
|
|
175
|
+
console.print(
|
|
176
|
+
"[green]Role created successfully, you may add it now to the user[/]"
|
|
177
|
+
)
|
|
178
|
+
except Exception:
|
|
179
|
+
await session.rollback()
|
|
180
|
+
if r:
|
|
181
|
+
u.roles.append(r)
|
|
182
|
+
await session.commit()
|
|
183
|
+
|
|
184
|
+
run_async(_run())
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@click.command()
|
|
188
|
+
@click.option("-e", "--email", prompt="Email", default=None)
|
|
189
|
+
@click.option("-p", "--password", hide_input=True, prompt=True, default=None)
|
|
190
|
+
def reset(email, password):
|
|
191
|
+
"""Reset a user password using email"""
|
|
192
|
+
app = cli_app()
|
|
193
|
+
try:
|
|
194
|
+
pwd = hash_password(password, app=app)
|
|
195
|
+
|
|
196
|
+
async def _run():
|
|
197
|
+
async with ext.async_session_factory() as session:
|
|
198
|
+
u = (
|
|
199
|
+
await session.execute(select(User).where(User.email == email))
|
|
200
|
+
).scalar_one_or_none()
|
|
201
|
+
if not u:
|
|
202
|
+
console.print(f'[red]User with email "{email}" not found.[/]')
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
u.password = pwd
|
|
206
|
+
u.password_set = True
|
|
207
|
+
u.fs_uniquifier = uuid4().hex
|
|
208
|
+
try:
|
|
209
|
+
await session.commit()
|
|
210
|
+
console.print(
|
|
211
|
+
"[green]User password has been reset successfully.[/]"
|
|
212
|
+
)
|
|
213
|
+
except Exception:
|
|
214
|
+
await session.rollback()
|
|
215
|
+
console.print("[red]Error committing to database.[/]")
|
|
216
|
+
|
|
217
|
+
run_async(_run())
|
|
218
|
+
except Exception as e:
|
|
219
|
+
console.print(f"[red]Error resetting user password: {e}[/]")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@click.command("cleanup-sessions")
|
|
223
|
+
def cleanup_sessions():
|
|
224
|
+
"""Deactivate expired sessions and delete old rows."""
|
|
225
|
+
from stk.tasks import cleanup_expired_sessions
|
|
226
|
+
|
|
227
|
+
run_async(cleanup_expired_sessions())
|
|
228
|
+
console.print("[green]Session cleanup complete[/]")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@click.command("protect-mfa")
|
|
232
|
+
def protect_mfa():
|
|
233
|
+
"""Encrypt legacy MFA seeds or rotate them to the current key."""
|
|
234
|
+
from quart_security.totp import encrypt_totp_secret
|
|
235
|
+
from sqlalchemy import update
|
|
236
|
+
|
|
237
|
+
app = cli_app()
|
|
238
|
+
|
|
239
|
+
async def _run():
|
|
240
|
+
count = 0
|
|
241
|
+
async with ext.async_session_factory() as database:
|
|
242
|
+
rows = await database.stream(
|
|
243
|
+
select(User.id, User.tf_totp_secret)
|
|
244
|
+
.where(User.tf_totp_secret.is_not(None))
|
|
245
|
+
.execution_options(yield_per=100)
|
|
246
|
+
)
|
|
247
|
+
async for user_id, secret in rows:
|
|
248
|
+
encrypted = encrypt_totp_secret(secret, app=app)
|
|
249
|
+
await database.execute(
|
|
250
|
+
update(User)
|
|
251
|
+
.where(User.id == user_id)
|
|
252
|
+
.values(tf_totp_secret=encrypted)
|
|
253
|
+
)
|
|
254
|
+
count += 1
|
|
255
|
+
await database.commit()
|
|
256
|
+
return count
|
|
257
|
+
|
|
258
|
+
count = run_async(_run())
|
|
259
|
+
console.print(f"[green]Protected {count} MFA seeds[/]")
|