qasight 1.6.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.
- qasight/__init__.py +83 -0
- qasight/accounts.py +272 -0
- qasight/cli/__init__.py +5 -0
- qasight/cli/app.py +359 -0
- qasight/cli/cleanup.py +68 -0
- qasight/cli/commands/__init__.py +6 -0
- qasight/cli/commands/affected.py +114 -0
- qasight/cli/commands/ai.py +123 -0
- qasight/cli/commands/backup.py +86 -0
- qasight/cli/commands/badge.py +47 -0
- qasight/cli/commands/clean.py +264 -0
- qasight/cli/commands/config.py +147 -0
- qasight/cli/commands/config_db.py +137 -0
- qasight/cli/commands/demo.py +209 -0
- qasight/cli/commands/diff_cli.py +112 -0
- qasight/cli/commands/doctor.py +221 -0
- qasight/cli/commands/export_cmd.py +65 -0
- qasight/cli/commands/export_site.py +83 -0
- qasight/cli/commands/flaky.py +165 -0
- qasight/cli/commands/ingest.py +169 -0
- qasight/cli/commands/init_cmd.py +499 -0
- qasight/cli/commands/issues.py +76 -0
- qasight/cli/commands/lease.py +436 -0
- qasight/cli/commands/mcp_cmd.py +133 -0
- qasight/cli/commands/notify.py +244 -0
- qasight/cli/commands/otel.py +50 -0
- qasight/cli/commands/plans.py +92 -0
- qasight/cli/commands/plugins.py +112 -0
- qasight/cli/commands/pool.py +353 -0
- qasight/cli/commands/quarantine.py +212 -0
- qasight/cli/commands/report.py +410 -0
- qasight/cli/commands/report_archive.py +172 -0
- qasight/cli/commands/run.py +160 -0
- qasight/cli/commands/server_cmd.py +58 -0
- qasight/cli/commands/split.py +158 -0
- qasight/cli/commands/try_cmd.py +239 -0
- qasight/cli/commands/ui_cmd.py +156 -0
- qasight/cli/commands/users.py +150 -0
- qasight/cli/commands/version.py +18 -0
- qasight/cli/commands/watch.py +135 -0
- qasight/cli/cycle_hint.py +30 -0
- qasight/cli/missing_module.py +60 -0
- qasight/cli/runner.py +462 -0
- qasight/cli/server_url.py +23 -0
- qasight/cli/ui/__init__.py +5 -0
- qasight/cli/ui/ci_renderer.py +19 -0
- qasight/cli/ui/console.py +65 -0
- qasight/cli/ui/panels.py +152 -0
- qasight/cli/ui/progress.py +29 -0
- qasight/cli/ui/renderers.py +187 -0
- qasight/cli/ui/summary_dashboard.py +345 -0
- qasight/config/__init__.py +29 -0
- qasight/config/affected.py +122 -0
- qasight/config/database_section.py +161 -0
- qasight/config/errors.py +38 -0
- qasight/config/history_section.py +157 -0
- qasight/config/if_expr.py +69 -0
- qasight/config/json_schema.py +462 -0
- qasight/config/loader.py +269 -0
- qasight/config/resolver.py +89 -0
- qasight/config/schema.py +178 -0
- qasight/config/sections.py +463 -0
- qasight/config/triggers.py +294 -0
- qasight/config/unknown_keys.py +97 -0
- qasight/engine/__init__.py +30 -0
- qasight/engine/backends/__init__.py +128 -0
- qasight/engine/backends/container.py +194 -0
- qasight/engine/backends/runner_service.py +502 -0
- qasight/engine/crash_signatures.py +230 -0
- qasight/engine/cycle_lock.py +197 -0
- qasight/engine/events.py +46 -0
- qasight/engine/executor.py +638 -0
- qasight/engine/exit_codes.py +48 -0
- qasight/engine/interrupt.py +188 -0
- qasight/engine/log_buffer.py +151 -0
- qasight/engine/orchestrator.py +459 -0
- qasight/engine/quarantine.py +81 -0
- qasight/engine/result.py +61 -0
- qasight/engine/stage_resources.py +136 -0
- qasight/frameworks/__init__.py +21 -0
- qasight/frameworks/base.py +95 -0
- qasight/frameworks/behave_adapter.py +49 -0
- qasight/frameworks/behavex_adapter.py +74 -0
- qasight/frameworks/command_adapter.py +41 -0
- qasight/frameworks/cypress_adapter.py +55 -0
- qasight/frameworks/jest_adapter.py +97 -0
- qasight/frameworks/junit_adapter.py +40 -0
- qasight/frameworks/playwright_adapter.py +74 -0
- qasight/frameworks/pytest_adapter.py +152 -0
- qasight/history/__init__.py +16 -0
- qasight/history/backup.py +291 -0
- qasight/history/cases.py +36 -0
- qasight/history/maintenance.py +118 -0
- qasight/history/read_model.py +95 -0
- qasight/history/report_links.py +45 -0
- qasight/history/retention.py +228 -0
- qasight/history/snapshots.py +52 -0
- qasight/history/storage.py +183 -0
- qasight/history/views.py +164 -0
- qasight/mcp_server.py +485 -0
- qasight/paths.py +166 -0
- qasight/persistence/__init__.py +23 -0
- qasight/persistence/backend.py +21 -0
- qasight/persistence/composite.py +50 -0
- qasight/persistence/db_backend.py +216 -0
- qasight/persistence/failure_context.py +120 -0
- qasight/persistence/health.py +109 -0
- qasight/persistence/json_backend.py +68 -0
- qasight/plugins.py +157 -0
- qasight/pytest_plugin.py +716 -0
- qasight/reporting/__init__.py +11 -0
- qasight/reporting/allure.py +84 -0
- qasight/reporting/allure_cases.py +216 -0
- qasight/reporting/allure_cli.py +213 -0
- qasight/reporting/allure_passthrough.py +104 -0
- qasight/reporting/allure_results.py +235 -0
- qasight/reporting/behavex_allure.py +63 -0
- qasight/reporting/collector.py +144 -0
- qasight/reporting/coverage_import.py +168 -0
- qasight/reporting/entry.py +130 -0
- qasight/reporting/exporter.py +151 -0
- qasight/reporting/gotest_import.py +223 -0
- qasight/reporting/history_inject.py +76 -0
- qasight/reporting/imported_results.py +139 -0
- qasight/reporting/integrations.py +232 -0
- qasight/reporting/jest_import.py +219 -0
- qasight/reporting/junit_import.py +520 -0
- qasight/reporting/k6_import.py +134 -0
- qasight/reporting/metrics.py +191 -0
- qasight/reporting/metrics_extractor.py +187 -0
- qasight/reporting/native_reports.py +277 -0
- qasight/reporting/paths.py +78 -0
- qasight/reporting/playwright_import.py +406 -0
- qasight/reporting/pyramid_data.py +33 -0
- qasight/reporting/pyramid_viz.py +128 -0
- qasight/reporting/report_generator.py +332 -0
- qasight/reporting/reporters/__init__.py +14 -0
- qasight/reporting/reporters/alert_reporter.py +85 -0
- qasight/reporting/reporters/allure_reporter.py +137 -0
- qasight/reporting/reporters/base.py +63 -0
- qasight/reporting/reporters/extent_builder.py +106 -0
- qasight/reporting/reporters/extent_reporter.py +61 -0
- qasight/reporting/reporters/factory.py +151 -0
- qasight/reporting/reporters/orchestrate.py +95 -0
- qasight/reporting/reporters/otel_reporter.py +66 -0
- qasight/reporting/reporters/reportportal_client.py +367 -0
- qasight/reporting/reporters/reportportal_reporter.py +97 -0
- qasight/reporting/reporters/templates/extent_dashboard.html.j2 +244 -0
- qasight/reporting/reporters/testbeats_payloads.py +197 -0
- qasight/reporting/reporters/testbeats_reporter.py +115 -0
- qasight/reporting/robot_import.py +128 -0
- qasight/reporting/rspec_import.py +114 -0
- qasight/reporting/server.py +140 -0
- qasight/reporting/trx_import.py +170 -0
- qasight/reporting/xcresult_import.py +133 -0
- qasight/repository/__init__.py +15 -0
- qasight/repository/adapters.py +226 -0
- qasight/repository/base.py +94 -0
- qasight/repository/db.py +40 -0
- qasight/repository/db_config.py +139 -0
- qasight/repository/factory.py +115 -0
- qasight/repository/migrations/__init__.py +134 -0
- qasight/repository/migrations/env.py +41 -0
- qasight/repository/migrations/script.py.mako +26 -0
- qasight/repository/migrations/versions/0001_baseline.py +212 -0
- qasight/repository/migrations/versions/0002_promote_run_columns.py +96 -0
- qasight/repository/migrations/versions/0003_test_results.py +49 -0
- qasight/repository/migrations/versions/0004_test_result_crash.py +38 -0
- qasight/repository/migrations/versions/0005_quarantine_and_retried.py +53 -0
- qasight/repository/migrations/versions/0006_test_labels.py +45 -0
- qasight/repository/models.py +380 -0
- qasight/repository/report_archive_repository.py +119 -0
- qasight/repository/test_results.py +105 -0
- qasight/resources/__init__.py +6 -0
- qasight/resources/client.py +583 -0
- qasight/resources/demo.py +151 -0
- qasight/resources/pools.py +430 -0
- qasight/resources/provider.py +312 -0
- qasight/resources/store.py +557 -0
- qasight/security/__init__.py +15 -0
- qasight/security/redaction.py +189 -0
- qasight/services/__init__.py +86 -0
- qasight/services/affected_tests.py +390 -0
- qasight/services/ai/__init__.py +40 -0
- qasight/services/ai/cli_settings.py +121 -0
- qasight/services/ai/config.py +88 -0
- qasight/services/ai/factory.py +28 -0
- qasight/services/ai/integration_settings.py +147 -0
- qasight/services/ai/provider_base.py +72 -0
- qasight/services/ai/providers/__init__.py +5 -0
- qasight/services/ai/providers/anthropic_provider.py +77 -0
- qasight/services/ai/providers/claude_code_provider.py +175 -0
- qasight/services/ai/providers/openai_provider.py +123 -0
- qasight/services/ai_run_summary.py +60 -0
- qasight/services/ask_tests.py +177 -0
- qasight/services/attention.py +118 -0
- qasight/services/badge.py +108 -0
- qasight/services/ci_provenance.py +165 -0
- qasight/services/ci_templates.py +524 -0
- qasight/services/cycle_run.py +503 -0
- qasight/services/dashboard_service.py +357 -0
- qasight/services/delivery_metrics.py +434 -0
- qasight/services/delta_models.py +61 -0
- qasight/services/delta_service.py +330 -0
- qasight/services/failed_first.py +67 -0
- qasight/services/failure_analysis_service.py +222 -0
- qasight/services/failure_context_builder.py +139 -0
- qasight/services/failure_patterns.py +300 -0
- qasight/services/failure_signal.py +233 -0
- qasight/services/failure_source.py +347 -0
- qasight/services/flaky_issues.py +483 -0
- qasight/services/flaky_service.py +353 -0
- qasight/services/github_history.py +110 -0
- qasight/services/github_issues.py +129 -0
- qasight/services/ingest_client.py +138 -0
- qasight/services/junit_ingest.py +195 -0
- qasight/services/manager_report.py +433 -0
- qasight/services/mcp_install.py +111 -0
- qasight/services/metrics_service.py +41 -0
- qasight/services/otel_export.py +411 -0
- qasight/services/plugin_scaffold.py +75 -0
- qasight/services/project_health.py +354 -0
- qasight/services/project_review.py +556 -0
- qasight/services/project_setup.py +588 -0
- qasight/services/quarantine_store.py +428 -0
- qasight/services/releases.py +209 -0
- qasight/services/repo_scan.py +233 -0
- qasight/services/report_archive.py +168 -0
- qasight/services/report_archive_diff.py +181 -0
- qasight/services/report_email.py +196 -0
- qasight/services/report_export.py +240 -0
- qasight/services/report_office.py +480 -0
- qasight/services/report_service.py +121 -0
- qasight/services/rerun_commands.py +280 -0
- qasight/services/run_alerts.py +197 -0
- qasight/services/run_annotations.py +127 -0
- qasight/services/run_digest.py +329 -0
- qasight/services/run_environment.py +111 -0
- qasight/services/run_failures.py +248 -0
- qasight/services/run_release.py +54 -0
- qasight/services/run_snapshot_diff.py +37 -0
- qasight/services/run_summary_markdown.py +361 -0
- qasight/services/server_setup.py +182 -0
- qasight/services/setup_pr.py +166 -0
- qasight/services/stage_check.py +199 -0
- qasight/services/suite_hygiene.py +162 -0
- qasight/services/team_view.py +192 -0
- qasight/services/test_attachments.py +99 -0
- qasight/services/test_history.py +329 -0
- qasight/services/test_issues.py +119 -0
- qasight/services/test_labels.py +78 -0
- qasight/services/test_ownership.py +139 -0
- qasight/services/test_selection.py +173 -0
- qasight/services/test_split.py +210 -0
- qasight/services/triage.py +164 -0
- qasight/services/update_check.py +121 -0
- qasight/templates/plugin/README.md +16 -0
- qasight/templates/plugin/__MODULE__/__init__.py.tmpl +65 -0
- qasight/templates/plugin/__MODULE__/runner.py.tmpl +74 -0
- qasight/templates/plugin/gitignore +6 -0
- qasight/templates/plugin/pyproject.toml +21 -0
- qasight/templates/plugin/qasight.yaml +11 -0
- qasight/templates/plugin/tests/__init__.py.tmpl +0 -0
- qasight/templates/plugin/tests/test_example.py.tmpl +18 -0
- qasight/templates/server/docker-compose.yml +117 -0
- qasight/ui_bundle.py +30 -0
- qasight-1.6.0.dist-info/METADATA +475 -0
- qasight-1.6.0.dist-info/RECORD +305 -0
- qasight-1.6.0.dist-info/WHEEL +4 -0
- qasight-1.6.0.dist-info/entry_points.txt +10 -0
- qasight-1.6.0.dist-info/licenses/LICENSE +21 -0
- qasight_api/__init__.py +3 -0
- qasight_api/cycle_execution_manager.py +560 -0
- qasight_api/demo_examples.py +257 -0
- qasight_api/dependencies.py +46 -0
- qasight_api/execution_events.py +186 -0
- qasight_api/login_throttle.py +96 -0
- qasight_api/main.py +371 -0
- qasight_api/models.py +1425 -0
- qasight_api/report_origin.py +180 -0
- qasight_api/routes/__init__.py +1 -0
- qasight_api/routes/ai.py +107 -0
- qasight_api/routes/analytics.py +400 -0
- qasight_api/routes/ask.py +25 -0
- qasight_api/routes/auth.py +230 -0
- qasight_api/routes/badge.py +37 -0
- qasight_api/routes/cycles.py +313 -0
- qasight_api/routes/dashboard.py +152 -0
- qasight_api/routes/health.py +63 -0
- qasight_api/routes/history.py +268 -0
- qasight_api/routes/ingest.py +96 -0
- qasight_api/routes/project_health.py +63 -0
- qasight_api/routes/quarantine.py +72 -0
- qasight_api/routes/report.py +67 -0
- qasight_api/routes/resources.py +233 -0
- qasight_api/routes/storage.py +71 -0
- qasight_api/routes/triage.py +64 -0
- qasight_api/routes/version.py +28 -0
- qasight_api/run_queue.py +193 -0
- qasight_api/security.py +484 -0
- qasight_api/static_site.py +626 -0
- qasight_api/ui.py +35 -0
- qasight_api/ui_dist/assets/index-CcPZt3fO.js +50 -0
- qasight_api/ui_dist/assets/index-gUannA5U.css +2 -0
- qasight_api/ui_dist/index.html +21 -0
qasight/__init__.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""QASight — public API for the ``qasight`` distribution.
|
|
2
|
+
|
|
3
|
+
The new narrow surface is :class:`qasight.config.Plan` /
|
|
4
|
+
:class:`qasight.config.Stage` plus :func:`qasight.engine.run_plan`.
|
|
5
|
+
Every name is exposed via PEP 562 lazy loading, so ``import qasight`` (which
|
|
6
|
+
pytest does for every run through the ``pytest11`` plugin) loads no config,
|
|
7
|
+
engine or SQLAlchemy code until a consumer actually touches a name. Loading
|
|
8
|
+
them eagerly also hid those modules from pytest-cov, which starts after plugins.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from importlib.metadata import PackageNotFoundError
|
|
15
|
+
from importlib.metadata import version as pkg_version
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _mirror_env_prefix() -> None:
|
|
20
|
+
"""Let ``QASIGHT_*`` stand in for every ``TESTO_*`` setting.
|
|
21
|
+
|
|
22
|
+
Settings kept their ``TESTO_`` names through the rename to QASight (1.6), so
|
|
23
|
+
nothing that already sets them breaks. ``QASIGHT_X`` fills ``TESTO_X`` when that
|
|
24
|
+
isn't set; an explicit ``TESTO_X`` wins. Child processes inherit the result.
|
|
25
|
+
"""
|
|
26
|
+
for key, value in list(os.environ.items()):
|
|
27
|
+
if key.startswith("QASIGHT_") and len(key) > len("QASIGHT_"):
|
|
28
|
+
os.environ.setdefault("TESTO_" + key[len("QASIGHT_") :], value)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_mirror_env_prefix()
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from qasight.config import Plan as Plan
|
|
35
|
+
from qasight.config import QASightConfig as QASightConfig
|
|
36
|
+
from qasight.config import Stage as Stage
|
|
37
|
+
from qasight.config import TestosteroneConfig as TestosteroneConfig
|
|
38
|
+
from qasight.engine.exit_codes import EngineExitCode as EngineExitCode
|
|
39
|
+
|
|
40
|
+
# Each entry maps the exported attribute to its real module path. PEP 562
|
|
41
|
+
# :func:`__getattr__` resolves them on first access, so ``import qasight``
|
|
42
|
+
# loads nothing else.
|
|
43
|
+
_LAZY_EXPORTS: dict[str, str] = {
|
|
44
|
+
"Plan": "qasight.config",
|
|
45
|
+
"Stage": "qasight.config",
|
|
46
|
+
"QASightConfig": "qasight.config",
|
|
47
|
+
"TestosteroneConfig": "qasight.config",
|
|
48
|
+
"EngineExitCode": "qasight.engine.exit_codes",
|
|
49
|
+
"get_repository": "qasight.repository.db",
|
|
50
|
+
"reset_repository_cache": "qasight.repository.db",
|
|
51
|
+
"create_db_and_tables": "qasight.repository.db_config",
|
|
52
|
+
"get_engine": "qasight.repository.db_config",
|
|
53
|
+
"reset_engine_cache": "qasight.repository.db_config",
|
|
54
|
+
"resolve_database_url": "qasight.repository.db_config",
|
|
55
|
+
"RunRecord": "qasight.repository.models",
|
|
56
|
+
"RunStatus": "qasight.repository.models",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
__all__ = [*_LAZY_EXPORTS.keys()]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def __getattr__(name: str) -> Any:
|
|
64
|
+
"""PEP 562 lazy attribute resolver for the names above."""
|
|
65
|
+
module_path = _LAZY_EXPORTS.get(name)
|
|
66
|
+
if module_path is None:
|
|
67
|
+
raise AttributeError(f"module 'qasight' has no attribute {name!r}")
|
|
68
|
+
import importlib
|
|
69
|
+
|
|
70
|
+
module = importlib.import_module(module_path)
|
|
71
|
+
value = getattr(module, name)
|
|
72
|
+
globals()[name] = value # cache for subsequent accesses
|
|
73
|
+
return value
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def __dir__() -> list[str]:
|
|
77
|
+
return sorted(__all__)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
__version__ = pkg_version("qasight")
|
|
82
|
+
except PackageNotFoundError: # pragma: no cover - source tree import before install
|
|
83
|
+
__version__ = "0.1.0"
|
qasight/accounts.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Team server accounts and the audit log (used only when ``TESTO_AUTH=accounts``).
|
|
2
|
+
|
|
3
|
+
Accounts live in the run-history database, next to the runs. There is no sign-up:
|
|
4
|
+
an admin creates them with ``qasight users add``, which talks to the database
|
|
5
|
+
directly. Passwords are hashed with ``scrypt`` from the standard library.
|
|
6
|
+
|
|
7
|
+
Roles, from least to most:
|
|
8
|
+
|
|
9
|
+
- ``viewer``: reads runs, logs and reports.
|
|
10
|
+
- ``runner``: also starts runs and uploads results.
|
|
11
|
+
- ``admin``: also changes server settings (AI provider) and reads the audit log.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import hmac
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
from typing import Any, Final
|
|
23
|
+
|
|
24
|
+
ROLES: Final[tuple[str, ...]] = ("viewer", "runner", "admin")
|
|
25
|
+
_USERNAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._@-]{0,63}$")
|
|
26
|
+
MIN_PASSWORD_LENGTH: Final[int] = 10
|
|
27
|
+
|
|
28
|
+
# scrypt cost: n=2**14, r=8 is the common interactive-login setting (~16 MB, tens of ms).
|
|
29
|
+
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P = 2**14, 8, 1
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AccountError(ValueError):
|
|
33
|
+
"""A bad username, password or role, or an account that doesn't exist."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def role_at_least(role: str, needed: str) -> bool:
|
|
37
|
+
return role in ROLES and ROLES.index(role) >= ROLES.index(needed)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def hash_password(password: str) -> str:
|
|
41
|
+
salt = os.urandom(16)
|
|
42
|
+
digest = hashlib.scrypt(
|
|
43
|
+
password.encode(), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=32
|
|
44
|
+
)
|
|
45
|
+
return f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${salt.hex()}${digest.hex()}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def verify_password(password: str, stored: str) -> bool:
|
|
49
|
+
try:
|
|
50
|
+
scheme, n, r, p, salt_hex, digest_hex = stored.split("$")
|
|
51
|
+
if scheme != "scrypt":
|
|
52
|
+
return False
|
|
53
|
+
digest = hashlib.scrypt(
|
|
54
|
+
password.encode(),
|
|
55
|
+
salt=bytes.fromhex(salt_hex),
|
|
56
|
+
n=int(n),
|
|
57
|
+
r=int(r),
|
|
58
|
+
p=int(p),
|
|
59
|
+
dklen=len(digest_hex) // 2,
|
|
60
|
+
)
|
|
61
|
+
except (ValueError, TypeError):
|
|
62
|
+
return False
|
|
63
|
+
return hmac.compare_digest(digest.hex(), digest_hex)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _check_username(username: str) -> str:
|
|
67
|
+
name = username.strip()
|
|
68
|
+
if not _USERNAME_RE.match(name):
|
|
69
|
+
raise AccountError("usernames are 1-64 characters: letters, digits and . _ @ - (not first)")
|
|
70
|
+
return name
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def check_new_account(username: str, role: str) -> str:
|
|
74
|
+
"""Validate the name and role of an account about to be added (before asking for a
|
|
75
|
+
password); returns the cleaned name. :class:`AccountError` if either is bad."""
|
|
76
|
+
name = _check_username(username)
|
|
77
|
+
_check_role(role)
|
|
78
|
+
return name
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _check_password(password: str) -> None:
|
|
82
|
+
if len(password) < MIN_PASSWORD_LENGTH:
|
|
83
|
+
raise AccountError(f"passwords need at least {MIN_PASSWORD_LENGTH} characters")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _check_role(role: str) -> str:
|
|
87
|
+
if role not in ROLES:
|
|
88
|
+
raise AccountError(f"role must be one of {', '.join(ROLES)}")
|
|
89
|
+
return role
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class Account:
|
|
94
|
+
username: str
|
|
95
|
+
role: str
|
|
96
|
+
session_version: int
|
|
97
|
+
created_at: datetime
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class AuditEntry:
|
|
102
|
+
at: datetime
|
|
103
|
+
username: str
|
|
104
|
+
method: str
|
|
105
|
+
path: str
|
|
106
|
+
status: int
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _aware(value: datetime) -> datetime:
|
|
110
|
+
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class AccountStore:
|
|
114
|
+
"""Accounts and audit events on a SQLAlchemy engine (the run-history DB by default)."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, engine: Any = None) -> None:
|
|
117
|
+
from qasight.repository.db_config import create_db_and_tables, get_engine
|
|
118
|
+
|
|
119
|
+
self._engine = engine if engine is not None else get_engine()
|
|
120
|
+
create_db_and_tables(self._engine)
|
|
121
|
+
|
|
122
|
+
# --- accounts ---------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def add(self, username: str, password: str, role: str = "viewer") -> Account:
|
|
125
|
+
from sqlmodel import Session
|
|
126
|
+
|
|
127
|
+
from qasight.repository.models import UserAccount
|
|
128
|
+
|
|
129
|
+
name = _check_username(username)
|
|
130
|
+
_check_password(password)
|
|
131
|
+
_check_role(role)
|
|
132
|
+
with Session(self._engine) as session:
|
|
133
|
+
if session.get(UserAccount, name) is not None:
|
|
134
|
+
raise AccountError(f"user {name!r} already exists")
|
|
135
|
+
row = UserAccount(username=name, password_hash=hash_password(password), role=role)
|
|
136
|
+
session.add(row)
|
|
137
|
+
session.commit()
|
|
138
|
+
session.refresh(row)
|
|
139
|
+
return self._account(row)
|
|
140
|
+
|
|
141
|
+
def get(self, username: str) -> Account | None:
|
|
142
|
+
from sqlmodel import Session
|
|
143
|
+
|
|
144
|
+
from qasight.repository.models import UserAccount
|
|
145
|
+
|
|
146
|
+
with Session(self._engine) as session:
|
|
147
|
+
row = session.get(UserAccount, username)
|
|
148
|
+
return None if row is None else self._account(row)
|
|
149
|
+
|
|
150
|
+
def all(self) -> list[Account]:
|
|
151
|
+
from sqlmodel import Session, col, select
|
|
152
|
+
|
|
153
|
+
from qasight.repository.models import UserAccount
|
|
154
|
+
|
|
155
|
+
with Session(self._engine) as session:
|
|
156
|
+
rows = session.exec(select(UserAccount).order_by(col(UserAccount.username))).all()
|
|
157
|
+
return [self._account(row) for row in rows]
|
|
158
|
+
|
|
159
|
+
def authenticate(self, username: str, password: str) -> Account | None:
|
|
160
|
+
from sqlmodel import Session
|
|
161
|
+
|
|
162
|
+
from qasight.repository.models import UserAccount
|
|
163
|
+
|
|
164
|
+
with Session(self._engine) as session:
|
|
165
|
+
row = session.get(UserAccount, username.strip())
|
|
166
|
+
if row is None:
|
|
167
|
+
# Same work as a real check, so timing doesn't say which names exist.
|
|
168
|
+
verify_password(password, _DUMMY_HASH)
|
|
169
|
+
return None
|
|
170
|
+
return self._account(row) if verify_password(password, row.password_hash) else None
|
|
171
|
+
|
|
172
|
+
def set_role(self, username: str, role: str) -> Account:
|
|
173
|
+
_check_role(role)
|
|
174
|
+
return self._update(username, role=role)
|
|
175
|
+
|
|
176
|
+
def reset_password(self, username: str, password: str) -> Account:
|
|
177
|
+
_check_password(password)
|
|
178
|
+
return self._update(username, password_hash=hash_password(password))
|
|
179
|
+
|
|
180
|
+
def sign_out_everywhere(self, username: str) -> Account:
|
|
181
|
+
return self._update(username)
|
|
182
|
+
|
|
183
|
+
def remove(self, username: str) -> None:
|
|
184
|
+
from sqlmodel import Session
|
|
185
|
+
|
|
186
|
+
from qasight.repository.models import UserAccount
|
|
187
|
+
|
|
188
|
+
with Session(self._engine) as session:
|
|
189
|
+
row = session.get(UserAccount, username)
|
|
190
|
+
if row is None:
|
|
191
|
+
raise AccountError(f"no user {username!r}")
|
|
192
|
+
session.delete(row)
|
|
193
|
+
session.commit()
|
|
194
|
+
|
|
195
|
+
def _update(self, username: str, **fields: Any) -> Account:
|
|
196
|
+
"""Change ``fields`` and bump the session version (every change signs the user out)."""
|
|
197
|
+
from sqlmodel import Session
|
|
198
|
+
|
|
199
|
+
from qasight.repository.models import UserAccount
|
|
200
|
+
|
|
201
|
+
with Session(self._engine) as session:
|
|
202
|
+
row = session.get(UserAccount, username)
|
|
203
|
+
if row is None:
|
|
204
|
+
raise AccountError(f"no user {username!r}")
|
|
205
|
+
for key, value in fields.items():
|
|
206
|
+
setattr(row, key, value)
|
|
207
|
+
row.session_version += 1
|
|
208
|
+
session.add(row)
|
|
209
|
+
session.commit()
|
|
210
|
+
session.refresh(row)
|
|
211
|
+
return self._account(row)
|
|
212
|
+
|
|
213
|
+
@staticmethod
|
|
214
|
+
def _account(row: Any) -> Account:
|
|
215
|
+
return Account(
|
|
216
|
+
username=row.username,
|
|
217
|
+
role=row.role,
|
|
218
|
+
session_version=row.session_version,
|
|
219
|
+
created_at=_aware(row.created_at),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# --- audit log --------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
def record(self, *, username: str, method: str, path: str, status: int) -> None:
|
|
225
|
+
from sqlmodel import Session
|
|
226
|
+
|
|
227
|
+
from qasight.repository.models import AuditEvent
|
|
228
|
+
|
|
229
|
+
with Session(self._engine) as session:
|
|
230
|
+
session.add(
|
|
231
|
+
AuditEvent(username=username, method=method, path=path[:1024], status=status)
|
|
232
|
+
)
|
|
233
|
+
session.commit()
|
|
234
|
+
|
|
235
|
+
def recent(self, limit: int = 100) -> list[AuditEntry]:
|
|
236
|
+
from sqlmodel import Session, col, select
|
|
237
|
+
|
|
238
|
+
from qasight.repository.models import AuditEvent
|
|
239
|
+
|
|
240
|
+
with Session(self._engine) as session:
|
|
241
|
+
rows = session.exec(
|
|
242
|
+
select(AuditEvent).order_by(col(AuditEvent.at).desc()).limit(max(1, limit))
|
|
243
|
+
).all()
|
|
244
|
+
return [AuditEntry(_aware(r.at), r.username, r.method, r.path, r.status) for r in rows]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
_DUMMY_HASH: Final[str] = hash_password("not-a-real-password")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def ensure_first_admin(
|
|
251
|
+
env: dict[str, str] | None = None, *, store: AccountStore | None = None
|
|
252
|
+
) -> str | None:
|
|
253
|
+
"""Create ``TESTO_ADMIN_USER`` as admin when ``TESTO_AUTH=accounts`` and no account exists.
|
|
254
|
+
|
|
255
|
+
For a fresh team server (``qasight server init`` writes both variables to
|
|
256
|
+
``.env``), so nobody has to ``docker compose exec`` into it to add the first
|
|
257
|
+
user. Does nothing once any account exists, so a changed password in ``.env``
|
|
258
|
+
never overwrites one. Returns the name it created.
|
|
259
|
+
"""
|
|
260
|
+
import os
|
|
261
|
+
|
|
262
|
+
env = dict(os.environ if env is None else env)
|
|
263
|
+
if env.get("TESTO_AUTH", "").strip().lower() != "accounts":
|
|
264
|
+
return None
|
|
265
|
+
user = env.get("TESTO_ADMIN_USER", "").strip()
|
|
266
|
+
password = env.get("TESTO_ADMIN_PASSWORD", "")
|
|
267
|
+
if not user or not password:
|
|
268
|
+
return None
|
|
269
|
+
store = store or AccountStore()
|
|
270
|
+
if store.all():
|
|
271
|
+
return None
|
|
272
|
+
return store.add(user, password, role="admin").username
|