fix: complete audit follow-ups and add regression coverage

Centralize backup schedule calculations and shared request/form flows, encode query values, and move repository finalization into its service. Add browser CI coverage, repair stale test fixtures, and provide opt-in request and build measurement tools.
This commit is contained in:
Juan Denis committed 2026-09-05 14:41:15 -04:00
1 parent aae180aaef
commit d67e5d07d0
72 files changed
+1813 -526

No files matched your search

+4
View File
@@ -155,3 +155,7 @@ SERVERKIT_GITHUB_REPO=jhd3197/ServerKit
# Mark this panel as a staging install (reported by /health).
# SERVERKIT_STAGING=false
# Add request duration, SQL duration and statement count to Server-Timing.
# Temporary local/staging diagnosis; disabled by default. No SQL text is emitted.
# SERVERKIT_PROFILE_REQUESTS=false
+5 -5
View File
@@ -50,8 +50,8 @@ jobs:
run: python tests/check_test_count.py
# Sharded because this job WAS the entire wait: 21m50s of a ~22m pipeline,
# while every other workflow finished inside a minute. Splitting the 3173
# tests over 4 runners cuts the critical path to roughly a quarter.
# while every other workflow finished inside a minute. Splitting the suite
# over 4 runners cuts the critical path to roughly a quarter.
#
# Sharding rather than pytest-xdist is deliberate: each shard is its own VM,
# so the process-shared state that makes in-process parallelism unsafe here
@@ -86,9 +86,9 @@ jobs:
pip install -r requirements.txt
pip install pytest pytest-split
- name: Run tests (shard ${{ matrix.group }} of 4)
# Scoped to `tests` rather than a bare `pytest`. Identical here (3173
# either way, since backend/dev-data/ is gitignored and absent from a
# CI checkout), but it makes the command reproducible on a dev box: a
# Scoped to `tests` rather than a bare `pytest`. This collects the same
# suite in CI (backend/dev-data/ is gitignored), while keeping the
# command reproducible on a dev box: a
# bare pytest there tries to collect the locally deployed apps under
# backend/dev-data/ and dies during collection. Copy this line verbatim
# to debug a red shard locally.
+17 -21
View File
@@ -1,20 +1,7 @@
name: Frontend CI
# The frontend's lint gate ran nowhere until now — `npm run lint` was in
# package.json and in CLAUDE.md, but no workflow ever invoked it. That silently
# unenforced three project-specific checkers that exist precisely because a
# human review keeps missing what they catch:
#
# check-settings-index every Settings tab has a search-index entry
# check-theme-tokens the theme-token whitelist stays in 3-way sync
# check-html-sinks every raw-HTML sink is sanitized or annotated (XSS)
#
# `npm run lint` and `npm test` run here. The test suite (`node --test`,
# 141 assertions over pure-logic modules) sat invocable-but-uninvoked the
# same way lint once did — written insurance no workflow cashed in. The
# frontend is already COMPILED in CI by Release Build Smoke Test, whose
# scripts/build-release.sh does `npm ci && npm run build` — adding a build
# job here would just duplicate that.
# Run the warning ratchet, repository integrity checks, unit tests and browser
# regressions. Release Build Smoke Test separately compiles the production app.
#
# backend/app/** is in the paths because check-html-sinks scans it too (for
# `|safe`, `Markup(`, `render_template_string`), so a backend-only commit can
@@ -52,23 +39,32 @@ jobs:
working-directory: frontend
run: npm ci
- name: Lint
# eslint + the three checkers, chained by the package.json script.
# Currently 926 warnings / 0 errors, and eslint exits 0 on warnings —
# so this gates on errors only. If you ever want the warning count
# ratcheted the way backend/tests/BASELINE_COUNT ratchets test count,
# add --max-warnings=<N> here rather than mass-fixing in one commit.
# Reject errors and warning growth by file/rule, then run all integrity
# checks chained by package.json. Keep the baseline reviewed in Git.
working-directory: frontend
run: npm run lint
- name: Unit tests
# node --test over src/**/__tests__ — pure-logic modules, no jsdom.
working-directory: frontend
run: npm test
- name: Install browser for Settings regressions
- name: Install browser for regressions
working-directory: frontend
run: npx playwright install --with-deps chromium
- name: Settings browser regressions
working-directory: frontend
run: npm run test:browser
- name: Shared controls browser regressions
working-directory: frontend
run: npm run test:controls
- name: Metrics and widget browser regressions
working-directory: frontend
run: npm run test:metrics
- name: Authentication and layout hooks browser regressions
working-directory: frontend
run: npm run test:hooks
- name: Backup request and form browser regressions
working-directory: frontend
run: npm run test:backups
- name: Upload Settings screenshots
if: always()
uses: actions/upload-artifact@v4
+2 -2
View File
@@ -16,8 +16,8 @@ on:
- 'backend/tests/test_utils_system*.py'
# The mocked `unit-tests` job that used to lead this file was removed: it ran
# `pytest tests/test_utils_system.py` (44 tests), and Backend CI's bare
# `pytest -v` already collects that exact file — there is no pytest.ini,
# `pytest tests/test_utils_system.py`, and Backend CI's
# `pytest tests -v` already collects that exact file — there is no pytest.ini,
# addopts, or collect_ignore narrowing it. What is left here is the part
# Backend CI genuinely cannot do: exercise the package-manager detection
# against real apt/dnf inside real distro images.
+2
View File
@@ -128,6 +128,8 @@ def create_app(config_name=None):
# Initialize extensions
db.init_app(app)
from app.middleware.request_profiling import register_request_profiling
register_request_profiling(app, db)
migrate.init_app(app, db)
jwt.init_app(app)
# Storage backend comes from app.config's RATELIMIT_STORAGE_URI when set
+17 -74
View File
@@ -4,7 +4,6 @@
import os
import json
import re
import shutil
from datetime import datetime
from flask import Blueprint, request, jsonify, current_app
from flask_jwt_extended import jwt_required, get_jwt_identity
@@ -21,7 +20,7 @@ from app.services.source_connection_service import SourceConnectionService
from app.services.remote_docker_service import RemoteDockerService
from app.services.container_registry_service import ContainerRegistryService
from app.services.image_update_service import ImageUpdateService
from app.services import container_status_service, application_lifecycle_service
from app.services import container_status_service, application_lifecycle_service, repository_application_service
from app.services.application_lifecycle_service import (
_compose_target, _local_compose_file, _agent_result_failed,
_agent_result_error, _assert_managed_app_path,
@@ -854,92 +853,36 @@ def create_app_from_repository():
)
try:
db.session.add(app)
db.session.commit()
deploy_result = GitService.configure_deployment(
app_id=app.id,
app_path=app.root_path,
repo_url=deploy_repo_url,
branch=branch or 'main',
auto_deploy=auto_deploy,
created = repository_application_service.finalize_repository_application(
app, user_id=user.id, repo_url=deploy_repo_url, branch=branch or 'main',
auto_deploy=auto_deploy, manifest=manifest,
build_options={
'build_method': resolved_build_method,
'dockerfile_path': dockerfile_path,
'custom_build_cmd': custom_build_cmd,
'custom_start_cmd': custom_start_cmd,
'buildpack_plan': buildpack_plan,
'buildpack_overrides': buildpack_overrides,
},
)
if not deploy_result.get('success'):
raise RuntimeError(deploy_result.get('error', 'Failed to configure deployment'))
build_result = BuildService.configure_build(
app_id=app.id,
app_path=app.root_path,
build_method=resolved_build_method,
dockerfile_path=dockerfile_path,
custom_build_cmd=custom_build_cmd,
custom_start_cmd=custom_start_cmd,
buildpack_plan=buildpack_plan,
buildpack_overrides=buildpack_overrides,
)
if not build_result.get('success'):
raise RuntimeError(build_result.get('error', 'Failed to configure build'))
# Stop dropping what we detect: persist the manifest, seed non-secret
# env values, and record the health-check path (plan 17, Phase 1).
manifest_summary = None
try:
from app.services.manifest_persistence_service import ManifestPersistenceService
manifest_summary = ManifestPersistenceService.apply_import(
app, manifest, user_id=user.id,
source_repo=deploy_repo_url, source_ref=branch or 'main',
)
except Exception:
manifest_summary = None
# Actually deploy + start the service: hand it to the same observable
# DeploymentJob pipeline template installs use (kind 'app_deploy' →
# unified job 'deploy.app' → DeploymentService.deploy). A queue failure
# must NOT fail app creation — the service stays 'stopped' and the user
# can deploy manually from the Builds tab.
deploy_job_id = None
try:
from app.services.deployment_job_service import DeploymentJobService
enqueue_result = DeploymentJobService.enqueue_app_deploy(
app, user_id=user.id, trigger='install')
if enqueue_result.get('success'):
deploy_job_id = enqueue_result.get('job_id')
else:
current_app.logger.warning(
'app deploy enqueue failed for app %s: %s',
app.id, enqueue_result.get('error'))
except Exception as exc:
current_app.logger.warning(
'app deploy enqueue failed for app %s: %s', app.id, exc)
return jsonify({
'message': 'Repository service created',
'app': _attach_deploy_config(app.to_dict(include_linked=True)),
'deploy_job_id': deploy_job_id,
'manifest_import': manifest_summary,
'deploy_job_id': created['deploy_job_id'],
'manifest_import': created['manifest_import'],
'deploy_config': {
'repo_url': _safe_repo_url(deploy_repo_url),
'branch': branch or 'main',
'auto_deploy': auto_deploy,
'webhook_url': deploy_result.get('webhook_url'),
'webhook_url': created['deploy_result'].get('webhook_url'),
},
'build_config': build_result.get('config'),
'build_config': created['build_config'],
'detection': detection,
'manifest': manifest,
}), 201
except Exception as exc:
db.session.rollback()
if app.id:
GitService.remove_deployment(app.id)
# Unwinding a create that failed, not deleting an app someone made:
# a hard delete (no query_active, no tombstone) is what belongs
# here — a half-created service must not land in the recycle bin.
existing_app = Application.query.get(app.id)
if existing_app:
db.session.delete(existing_app)
db.session.commit()
if os.path.abspath(app_path).startswith(os.path.abspath(paths.APPS_DIR) + os.sep):
shutil.rmtree(app_path, ignore_errors=True)
repository_application_service.abort_repository_creation(app)
return jsonify({'error': str(exc)}), 400
+2 -1
View File
@@ -474,8 +474,9 @@ def cleanup_backups():
@admin_required
def list_schedules():
"""List backup schedules."""
from app.services.backup_schedule_service import server_timezone
schedules = BackupService.get_schedules()
return jsonify({'schedules': schedules}), 200
return jsonify({'schedules': schedules, 'timezone': str(server_timezone())}), 200
@backups_bp.route('/schedules', methods=['POST'])
@@ -0,0 +1,66 @@
"""Opt-in request/SQL costs, without SQL text, parameters or persistent storage."""
from time import perf_counter_ns
from flask import g, has_request_context, request
from sqlalchemy import event
def register_request_profiling(app, database):
"""Attach only when enabled; normal deployments install no query listeners.
Counts SQLAlchemy statements on the request thread, including failed SQL.
Streaming body iteration and background jobs occur outside this interval.
"""
if not app.config.get('PROFILE_REQUESTS'):
return
if app.extensions.get('serverkit_request_profiling'):
return
app.extensions['serverkit_request_profiling'] = True
@app.before_request
def begin_request():
if request.path.startswith('/api/'):
g.serverkit_profile = {
'start': perf_counter_ns(), 'queries': 0, 'sql_ns': 0,
}
def before_sql(_conn, _cursor, _statement, _parameters, context, _many):
if not has_request_context():
return
state = getattr(g, 'serverkit_profile', None)
if state is not None:
state['queries'] += 1
context._serverkit_profile = (state, perf_counter_ns())
def finish_sql(context):
timing = getattr(context, '_serverkit_profile', None)
if timing is not None:
state, start = timing
state['sql_ns'] += perf_counter_ns() - start
context._serverkit_profile = None
def after_sql(_conn, _cursor, _statement, _parameters, context, _many):
finish_sql(context)
def failed_sql(error_context):
finish_sql(error_context.execution_context)
with app.app_context():
for engine in set(database.engines.values()):
event.listen(engine, 'before_cursor_execute', before_sql)
event.listen(engine, 'after_cursor_execute', after_sql)
event.listen(engine, 'handle_error', failed_sql)
@app.after_request
def expose_profile(response):
state = getattr(g, 'serverkit_profile', None)
if state is not None:
app_ms = (perf_counter_ns() - state['start']) / 1_000_000
sql_ms = state['sql_ns'] / 1_000_000
response.headers.add(
'Server-Timing',
f'app;dur={app_ms:.3f}, db;dur={sql_ms:.3f};'
f'desc="{state["queries"]} queries"',
)
return response
@@ -0,0 +1,98 @@
"""Server-local legacy backup schedules, shared by the API and job tick.
Wall-clock schedules retain their original behavior: a missing spring-forward
minute is skipped; both occurrences of a repeated autumn minute can run.
All comparisons use UTC so Python's same-zone arithmetic cannot collapse folds.
"""
from datetime import datetime, timedelta, timezone
import re
from tzlocal import reload_localzone
WEEKDAYS = ('monday', 'tuesday', 'wednesday', 'thursday', 'friday',
'saturday', 'sunday')
def server_timezone():
# A panel timezone change must take effect without restarting the process.
return reload_localzone()
def validate_schedule(schedule):
value = schedule.get('schedule_time')
if not isinstance(value, str) or not re.fullmatch(r'(?:[01]\d|2[0-3]):[0-5]\d', value):
raise ValueError('schedule_time must be a valid HH:MM time')
days = schedule.get('days', ['daily'])
if (not isinstance(days, list) or not days
or any(not isinstance(day, str) or day not in (*WEEKDAYS, 'daily') for day in days)):
raise ValueError('days must contain daily or valid lowercase weekday names')
return tuple(map(int, value.split(':'))), days
def _last_run(schedule, zone):
try:
last = datetime.fromisoformat(schedule.get('last_run') or '')
# Existing records have naive server-local timestamps. New writes carry
# an offset; retain compatibility without changing persisted schedules.
return (last if last.tzinfo else last.replace(tzinfo=zone)).astimezone(timezone.utc)
except (TypeError, ValueError):
return None
def next_run(schedule, *, now=None, zone=None):
"""Return the next eligible minute (including the current minute), or None.
Uses the same 120-second duplicate suppression as the original job tick.
There is no missed-run catchup: only this minute or a future minute qualifies.
Invalid legacy schedules remain readable and inert rather than breaking ticks.
"""
try:
(hour, minute), days = validate_schedule(schedule)
except ValueError:
return None
if not schedule.get('enabled', False):
return None
zone = zone or server_timezone()
now = now or datetime.now(timezone.utc)
if now.tzinfo is None:
raise ValueError('now must include a timezone')
local_now = now.astimezone(zone)
start = local_now.replace(second=0, microsecond=0).astimezone(timezone.utc)
last = _last_run(schedule, zone)
# A weekly occurrence can be absent during a DST jump; search through the
# following week's occurrence as well.
for offset in range(15):
date = local_now.date() + timedelta(days=offset)
if 'daily' not in days and WEEKDAYS[date.weekday()] not in days:
continue
wall = datetime(date.year, date.month, date.day, hour, minute)
candidates = set()
for fold in (0, 1):
candidate = wall.replace(tzinfo=zone, fold=fold).astimezone(timezone.utc)
# A nonexistent local minute round-trips to a different wall time.
if candidate.astimezone(zone).replace(tzinfo=None) == wall:
candidates.add(candidate)
for candidate in sorted(candidates):
if candidate < start:
continue
# For a due minute compare against the real tick time, preserving
# dedup behavior for records stamped partway through that minute.
check_time = max(candidate, now.astimezone(timezone.utc))
if last is not None and (check_time - last).total_seconds() < 120:
continue
return candidate.astimezone(zone)
return None
def describe_schedule(schedule, *, globally_enabled=True, now=None, zone=None):
zone = zone or server_timezone()
result = dict(schedule)
result['timezone'] = str(zone)
try:
validate_schedule(schedule)
except ValueError as exc:
result['schedule_error'] = str(exc)
following = next_run(schedule, now=now, zone=zone) if globally_enabled else None
result['next_run_at'] = following.isoformat() if following else None
return result
+26 -25
View File
@@ -5,7 +5,7 @@ import shutil
import tarfile
import gzip
import tempfile
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional
from pathlib import Path
import threading
@@ -19,6 +19,7 @@ from app.utils.config_store import load_json_config, save_json_config
from app.utils.formatting import format_bytes
from app.utils.system import is_command_available, run_checked
from app.services.telemetry_service import TelemetryService, generate_correlation_id
from app.services import backup_schedule_service
# Unified job kind for asynchronous scheduled backups (see register_jobs).
BACKUP_JOB_KIND = 'backup.run'
@@ -660,18 +661,23 @@ class BackupService:
'backup_type': backup_type,
'target': target,
'schedule_time': schedule_time,
'days': days or ['daily'],
'days': ['daily'] if days is None else days,
'enabled': True,
'upload_remote': upload_remote,
'last_run': None,
'last_status': None
}
try:
backup_schedule_service.validate_schedule(schedule_entry)
except ValueError as exc:
return {'success': False, 'error': str(exc)}
config.setdefault('schedules', []).append(schedule_entry)
result = cls.save_config(config)
if result.get('success'):
return {'success': True, 'schedule': schedule_entry}
return {'success': True, 'schedule': backup_schedule_service.describe_schedule(
schedule_entry, globally_enabled=config.get('enabled', False))}
return result
@classmethod
@@ -684,9 +690,12 @@ class BackupService:
if s.get('id') == schedule_id:
allowed_fields = ['name', 'backup_type', 'target', 'schedule_time',
'days', 'enabled', 'upload_remote']
for field in allowed_fields:
if field in updates:
schedules[i][field] = updates[field]
updated = {**s, **{field: updates[field] for field in allowed_fields if field in updates}}
try:
backup_schedule_service.validate_schedule(updated)
except ValueError as exc:
return {'success': False, 'error': str(exc)}
schedules[i] = updated
config['schedules'] = schedules
return cls.save_config(config)
@@ -710,7 +719,11 @@ class BackupService:
def get_schedules(cls) -> List[Dict]:
"""Get all backup schedules."""
config = cls.get_config()
return config.get('schedules', [])
zone = backup_schedule_service.server_timezone()
now = datetime.now(timezone.utc)
return [backup_schedule_service.describe_schedule(
entry, globally_enabled=config.get('enabled', False), now=now, zone=zone,
) for entry in config.get('schedules', [])]
@classmethod
def get_backup_stats(cls) -> Dict:
@@ -895,27 +908,15 @@ class BackupService:
if not config.get('enabled', False):
return
now = datetime.now()
zone = backup_schedule_service.server_timezone()
now = datetime.now(timezone.utc).astimezone(zone)
current_time = now.strftime('%H:%M')
current_day = now.strftime('%A').lower()
dirty = False
for sched in config.get('schedules', []):
if not sched.get('enabled', False):
due = backup_schedule_service.next_run(sched, now=now, zone=zone)
if due is None or due.astimezone(timezone.utc) > now.astimezone(timezone.utc):
continue
if sched.get('schedule_time') != current_time:
continue
days = sched.get('days', ['daily'])
if 'daily' not in days and current_day not in days:
continue
# Skip if a run was enqueued / ran within the last ~2 minutes.
last_run = sched.get('last_run')
if last_run:
try:
if (now - datetime.fromisoformat(last_run)).total_seconds() < 120:
continue
except Exception:
pass
JobService.enqueue(
BACKUP_JOB_KIND,
@@ -1018,7 +1019,7 @@ class BackupService:
config = cls.get_config()
for s in config.get('schedules', []):
if s.get('id') == sched.get('id'):
s['last_run'] = datetime.now().isoformat()
s['last_run'] = datetime.now(timezone.utc).isoformat()
s['last_status'] = 'success' if result and result.get('success') else 'failed'
break
cls.save_config(config)
@@ -1037,7 +1038,7 @@ class BackupService:
config = cls.get_config()
for s in config.get('schedules', []):
if s.get('id') == sched.get('id'):
s['last_run'] = datetime.now().isoformat()
s['last_run'] = datetime.now(timezone.utc).isoformat()
s['last_status'] = 'failed'
break
cls.save_config(config)
@@ -0,0 +1,80 @@
"""Finalize repository imports with consistent compensation on setup failure.
The caller validates and authorizes the repository/workspace before constructing
the application. Deployment configuration requires its committed ID. Mandatory
configuration failure removes that half-created record and its managed source;
optional manifest enrichment or queue failure keeps the registered application.
"""
import logging
import os
import shutil
from app import db, paths
from app.models import Application
from app.services.git_service import GitService
from app.services.build_service import BuildService
logger = logging.getLogger(__name__)
def abort_repository_creation(app):
"""Compensate a failed import, never placing half-created rows in the bin."""
db.session.rollback()
if app.id:
GitService.remove_deployment(app.id)
existing = Application.query.get(app.id)
if existing:
db.session.delete(existing)
db.session.commit()
if os.path.abspath(app.root_path).startswith(os.path.abspath(paths.APPS_DIR) + os.sep):
shutil.rmtree(app.root_path, ignore_errors=True)
def finalize_repository_application(app, *, user_id, repo_url, branch,
auto_deploy, build_options, manifest):
"""Commit and configure an authorized app, returning presentation data.
Compensation is exposed separately so an HTTP caller can also unwind a
failure while building its response, retaining the existing route contract.
Non-HTTP callers should use the same compensation if finalization raises.
"""
db.session.add(app)
db.session.commit()
deploy_result = GitService.configure_deployment(
app_id=app.id, app_path=app.root_path, repo_url=repo_url,
branch=branch, auto_deploy=auto_deploy,
)
if not deploy_result.get('success'):
raise RuntimeError(deploy_result.get('error', 'Failed to configure deployment'))
build_result = BuildService.configure_build(
app_id=app.id, app_path=app.root_path, **build_options,
)
if not build_result.get('success'):
raise RuntimeError(build_result.get('error', 'Failed to configure build'))
manifest_summary = None
try:
from app.services.manifest_persistence_service import ManifestPersistenceService
manifest_summary = ManifestPersistenceService.apply_import(
app, manifest, user_id=user_id, source_repo=repo_url, source_ref=branch,
)
except Exception:
# Enrichment has always been best-effort; creation remains usable.
pass
deploy_job_id = None
try:
from app.services.deployment_job_service import DeploymentJobService
result = DeploymentJobService.enqueue_app_deploy(app, user_id=user_id, trigger='install')
if result.get('success'):
deploy_job_id = result.get('job_id')
else:
logger.warning('app deploy enqueue failed for app %s: %s', app.id, result.get('error'))
except Exception as exc:
logger.warning('app deploy enqueue failed for app %s: %s', app.id, exc)
return {
'deploy_result': deploy_result, 'build_config': build_result.get('config'),
'manifest_import': manifest_summary, 'deploy_job_id': deploy_job_id,
}
+2
View File
@@ -66,6 +66,8 @@ class Config:
# Database - use instance folder for Flask convention
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', 'sqlite:////app/instance/serverkit.db')
SQLALCHEMY_TRACK_MODIFICATIONS = False
# Temporary local/staging profiling. Off by default; no SQL text is exposed.
PROFILE_REQUESTS = _env_bool('SERVERKIT_PROFILE_REQUESTS', False)
# JWT
JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', 'jwt-secret-key-change-in-production')
+1 -1
View File
@@ -1 +1 @@
4791
5089
@@ -2,8 +2,8 @@
"schema_version": 1,
"policy": "Exact reviewed inventory of direct persistence, subprocess, and filesystem mutation calls in Flask API controllers. New calls are forbidden; remove entries as controllers delegate to services.",
"counts": {
"filesystem": 9,
"persistence": 494
"filesystem": 8,
"persistence": 488
},
"owners": {
"api/admin.py": {
@@ -265,14 +265,7 @@
"api/apps.py::create_app::persistence::db.session.commit()::1",
"api/apps.py::create_app_db_snapshot::persistence::db.session.add(snapshot)::1",
"api/apps.py::create_app_db_snapshot::persistence::db.session.commit()::1",
"api/apps.py::create_app_from_repository::filesystem::shutil.rmtree(app_path, ignore_errors=True)::1",
"api/apps.py::create_app_from_repository::persistence::Application.query.filter_by(name=name, server_id=None).first()::1",
"api/apps.py::create_app_from_repository::persistence::Application.query.get(app.id)::1",
"api/apps.py::create_app_from_repository::persistence::db.session.add(app)::1",
"api/apps.py::create_app_from_repository::persistence::db.session.commit()::1",
"api/apps.py::create_app_from_repository::persistence::db.session.commit()::2",
"api/apps.py::create_app_from_repository::persistence::db.session.delete(existing_app)::1",
"api/apps.py::create_app_from_repository::persistence::db.session.rollback()::1",
"api/apps.py::create_manual_app::persistence::Application.query.filter_by(name=name).first()::1",
"api/apps.py::create_manual_app::persistence::db.session.add(app)::1",
"api/apps.py::create_manual_app::persistence::db.session.commit()::1",
+8 -3
View File
@@ -39,11 +39,16 @@ def make_user(db, username=None, role='developer', password='x', **overrides):
return user
def headers_for(user):
"""JWT auth headers for a User row (or a raw user id)."""
def access_token_for(user, **token_options):
"""Mint through one door, allowing explicit malformed/expired test claims."""
from flask_jwt_extended import create_access_token
user_id = getattr(user, 'id', user)
return {'Authorization': f'Bearer {create_access_token(identity=user_id)}'}
return create_access_token(identity=user_id, **token_options)
def headers_for(user, **token_options):
"""JWT auth headers for a User row (or a raw user id)."""
return {'Authorization': f'Bearer {access_token_for(user, **token_options)}'}
def make_workspace(db, name='ws', created_by=None, **overrides):
+3 -2
View File
@@ -254,11 +254,12 @@ def test_cancelled_confirmation_does_not_create_pending_action(app):
def test_stream_tool_rejects_logged_out_session_after_approval(app, no_pii):
from flask_jwt_extended import create_access_token, decode_token
from flask_jwt_extended import decode_token
from factories import access_token_for
from app.models import RevokedSession
with app.app_context():
user = make_user(db, role='admin')
claims = decode_token(create_access_token(identity=user.id))
claims = decode_token(access_token_for(user))
called = []
def logout():
+20
View File
@@ -264,6 +264,26 @@ class TestFromRepositoryDeployWiring:
assert res.status_code == 201
assert res.get_json()['deploy_job_id'] is None
@pytest.mark.parametrize('stage', ['deployment', 'build'])
def test_setup_failure_unwinds_committed_app(self, app, client, auth_headers, monkeypatch, stage):
from unittest.mock import Mock
from app.models import Application
from app.services.git_service import GitService
from app.services.build_service import BuildService
self._mock_create_stack(monkeypatch, enqueue_result={'success': True, 'job_id': 'unused'})
cleanup = Mock()
monkeypatch.setattr(GitService, 'remove_deployment', cleanup)
service, method = ((GitService, 'configure_deployment') if stage == 'deployment'
else (BuildService, 'configure_build'))
monkeypatch.setattr(service, method, Mock(return_value={'success': False, 'error': 'setup rejected'}))
res = client.post('/api/v1/apps/from-repository', headers=auth_headers, json={
'name': 'repo-failed', 'repo_url': 'https://github.com/acme/repo-failed.git',
})
assert res.status_code == 400
assert res.get_json()['error'] == 'setup rejected'
assert Application.query.filter_by(name='repo-failed').first() is None
cleanup.assert_called_once()
class TestListJobsAppIdFilter:
def test_service_filter(self, app):
@@ -0,0 +1,121 @@
"""Legacy schedule API and execution agree across zones and DST boundaries."""
from copy import deepcopy
from datetime import datetime, timezone
from unittest.mock import Mock
from zoneinfo import ZoneInfo
import pytest
from app.services import backup_schedule_service as schedules
from app.services.backup_service import BackupService
def schedule(**overrides):
return {'id': 'nightly', 'enabled': True, 'schedule_time': '02:30',
'days': ['daily'], **overrides}
@pytest.mark.parametrize('zone_name,now,expected', [
('America/New_York', '2026-09-05T03:00:00+00:00', '2026-09-05T02:30:00-04:00'),
('Asia/Kolkata', '2026-09-05T00:00:00+00:00', '2026-09-06T02:30:00+05:30'),
# 02:30 never occurs on the spring-forward date.
('America/New_York', '2026-03-08T05:00:00+00:00', '2026-03-09T02:30:00-04:00'),
])
def test_next_run_uses_server_zone(zone_name, now, expected):
result = schedules.next_run(schedule(), now=datetime.fromisoformat(now), zone=ZoneInfo(zone_name))
assert result.isoformat() == expected
@pytest.mark.parametrize('now', ['2026-03-08T05:00:00+00:00', '2026-03-01T09:00:00+00:00'])
def test_weekly_spring_gap_skips_to_next_week(now):
result = schedules.next_run(schedule(days=['sunday']),
now=datetime.fromisoformat(now),
zone=ZoneInfo('America/New_York'))
assert result.isoformat() == '2026-03-15T02:30:00-04:00'
def test_fall_back_retains_both_legacy_wall_clock_runs_without_double_enqueue():
zone = ZoneInfo('America/New_York')
entry = schedule(schedule_time='01:30')
first = datetime.fromisoformat('2026-11-01T05:30:10+00:00')
assert schedules.next_run(entry, now=first, zone=zone).isoformat() == '2026-11-01T01:30:00-04:00'
entry['last_run'] = first.isoformat()
assert schedules.next_run(entry, now=first, zone=zone).isoformat() == '2026-11-01T01:30:00-05:00'
second = datetime.fromisoformat('2026-11-01T06:30:10+00:00')
assert schedules.next_run(entry, now=second, zone=zone).isoformat() == '2026-11-01T01:30:00-05:00'
entry['last_run'] = second.isoformat()
assert schedules.next_run(entry, now=second, zone=zone).isoformat() == '2026-11-02T01:30:00-05:00'
def test_naive_legacy_last_run_is_server_local_and_suppresses_current_minute():
result = schedules.next_run(schedule(last_run='2026-09-05T02:30:05'),
now=datetime.fromisoformat('2026-09-05T06:30:10+00:00'),
zone=ZoneInfo('America/New_York'))
assert result.isoformat() == '2026-09-06T02:30:00-04:00'
@pytest.mark.parametrize('overrides', [
{'schedule_time': '25:00'}, {'schedule_time': '2:30'}, {'schedule_time': None},
{'days': []}, {'days': 'daily'}, {'days': ['MONDAY']}, {'days': [None]},
])
def test_invalid_legacy_entries_are_visible_but_never_due(overrides):
entry = schedule(**overrides)
result = schedules.describe_schedule(entry, zone=ZoneInfo('UTC'))
assert result['next_run_at'] is None
assert result['schedule_error']
assert result['timezone'] == 'UTC'
assert 'next_run_at' not in entry
@pytest.mark.parametrize('global_enabled,entry_enabled', [(False, True), (True, False)])
def test_disabled_schedules_have_no_countdown(global_enabled, entry_enabled):
result = schedules.describe_schedule(schedule(enabled=entry_enabled),
globally_enabled=global_enabled, zone=ZoneInfo('UTC'))
assert result['next_run_at'] is None
def test_invalid_create_or_update_does_not_write_or_mutate_config(monkeypatch):
config = {'enabled': True, 'schedules': [schedule()]}
original = deepcopy(config)
save = Mock()
monkeypatch.setattr(BackupService, 'get_config', lambda: config)
monkeypatch.setattr(BackupService, 'save_config', save)
assert not BackupService.add_schedule('test', 'files', '/srv', '24:01')['success']
assert not BackupService.add_schedule('test', 'files', '/srv', '02:30', days=[])['success']
assert not BackupService.update_schedule('nightly', {'schedule_time': '99:99'})['success']
assert config == original
save.assert_not_called()
def test_list_and_scheduler_use_same_due_instant_without_persisting_metadata(monkeypatch):
from app.jobs.service import JobService
from app.services import backup_service
zone = ZoneInfo('America/New_York')
now = datetime.fromisoformat('2026-09-05T06:30:10+00:00')
clock = Mock()
clock.now.return_value = now
monkeypatch.setattr(backup_service, 'datetime', clock)
monkeypatch.setattr(schedules, 'server_timezone', lambda: zone)
config = {'enabled': True, 'schedules': [schedule()]}
monkeypatch.setattr(BackupService, 'get_config', lambda: config)
monkeypatch.setattr(BackupService, 'save_config', Mock())
enqueue = Mock()
monkeypatch.setattr(JobService, 'enqueue', enqueue)
shown = BackupService.get_schedules()[0]
assert shown['next_run_at'] == '2026-09-05T02:30:00-04:00'
BackupService.check_backup_schedules()
enqueue.assert_called_once()
assert datetime.fromisoformat(config['schedules'][0]['last_run']).astimezone(timezone.utc) == now
assert 'next_run_at' not in config['schedules'][0]
BackupService.check_backup_schedules()
enqueue.assert_called_once()
assert BackupService.get_schedules()[0]['next_run_at'] == '2026-09-06T02:30:00-04:00'
def test_empty_schedule_api_still_exposes_server_timezone(client, auth_headers, monkeypatch):
monkeypatch.setattr(BackupService, 'get_config', lambda: {'enabled': True, 'schedules': []})
monkeypatch.setattr(schedules, 'server_timezone', lambda: ZoneInfo('Asia/Kolkata'))
response = client.get('/api/v1/backups/schedules', headers=auth_headers)
assert response.status_code == 200
assert response.get_json() == {'schedules': [], 'timezone': 'Asia/Kolkata'}
+3 -3
View File
@@ -11,7 +11,7 @@ import pytest
import app.sockets as sk
from app import db
from factories import make_user
from factories import make_user, access_token_for
BACKEND = Path(__file__).resolve().parents[1]
@@ -38,11 +38,11 @@ def _run(app, handler, data=None, sid='sid-1'):
def _authed(sid='sid-1', role='developer'):
with sk._connected_clients_lock:
from flask_jwt_extended import create_access_token, decode_token
from flask_jwt_extended import decode_token
user = make_user(db, role=role)
sk.connected_clients[sid] = {
'user_id': user.id, 'role': role,
'claims': decode_token(create_access_token(identity=user.id)),
'claims': decode_token(access_token_for(user)),
}
+24 -9
View File
@@ -88,9 +88,8 @@ class _FakeReq:
class TestSubscribeDeployAuth:
"""The socketio test client is unusable on this Flask/Werkzeug pairing
(`ctx.session` became read-only), so exercise the handler's auth guard
directly by faking the socket context (request.sid / emit / join_room)."""
"""Exercise handler gates directly, with the same persisted user, session
claims and resource checks as a socket authenticated at connect time."""
def _patch(self, monkeypatch, sid):
import app.sockets as sk
@@ -100,6 +99,11 @@ class TestSubscribeDeployAuth:
monkeypatch.setattr(sk, 'request', _FakeReq(sid))
return sk, emitted, joined
def _authenticate(self, sk, sid, auth_headers):
from flask_jwt_extended import decode_token
claims = decode_token(auth_headers['Authorization'].removeprefix('Bearer '))
sk.connected_clients[sid] = {'user_id': int(claims['sub']), 'role': 'admin', 'claims': claims}
def test_unauthenticated_join_rejected(self, app, monkeypatch):
sk, emitted, joined = self._patch(monkeypatch, 'sid-unauth')
sk.connected_clients.pop('sid-unauth', None) # not authenticated
@@ -107,19 +111,30 @@ class TestSubscribeDeployAuth:
assert joined == [] # never joined a room
assert any(a and a[0] == 'error' for a, k in emitted)
def test_authenticated_join_succeeds(self, app, monkeypatch):
def test_authenticated_join_succeeds(self, app, auth_headers, monkeypatch):
sk, emitted, joined = self._patch(monkeypatch, 'sid-auth')
sk.connected_clients['sid-auth'] = {'user_id': 1, 'role': 'admin'}
job = _make_job()
self._authenticate(sk, 'sid-auth', auth_headers)
try:
sk.CHANNELS['deploy']['subscribe']({'job_id': 'job-xyz'})
sk.CHANNELS['deploy']['subscribe']({'job_id': job.id})
finally:
sk.connected_clients.pop('sid-auth', None)
assert 'deploy_job-xyz' in joined
assert f'deploy_{job.id}' in joined
assert any(a and a[0] == 'subscribed' for a, k in emitted)
def test_missing_job_id_rejected(self, app, monkeypatch):
def test_authenticated_unknown_job_is_rejected(self, app, auth_headers, monkeypatch):
sk, emitted, joined = self._patch(monkeypatch, 'sid-unknown-job')
self._authenticate(sk, 'sid-unknown-job', auth_headers)
try:
sk.CHANNELS['deploy']['subscribe']({'job_id': 'no-such-job'})
finally:
sk.connected_clients.pop('sid-unknown-job', None)
assert joined == []
assert any(a and a[0] == 'error' for a, k in emitted)
def test_missing_job_id_rejected(self, app, auth_headers, monkeypatch):
sk, emitted, joined = self._patch(monkeypatch, 'sid-auth2')
sk.connected_clients['sid-auth2'] = {'user_id': 1, 'role': 'admin'}
self._authenticate(sk, 'sid-auth2', auth_headers)
try:
sk.CHANNELS['deploy']['subscribe']({})
finally:
+3 -2
View File
@@ -116,8 +116,9 @@ def test_appless_job_detail_requester_or_admin(client, job_rbac):
"""An app-less job belongs to its requester (or a panel admin)."""
s = job_rbac.s
url = f'/api/v1/deployment-jobs/{job_rbac.owner_job_id}'
assert client.get(url, headers=s.owner).status_code == 200
assert client.get(url, headers=s.admin).status_code == 200
for headers in (s.owner, s.admin):
response = client.get(url, headers=headers)
assert response.status_code == 200, response.get_json()
for persona in ('member', 'viewer', 'foreign'):
assert client.get(url, headers=getattr(s, persona)).status_code == 403, persona
@@ -664,7 +664,7 @@ class TestApi:
headers=auth_headers,
)
assert resp.status_code == 200
assert resp.status_code == 200, resp.get_json()
body = resp.get_json()
assert body['success'] is True
assert body['connection']['id'] == second.id
+9 -4
View File
@@ -74,7 +74,9 @@ def _deploy_and_probe(repo_path, expect_language, expect_framework=None,
build = subprocess.run(
['docker', 'build', '-f', str(repo_path / 'Dockerfile.serverkit'),
'-t', tag, str(repo_path)],
capture_output=True, text=True, timeout=600,
# Docker emits UTF-8 build logs even when Windows uses a legacy locale.
capture_output=True, text=True, encoding='utf-8', errors='replace',
timeout=600,
)
try:
assert build.returncode == 0, (
@@ -87,13 +89,15 @@ def _deploy_and_probe(repo_path, expect_language, expect_framework=None,
run = subprocess.run(
['docker', 'run', '-d', '--rm', '--name', tag,
'-p', f'127.0.0.1:0:{port}', tag],
capture_output=True, text=True, timeout=60,
capture_output=True, text=True, encoding='utf-8', errors='replace',
timeout=60,
)
assert run.returncode == 0, run.stderr
try:
mapped = subprocess.run(
['docker', 'port', tag, str(port)],
capture_output=True, text=True, timeout=20,
capture_output=True, text=True, encoding='utf-8', errors='replace',
timeout=20,
).stdout.strip().splitlines()[0] # e.g. 127.0.0.1:49321
url = f'http://{mapped}{probe_path}'
@@ -127,7 +131,8 @@ def _deploy_and_probe(repo_path, expect_language, expect_framework=None,
if body is None:
logs = subprocess.run(
['docker', 'logs', tag],
capture_output=True, text=True, timeout=20,
capture_output=True, text=True, encoding='utf-8', errors='replace',
timeout=20,
)
raise AssertionError(
f'container never answered {url}; logs:\n'
@@ -0,0 +1,99 @@
"""Repository registration ordering and compensating cleanup, without HTTP."""
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from app.services import repository_application_service as imports
from app.services.deployment_job_service import DeploymentJobService
from app.services.manifest_persistence_service import ManifestPersistenceService
@pytest.fixture
def runtime(monkeypatch, tmp_path):
app = SimpleNamespace(id=42, root_path=str(tmp_path / 'apps' / 'demo'))
session = Mock()
git = Mock()
build = Mock()
git.configure_deployment.return_value = {'success': True, 'webhook_url': '/hook'}
build.configure_build.return_value = {'success': True, 'config': {'build_method': 'dockerfile'}}
manifest = Mock(return_value={'imported': True})
enqueue = Mock(return_value={'success': True, 'job_id': 'job-42'})
monkeypatch.setattr(imports, 'db', SimpleNamespace(session=session))
monkeypatch.setattr(imports, 'GitService', git)
monkeypatch.setattr(imports, 'BuildService', build)
monkeypatch.setattr(imports.paths, 'APPS_DIR', str(tmp_path / 'apps'))
monkeypatch.setattr(ManifestPersistenceService, 'apply_import', manifest)
monkeypatch.setattr(DeploymentJobService, 'enqueue_app_deploy', enqueue)
options = dict(user_id=7, repo_url='https://github.com/acme/demo', branch='release',
auto_deploy=False, manifest={'strategy': 'dockerfile'},
build_options={'build_method': 'dockerfile', 'custom_start_cmd': 'serve'})
return SimpleNamespace(app=app, session=session, git=git, build=build,
manifest=manifest, enqueue=enqueue, options=options)
def test_commit_precedes_configuration_and_observable_deployment(runtime):
effects = Mock()
for name, method in [('commit', runtime.session.commit),
('deploy', runtime.git.configure_deployment),
('build', runtime.build.configure_build),
('manifest', runtime.manifest), ('enqueue', runtime.enqueue)]:
effects.attach_mock(method, name)
result = imports.finalize_repository_application(runtime.app, **runtime.options)
assert [call[0] for call in effects.mock_calls] == ['commit', 'deploy', 'build', 'manifest', 'enqueue']
assert result['deploy_job_id'] == 'job-42'
assert result['manifest_import'] == {'imported': True}
runtime.git.configure_deployment.assert_called_once_with(
app_id=42, app_path=runtime.app.root_path, repo_url=runtime.options['repo_url'],
branch='release', auto_deploy=False)
runtime.build.configure_build.assert_called_once_with(
app_id=42, app_path=runtime.app.root_path, **runtime.options['build_options'])
runtime.enqueue.assert_called_once_with(runtime.app, user_id=7, trigger='install')
@pytest.mark.parametrize('stage', ['deploy', 'build'])
def test_mandatory_setup_failure_propagates_for_compensation(runtime, stage):
method = runtime.git.configure_deployment if stage == 'deploy' else runtime.build.configure_build
method.return_value = {'success': False, 'error': 'setup failed'}
with pytest.raises(RuntimeError, match='setup failed'):
imports.finalize_repository_application(runtime.app, **runtime.options)
runtime.manifest.assert_not_called()
runtime.enqueue.assert_not_called()
@pytest.mark.parametrize('queue_failure', [False, True])
def test_optional_enrichment_and_queue_failure_keep_registered_app(runtime, queue_failure):
runtime.manifest.side_effect = RuntimeError('unsupported manifest')
if queue_failure:
runtime.enqueue.side_effect = RuntimeError('queue unavailable')
else:
runtime.enqueue.return_value = {'success': False, 'error': 'queue unavailable'}
result = imports.finalize_repository_application(runtime.app, **runtime.options)
assert result['manifest_import'] is None
assert result['deploy_job_id'] is None
runtime.session.commit.assert_called_once()
runtime.session.rollback.assert_not_called()
runtime.git.remove_deployment.assert_not_called()
@pytest.mark.parametrize('location', ['managed', 'sibling', 'base'])
def test_compensation_hard_deletes_record_but_only_removes_managed_child_path(runtime, monkeypatch, location):
from pathlib import Path
base = Path(imports.paths.APPS_DIR)
target = {'managed': base / 'demo', 'sibling': base.with_name('apps-other'), 'base': base}[location]
target.mkdir(parents=True)
marker = target / 'keep.txt'
marker.write_text('source')
runtime.app.root_path = str(target)
query = Mock()
query.get.return_value = runtime.app
monkeypatch.setattr(imports, 'Application', SimpleNamespace(query=query))
effects = Mock()
effects.attach_mock(runtime.session.rollback, 'rollback')
effects.attach_mock(runtime.git.remove_deployment, 'deployment')
effects.attach_mock(runtime.session.delete, 'delete')
effects.attach_mock(runtime.session.commit, 'commit')
imports.abort_repository_creation(runtime.app)
assert [call[0] for call in effects.mock_calls] == ['rollback', 'deployment', 'delete', 'commit']
assert marker.exists() == (location != 'managed')
+76
View File
@@ -0,0 +1,76 @@
"""Profiling is opt-in, request-local and never discloses SQL or parameters."""
import re
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from app.middleware.request_profiling import register_request_profiling
def profile_app(enabled):
app = Flask(__name__)
app.config.update(SQLALCHEMY_DATABASE_URI='sqlite://', PROFILE_REQUESTS=enabled)
database = SQLAlchemy(app)
register_request_profiling(app, database)
register_request_profiling(app, database) # registration must be idempotent
@app.get('/api/v1/query')
def query():
database.session.execute(text('SELECT :secret'), {'secret': 'private-value'})
database.session.execute(text('SELECT 2'))
return 'ok', 200, {'Server-Timing': 'existing;dur=1'}
@app.get('/api/v1/empty')
def empty():
return 'ok'
@app.get('/api/v1/fail')
def fail():
try:
database.session.execute(text('SELECT * FROM private_missing_table'))
except SQLAlchemyError:
return 'failed', 400
raise AssertionError('Expected invalid SQL')
@app.get('/page')
def page():
database.session.execute(text('SELECT 1'))
return 'page'
return app, database
def test_default_off_installs_no_profile_and_preserves_headers():
app, _database = profile_app(False)
response = app.test_client().get('/api/v1/query')
assert response.headers.getlist('Server-Timing') == ['existing;dur=1']
assert 'serverkit_request_profiling' not in app.extensions
def test_counts_queries_without_leaking_sql_and_isolates_requests():
app, database = profile_app(True)
client = app.test_client()
# A job/startup query must not be attributed to the next HTTP request.
with app.app_context():
database.session.execute(text('SELECT 1'))
response = client.get('/api/v1/query')
headers = response.headers.getlist('Server-Timing')
assert headers[0] == 'existing;dur=1'
assert re.fullmatch(r'app;dur=\d+\.\d{3}, db;dur=\d+\.\d{3};desc="2 queries"', headers[1])
assert 'private' not in str(headers)
assert 'SELECT' not in str(headers)
assert 'desc="0 queries"' in client.get('/api/v1/empty').headers['Server-Timing']
assert 'Server-Timing' not in client.get('/page').headers
def test_failed_sql_is_counted_and_does_not_leak_into_the_next_request():
app, _database = profile_app(True)
client = app.test_client()
response = client.get('/api/v1/fail')
assert response.status_code == 400
assert 'desc="1 queries"' in response.headers['Server-Timing']
assert 'private_missing_table' not in response.headers['Server-Timing']
assert 'desc="0 queries"' in client.get('/api/v1/empty').headers['Server-Timing']
+6 -4
View File
@@ -437,8 +437,10 @@ def test_deleted_and_inactive_jwt_users_fail_closed_on_get(
response = client.get(
f'/api/v1/restore-points/{point.id}', headers=inactive_headers,
)
assert response.status_code == 403
assert response.get_json()['error'] == 'Account is deactivated'
# Session validation rejects inactive/deleted identities before the route's
# resource policy runs. They no longer carry authenticated credentials.
assert response.status_code == 401
assert response.get_json()['msg'] == 'Token has been revoked'
deleted = make_user(db_session, role='developer')
deleted_headers = headers_for(deleted)
@@ -447,8 +449,8 @@ def test_deleted_and_inactive_jwt_users_fail_closed_on_get(
response = client.get(
f'/api/v1/restore-points/{point.id}', headers=deleted_headers,
)
assert response.status_code == 403
assert response.get_json()['error'] == 'Authenticated user not found'
assert response.status_code == 401
assert response.get_json()['msg'] == 'Token has been revoked'
def test_developer_api_key_reaches_policy_guarded_post(
@@ -117,7 +117,6 @@ APPLICATION_BASELINE = {
'test_site_base_domains.py',
'test_site_routing.py',
'test_sites_https.py',
'test_soft_delete_leaks.py',
'test_ssl_acme_contact.py',
'test_url_swap.py',
'test_workspace_scope.py',
+5 -5
View File
@@ -3,9 +3,9 @@ from datetime import timedelta
import pyotp
import pytest
from flask_jwt_extended import create_access_token, decode_token
from flask_jwt_extended import decode_token
from factories import make_user, headers_for
from factories import make_user, headers_for, access_token_for
from app.services.api_key_service import ApiKeyService
@@ -144,11 +144,11 @@ def test_revoked_pending_mfa_cannot_finish_login(client, db_session, change):
def test_unexpired_legacy_or_expired_mfa_tokens_are_rejected(client, db_session):
user = make_user(db_session, totp_enabled=True, totp_secret=pyotp.random_base32())
legacy = create_access_token(identity=user.id, additional_claims={'auth_version': None})
legacy = access_token_for(user, additional_claims={'auth_version': None})
assert client.get('/api/v1/auth/me', headers=_headers(legacy)).status_code == 401
for expiration in (False, timedelta(seconds=-1)):
pending = create_access_token(identity=user.id, additional_claims={'2fa_pending': True},
expires_delta=expiration)
pending = access_token_for(user, additional_claims={'2fa_pending': True},
expires_delta=expiration)
response = client.post('/api/v1/auth/2fa/verify', json={
'temp_token': pending, 'code': pyotp.TOTP(user.totp_secret).now(),
})
+3 -3
View File
@@ -165,9 +165,9 @@ def test_detach_requires_group_and_target_write(client, sr_rbac):
def test_create_group_requires_scope_write(client, sr_rbac):
s = sr_rbac.s
body = {'scope_type': 'workspace', 'scope_id': str(s.ws_id), 'name': 'New'}
assert client.post(G, json=body, headers=s.member).status_code == 201
assert client.post(G, json=body, headers=s.viewer).status_code == 403
assert client.post(G, json=body, headers=s.foreign).status_code == 403
for persona, status in [('member', 201), ('viewer', 403), ('foreign', 403)]:
response = client.post(G, json=body, headers=getattr(s, persona))
assert response.status_code == status, (persona, response.get_json())
def test_create_group_application_scope_foreign_denied(client, sr_rbac):
+3 -3
View File
@@ -3,14 +3,14 @@
from datetime import datetime, timedelta, timezone
import pytest
from flask import g
from flask_jwt_extended import create_access_token, verify_jwt_in_request
from flask_jwt_extended import verify_jwt_in_request
from app.services.cf_ops_change_service import CfOpsChangeService
from app.services.connect_format import iso_datetime
from app.services.resource_tier_service import ResourceTierService
from app.services.shared_resource_service import _current_user_id
from app.utils.actor import current_actor_id
from factories import make_user
from factories import make_user, headers_for
@pytest.mark.parametrize('lookup', [current_actor_id, _current_user_id, CfOpsChangeService._current_user_id])
@@ -23,7 +23,7 @@ def test_shared_actor_preserves_authenticated_owner(app, db_session, kind):
user = make_user(db_session, role='admin', username=f'actor-{kind}')
headers = {}
if kind == 'jwt':
headers['Authorization'] = f'Bearer {create_access_token(identity=str(user.id))}'
headers = headers_for(str(user.id))
with app.test_request_context(headers=headers):
if kind == 'jwt':
verify_jwt_in_request()
+6 -6
View File
@@ -1,10 +1,10 @@
"""Socket authorization must hold both when joining and during delivery."""
import pytest
from flask_jwt_extended import create_access_token, create_refresh_token
from flask_jwt_extended import create_refresh_token
from app import sockets as sk
from app.models.deployment_job import DeploymentJob
from factories import make_user, make_application, headers_for
from factories import make_user, make_application, headers_for, access_token_for
@pytest.fixture
@@ -13,7 +13,7 @@ def clients(app):
def connect(user=None, token=None):
client = sk.socketio.test_client(app, auth={
'token': token or create_access_token(identity=user.id),
'token': token or access_token_for(user),
})
opened.append(client)
if client.is_connected():
@@ -30,11 +30,11 @@ def clients(app):
def test_socket_rejects_non_session_tokens(db_session, clients, kind):
user = make_user(db_session, role='admin')
if kind == 'pending':
token = create_access_token(identity=user.id, additional_claims={'2fa_pending': True})
token = access_token_for(user, additional_claims={'2fa_pending': True})
elif kind == 'refresh':
token = create_refresh_token(identity=user.id)
else:
token = create_access_token(identity=user.id, expires_delta=False)
token = access_token_for(user, expires_delta=False)
assert not clients(token=token).is_connected()
@@ -208,7 +208,7 @@ def test_run_polling_cannot_bypass_socket_gate(client, db_session):
def test_logout_revokes_existing_socket(client, db_session, clients):
user = make_user(db_session)
token = create_access_token(identity=user.id)
token = access_token_for(user)
sock = clients(token=token)
response = client.post('/api/v1/auth/logout', headers={'Authorization': f'Bearer {token}'})
assert response.status_code == 200
+6 -7
View File
@@ -11,20 +11,20 @@ import pytest
from app import db
from app.models import Application, Domain
from app.services.domain_attach_service import DomainAttachService
from factories import make_application
@pytest.fixture
def deleted_domain(app):
with app.app_context():
application = Application(name='shop', app_type='docker', port=8001, user_id=1)
db.session.add(application)
db.session.flush()
application = make_application(db, name='shop', port=8001)
gone = Domain(name='old.example.com', application_id=application.id, is_primary=True)
db.session.add(gone)
db.session.commit()
gone.soft_delete()
db.session.commit()
yield {'app_id': application.id, 'domain_id': gone.id, 'name': gone.name}
yield {'app_id': application.id, 'user_id': application.user_id,
'domain_id': gone.id, 'name': gone.name}
def test_app_payload_hides_deleted_domains(app, deleted_domain):
@@ -42,9 +42,8 @@ def test_a_deleted_name_is_not_a_clash(app, deleted_domain):
"""Migration 083 made the unique index partial so deleting frees the name.
An application-level clash check must not re-impose the burn."""
with app.app_context():
other = Application(name='other', app_type='docker', port=8002, user_id=1)
db.session.add(other)
db.session.commit()
make_application(db, name='other', port=8002,
user_id=deleted_domain['user_id'])
clash = Domain.query_active().filter_by(name=deleted_domain['name']).first()
assert clash is None, 'a tombstone still blocks the name for another app'
+1
View File
@@ -796,6 +796,7 @@ Regenerate (backend/):
- `POST /auth/login`
- `POST /auth/login-links`
- `POST /auth/login-links/redeem`
- `POST /auth/logout`
- `POST /auth/passkeys/authenticate`
- `POST /auth/passkeys/options/authenticate`
- `POST /auth/passkeys/options/register`
+5 -5
View File
@@ -18,11 +18,11 @@ debt. Regenerate with `python scripts/generate-migration-inventory.py`.
| API crashes swallowed without recording | app.error_reporting | 0 | 0 | INVARIANT at 0 |
| Hand-shaped {'error': ...} bodies in app/api | typed errors + the global handler | 1141 | 1150 | migrate when touched; new endpoints raise |
| Raw subprocess calls outside the runners | app/utils/system.py runners | 24 | 24 | migrate when touched |
| Controller-boundary violations (routes doing service work) | service layer extraction | 503 | 503 | migrate when touched (first-wave ratchet) |
| raw api.* calls in pages/ | E1: useServerQuery/useServerMutation | 402 | 405 | migrate when touched |
| per-page toast.error extractions in pages/ | E1: query-layer error presentation | 209 | 218 | migrate when touched |
| hand-rolled form-group blocks | F2: FormField/useForm | 326 | 343 | migrate when touched |
| unencoded ?k=${v} query interpolations in services/api | C4: buildQuery/encoding template | 86 | 97 | migrate when touched |
| Controller-boundary violations (routes doing service work) | service layer extraction | 496 | 496 | migrate when touched (first-wave ratchet) |
| raw api.* calls in pages/ | E1: useServerQuery/useServerMutation | 398 | 398 | migrate when touched |
| per-page toast.error extractions in pages/ | E1: query-layer error presentation | 206 | 206 | migrate when touched |
| hand-rolled form-group blocks | F2: FormField/useForm | 321 | 321 | migrate when touched |
| unencoded ?k=${v} query interpolations in services/api | C4: buildQuery/encoding template | 0 | 0 | INVARIANT at 0 |
| raw setInterval pollers | E2: usePolling/refetchInterval | 7 | 7 | DELIBERATE RESIDUE: clock ticks, socket-fallback hooks, and sibling-repo extension timers - each listed per file |
| direct navigator.clipboard call sites | F3: copyToClipboard | 0 | 0 | INVARIANT at 0 |
| hex colour literals outside token files | G: var(--token) | 150 | 150 | migrate when touched |
+2
View File
@@ -12,7 +12,9 @@
"lint:baseline": "node scripts/lint.mjs --update",
"test:controls": "node scripts/controls-browser.mjs",
"test:metrics": "node scripts/metrics-browser.mjs",
"measure:build": "node scripts/measure-build.mjs",
"test:hooks": "node scripts/hooks-browser.mjs",
"test:backups": "node scripts/backups-browser.mjs",
"lint:settings-index": "node scripts/check-settings-index.mjs",
"lint:fonts": "node scripts/check-font-integrity.mjs",
"lint:status": "node scripts/check-status-one-door.mjs",
+108
View File
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createServer } from 'vite';
import react from '@vitejs/plugin-react';
import { chromium } from 'playwright';
// Real query/mutation/form hooks and the production schedule modal. Only HTTP,
// current workspace, toast output and translation are controlled boundaries.
const root = fileURLToPath(new URL('../', import.meta.url));
const fixture = `
import React,{useState} from 'react';
import {createRoot} from 'react-dom/client';
import {WorkspaceTestContext} from '/src/contexts/useWorkspace.js';
import {useBackupSchedules} from '/src/hooks/useBackupSchedules.js';
import AddScheduleModal from '/src/components/backups/AddScheduleModal.jsx';
window.calls=[];window.messages=[];window.workspace='one';
window.toast={error:value=>window.messages.push(['error',value]),success:value=>window.messages.push(['success',value])};
window.rows={one:[{id:1,name:'Daily',enabled:true,next_run_at:'2026-09-06T02:00:00-04:00',timezone:'America/New_York'}],two:[{id:2,name:'Other workspace',enabled:true}]};
window.testApi={
getBackupSchedules:()=>{window.calls.push(['get',window.workspace]);const result={schedules:window.rows[window.workspace].map(row=>({...row})),timezone:'America/New_York'};return window.holdRead?new Promise(resolve=>window.finishRead=()=>resolve(result)):Promise.resolve(result)},
addBackupSchedule:(...args)=>{window.calls.push(['create',...args]);return new Promise((resolve,reject)=>{window.finishCreate=()=>{window.rows.one.push({id:3,name:args[0],enabled:true});resolve({id:3})};window.failCreate=()=>reject(Object.assign(new Error('Please fix the field'),{fieldErrors:{name:'Already exists'}}))})},
updateBackupSchedule:(id,body)=>{window.calls.push(['toggle',id,body]);window.rows[window.workspace]=window.rows[window.workspace].map(row=>row.id===id?{...row,...body}:row);return Promise.resolve({})},
removeBackupSchedule:id=>{window.calls.push(['remove',id]);window.rows[window.workspace]=window.rows[window.workspace].filter(row=>row.id!==id);return Promise.resolve({})}
};
function Screen(){const store=useBackupSchedules();const [open,setOpen]=useState(true);return <>
<output id="rows">{JSON.stringify(store.schedules)}</output>
<button id="toggle" onClick={()=>store.toggle(store.schedules[0])}>Toggle</button>
<button id="remove" onClick={()=>store.remove(store.schedules[0].id)}>Remove</button>
<button id="open" onClick={()=>setOpen(true)}>Create</button>
<AddScheduleModal open={open} onClose={()=>setOpen(false)} onCreate={store.create} onCreated={()=>setOpen(false)} remoteEnabled timezone={store.timezone}/>
</>}
function App(){const [workspace,setWorkspace]=useState('one');window.changeWorkspace=()=>{window.workspace='two';setWorkspace('two')};return <WorkspaceTestContext.Provider value={{activeWorkspaceId:workspace}}><Screen/></WorkspaceTestContext.Provider>}
createRoot(document.getElementById('root')).render(<React.StrictMode><App/></React.StrictMode>);
`;
const server = await createServer({
root, configFile: false, logLevel: 'error', resolve: { alias: { '@': path.join(root, 'src') } },
cacheDir: path.join(root, 'node_modules/.vite-backups-regression'),
optimizeDeps: { entries: [] }, server: { host: '127.0.0.1', port: 0 },
plugins: [{
name: 'backup-regression-fixtures', enforce: 'pre',
resolveId(id) {
if (id === 'react-i18next') return '\0test-i18n';
if (id.endsWith('/backups-fixture.jsx')) return path.join(root, 'backups-fixture.jsx');
},
load(id) {
const normalized = id.replaceAll('\\', '/');
if (id === '\0test-i18n') return `const t=(key,fallback)=>typeof fallback==='string'?fallback:key;export const useTranslation=()=>({t});`;
if (normalized.endsWith('/src/contexts/useWorkspace.js')) return `import {createContext,useContext} from 'react';export const WorkspaceTestContext=createContext({});export const useWorkspace=()=>useContext(WorkspaceTestContext);`;
if (normalized.endsWith('/src/contexts/useToast.js')) return `export const useToast=()=>window.toast;`;
if (normalized.endsWith('/src/services/api.js') || normalized.endsWith('/src/services/api/index.js')) return `export default new Proxy({}, {get:(_,key)=>(...args)=>window.testApi[key](...args)});`;
if (normalized.endsWith('/backups-fixture.jsx')) return fixture;
},
configureServer(vite) {
vite.middlewares.use('/backups-check', async (_req, res) => {
res.setHeader('Content-Type', 'text/html');
res.end(await vite.transformIndexHtml('/backups-check', '<div id="root"></div><script type="module" src="/backups-fixture.jsx"></script>'));
});
},
}, react()],
});
const executablePath = [process.env.CHROME_PATH, chromium.executablePath(), 'C:/Program Files/Google/Chrome/Application/chrome.exe', 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe'].find(candidate => candidate && existsSync(candidate));
let browser;
try {
await server.listen();
browser = await chromium.launch({ ...(executablePath ? { executablePath } : {}), headless: true });
const page = await browser.newPage();
const errors = [];
page.on('pageerror', error => errors.push(error.message));
await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/backups-check`);
await page.getByLabel('Schedule Name', { exact: false }).fill('Daily 2');
await page.getByLabel('Application Name', { exact: false }).fill('my-app');
assert.equal(await page.locator('.form-field__hint').textContent(), 'America/New_York');
await page.evaluate(() => {
const form = document.querySelector('[data-walkthrough="backup-schedule-form"]');
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
});
await page.waitForFunction(() => window.calls.filter(([type]) => type === 'create').length === 1);
assert.equal(await page.locator('[data-walkthrough="backup-schedule-submit"]').isDisabled(), true);
await page.evaluate(() => window.failCreate());
await page.getByText('Already exists', { exact: true }).waitFor();
assert.equal(await page.getByLabel('Schedule Name', { exact: false }).inputValue(), 'Daily 2');
await page.getByLabel('Schedule Name', { exact: false }).fill('Unique schedule');
await page.locator('[data-walkthrough="backup-schedule-submit"]').click();
await page.waitForFunction(() => window.calls.filter(([type]) => type === 'create').length === 2);
await page.evaluate(() => window.finishCreate());
await page.locator('[data-walkthrough="backup-schedule-form"]').waitFor({ state: 'hidden' });
await page.waitForFunction(() => document.querySelector('#rows').textContent.includes('Unique schedule'));
console.log('PASS duplicate-submit guard, inline server errors, preserved input, retry and creation cache invalidation');
await page.locator('#toggle').click();
await page.waitForFunction(() => JSON.parse(document.querySelector('#rows').textContent)[0].enabled === false);
await page.locator('#remove').click();
await page.waitForFunction(() => JSON.parse(document.querySelector('#rows').textContent).length === 1);
assert.equal(await page.evaluate(() => window.messages.filter(([type]) => type === 'success').length), 2);
await page.evaluate(() => { window.holdRead = true; window.changeWorkspace(); });
await page.waitForFunction(() => Boolean(window.finishRead));
assert.deepEqual(JSON.parse(await page.locator('#rows').textContent()), []);
await page.evaluate(() => window.finishRead());
await page.waitForFunction(() => document.querySelector('#rows').textContent.includes('Other workspace'));
assert.deepEqual(errors, []);
console.log('PASS toggle/delete query invalidation and workspace isolation while a new workspace request is pending');
} finally {
await browser?.close();
await server.close();
}
@@ -221,25 +221,25 @@ for (const file of LEGACY_POLLERS.keys()) {
const ADOPTION_CEILINGS = [
{
name: 'raw api.* calls in pages/ (E1: useServerQuery/useServerMutation)',
ceiling: 405,
ceiling: 398,
include: (file) => file.startsWith('pages/'),
pattern: /\bapi\s*\.\s*\w+\s*\(/g,
},
{
name: 'per-page toast.error extractions in pages/ (E1: query-layer error presentation)',
ceiling: 218,
ceiling: 206,
include: (file) => file.startsWith('pages/'),
pattern: /toast\s*\.\s*error\s*\(/g,
},
{
name: 'hand-rolled form-group blocks (F2: FormField/useForm)',
ceiling: 343,
ceiling: 321,
include: (file) => file.startsWith('pages/') || file.startsWith('components/'),
pattern: /form-group/g,
},
{
name: 'unencoded ?k=${v} query interpolations in services/api (C4: buildQuery/encoding template)',
ceiling: 97,
ceiling: 0,
include: (file) => file.startsWith('services/api/'),
pattern: /[?&][A-Za-z_]+=\$\{(?!encodeURIComponent)/g,
},
+69
View File
@@ -0,0 +1,69 @@
// Deterministic byte census of a production dist. Gzip is per file, level 9;
// totals are storage/transfer estimates, never a page-load speed measurement.
import { readFileSync, readdirSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
import { resolve, relative, dirname, extname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { gzipSync } from 'node:zlib';
import { createHash } from 'node:crypto';
export function linkedAssets(html) {
const result = new Set();
for (const tag of html.matchAll(/<(?:script|link)\b[^>]*>/gi)) {
const attributes = Object.fromEntries([...tag[0].matchAll(/([\w-]+)\s*=\s*["']([^"']*)["']/g)]
.map((match) => [match[1].toLowerCase(), match[2]]));
const asset = attributes.src || (['stylesheet', 'modulepreload'].includes(attributes.rel) ? attributes.href : null);
if (asset && !/^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(asset)) {
result.add(decodeURIComponent(new URL(asset, 'https://build.invalid/').pathname).slice(1));
}
}
return result;
}
export function measureBuild(dist) {
const root = resolve(dist);
const walk = (directory) => readdirSync(directory).flatMap((name) => {
const path = resolve(directory, name);
return statSync(path).isDirectory() ? walk(path) : [path];
});
const fingerprint = createHash('sha256');
const files = walk(root).filter((path) => ['.js', '.mjs', '.css'].includes(extname(path))).sort().map((path) => {
const bytes = readFileSync(path);
const name = relative(root, path).replaceAll('\\', '/');
fingerprint.update(name).update('\0').update(bytes).update('\0');
return { path: name, bytes: bytes.length, gzip_bytes: gzipSync(bytes, { level: 9 }).length };
});
const linked = linkedAssets(readFileSync(resolve(root, 'index.html'), 'utf8'));
const missing = [...linked].filter((path) => !files.some((file) => file.path === path));
if (missing.length) throw new Error(`HTML-linked code assets missing from build: ${missing.join(', ')}`);
const sum = (items) => ({
files: items.length,
bytes: items.reduce((total, file) => total + file.bytes, 0),
gzip_bytes: items.reduce((total, file) => total + file.gzip_bytes, 0),
});
return {
schema_version: 1,
measured_at_utc: new Date().toISOString(),
node_version: process.version,
measured_code_sha256: fingerprint.digest('hex'),
compression: 'gzip, level 9, each file separately; sizes are bytes',
scope: 'Built JS/MJS/CSS, including public vendor shims. Excludes HTML, fonts, images, maps and other non-code assets.',
html_linked_scope: 'Only script, stylesheet and modulepreload URLs in index.html; not a complete runtime network trace or every later lazy import.',
all_code: sum(files),
html_linked_code: sum(files.filter((file) => linked.has(file.path))),
largest_code_assets: [...files].sort((a, b) => b.bytes - a.bytes).slice(0, 12),
assets: files,
};
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const here = dirname(fileURLToPath(import.meta.url));
const args = process.argv.slice(2);
const value = (flag, fallback) => args.includes(flag) ? args[args.indexOf(flag) + 1] : fallback;
const report = measureBuild(value('--dist', resolve(here, '../dist')));
const output = value('--output', null);
if (output) {
mkdirSync(dirname(resolve(output)), { recursive: true });
writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`);
}
console.log(JSON.stringify({ all_code: report.all_code, html_linked_code: report.html_linked_code }, null, 2));
}
+31
View File
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { linkedAssets, measureBuild } from './measure-build.mjs';
test('separates HTML-linked code from lazy chunks and excludes fonts/images', () => {
const directory = mkdtempSync(join(tmpdir(), 'serverkit-measure-'));
try {
writeFileSync(join(directory, 'index.html'), '<script src="/app.js"></script><link rel="modulepreload" href="/shared.js"><link rel="stylesheet" href="/app.css"><link rel="icon" href="/icon.png">');
for (const file of ['app.js', 'shared.js', 'lazy.js', 'app.css', 'font.woff2', 'icon.png']) {
writeFileSync(join(directory, file), '12345');
}
const report = measureBuild(directory);
assert.equal(report.all_code.files, 4);
assert.equal(report.all_code.bytes, 20);
assert.equal(report.html_linked_code.files, 3);
assert.equal(report.html_linked_code.bytes, 15);
assert.ok(report.all_code.gzip_bytes > report.html_linked_code.gzip_bytes);
} finally { rmSync(directory, { recursive: true }); }
});
test('deduplicates links, ignores external resources and detects missing assets', () => {
assert.deepEqual([...linkedAssets('<script src="./app.js?v=2"></script><link rel="modulepreload" href="/app.js"><script src="https://example.test/external.js"></script>')], ['app.js']);
const directory = mkdtempSync(join(tmpdir(), 'serverkit-measure-'));
try {
writeFileSync(join(directory, 'index.html'), '<script src="/missing.js"></script>');
assert.throws(() => measureBuild(directory), /missing.js/);
} finally { rmSync(directory, { recursive: true }); }
});
@@ -0,0 +1,59 @@
import { useTranslation } from 'react-i18next';
import Modal from '@/components/Modal';
import { FormField } from '@/components/FormField';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import useForm from '@/hooks/useForm';
export default function AddScheduleModal({ open, onClose, onCreate, onCreated, remoteEnabled, timezone }) {
const { t } = useTranslation();
const form = useForm({
initialValues: { name: '', backupType: 'application', target: '', scheduleTime: '02:00', days: ['daily'], uploadRemote: false },
onSubmit: async (values) => {
await onCreate(values);
form.reset();
onCreated();
},
});
const targetLabel = form.values.backupType === 'files'
? t('app.backups.schedulePaths', 'Paths (comma-separated)')
: form.values.backupType === 'database'
? t('app.backups.scheduleDatabase', 'Database (format: mysql:dbname or postgresql:dbname)')
: t('app.backups.applicationName', 'Application Name');
return (
<Modal open={open} onClose={() => { if (!form.isSubmitting) onClose(); }} title={t('app.backups.addBackupSchedule', 'Add Backup Schedule')}>
<form onSubmit={form.handleSubmit} data-walkthrough="backup-schedule-form">
{form.submitError && <p className="error-message" role="alert">{form.submitError}</p>}
<FormField htmlFor="backup-schedule-name" label={t('app.backups.scheduleName', 'Schedule Name')} error={form.getFieldError('name')} required>
<Input id="backup-schedule-name" type="text" {...form.getFieldProps('name')} placeholder={t('app.backups.dailyAppBackup', 'Daily App Backup')} required />
</FormField>
<FormField htmlFor="backup-schedule-type" label={t('app.backups.backupType', 'Backup Type')} error={form.getFieldError('backupType')}>
<select id="backup-schedule-type" {...form.getFieldProps('backupType')}>
<option value="application">{t('app.backups.application', 'Application')}</option>
<option value="database">{t('app.backups.database', 'Database')}</option>
<option value="files">{t('app.backups.filesDirectories', 'Files / Directories')}</option>
</select>
</FormField>
<FormField htmlFor="backup-schedule-target" label={targetLabel} error={form.getFieldError('target')} required>
<Input id="backup-schedule-target" type="text" {...form.getFieldProps('target')} placeholder={form.values.backupType === 'files' ? '/etc/nginx,/var/www/config' : form.values.backupType === 'database' ? 'mysql:mydb' : 'my-app'} required />
</FormField>
<FormField htmlFor="backup-schedule-time" label={t('common.labels.time', 'Time')} error={form.getFieldError('scheduleTime')} hint={timezone} required>
<Input id="backup-schedule-time" type="time" {...form.getFieldProps('scheduleTime')} required />
</FormField>
{remoteEnabled && (
<FormField>
<label className="checkbox-label">
<input type="checkbox" name="uploadRemote" checked={form.values.uploadRemote} onChange={form.handleChange} />
<span>{t('app.backups.uploadToRemoteStorageAfterBackup', 'Upload to remote storage after backup')}</span>
</label>
</FormField>
)}
<div className="modal-actions">
<Button type="button" variant="outline" onClick={onClose} disabled={form.isSubmitting}>{t('common.actions.cancel', 'Cancel')}</Button>
<Button type="submit" disabled={form.isSubmitting} data-walkthrough="backup-schedule-submit">{t('app.backups.addSchedule', 'Add Schedule')}</Button>
</div>
</form>
</Modal>
);
}
@@ -249,7 +249,7 @@ export default function BackupsOverview({
const nextScheduled = (() => {
const upcoming = (schedules || [])
.filter((s) => s.enabled !== false)
.map((s) => ({ schedule: s, at: nextFire(s, now) }))
.map((s) => ({ schedule: s, at: nextFire(s) }))
.filter((x) => x.at)
.sort((a, b) => a.at - b.at);
return upcoming[0] || null;
@@ -301,8 +301,8 @@ export default function BackupsOverview({
<div className="sk-kpi__sub">
<span>
{nextScheduled
? `${nextScheduled.schedule.name || 'schedule'} · ${nextScheduled.schedule.schedule_time}`
: 'no enabled schedules'}
? `${nextScheduled.schedule.name || 'schedule'} · ${[nextScheduled.schedule.schedule_time, nextScheduled.schedule.timezone].filter(Boolean).join(' ')}`
: t('app.backupsOverview.noScheduledRun', 'no scheduled run reported')}
</span>
</div>
</MetricCard>
@@ -108,14 +108,15 @@ export default function SchedulesTable({
headerKey: 'app.schedulesTable.next', header: 'Next',
sortable: true,
sortValue: (s) => {
const next = s.enabled ? nextFire(s, now) : null;
const next = nextFire(s);
return next ? next.getTime() : null;
},
cellClassName: 'sk-cell-mono',
render: (schedule) => {
const next = schedule.enabled ? nextFire(schedule, now) : null;
const next = nextFire(schedule);
if (schedule.schedule_error) return <span title={schedule.schedule_error}>{t('app.schedulesTable.invalidSchedule', 'Invalid schedule')}</span>;
return schedule.enabled
? (next ? untilLabel(next, now) : '—')
? (next ? <span title={schedule.next_run_at}>{untilLabel(next, now)}</span> : '—')
: <span className="bk-paused">paused</span>;
},
},
+49
View File
@@ -0,0 +1,49 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import api from '../services/api';
import { useToast } from '../contexts/useToast.js';
import { useServerMutation, useServerQuery } from './useServerQuery';
const SCHEDULE_KEY = ['backups', 'schedules'];
const EMPTY_SCHEDULES = [];
// One workspace-scoped cache owns policy CRUD and scheduler timing. Refresh
// through the shared poller so a passed next_run_at advances without reloading
// the whole backup archive or overwriting unfinished settings forms.
export function useBackupSchedules() {
const { t } = useTranslation();
const toast = useToast();
const query = useServerQuery(SCHEDULE_KEY, () => api.getBackupSchedules(), {
refetchInterval: 60_000,
onError: (error) => toast.error(error.message),
});
const create = useServerMutation((values) => api.addBackupSchedule(
values.name, values.backupType, values.target, values.scheduleTime,
values.days, values.uploadRemote,
), { invalidate: [SCHEDULE_KEY] });
const toggle = useServerMutation((schedule) => api.updateBackupSchedule(schedule.id, { enabled: !schedule.enabled }), {
invalidate: [SCHEDULE_KEY],
onSuccess: (_, schedule) => toast.success(t('app.backups.schedule', 'Schedule {{value}}', { value: schedule.enabled ? 'disabled' : 'enabled' })),
onError: (error) => toast.error(error.message),
});
const remove = useServerMutation((id) => api.removeBackupSchedule(id), {
invalidate: [SCHEDULE_KEY],
onSuccess: () => toast.success(t('app.backups.scheduleRemoved', 'Schedule removed')),
onError: (error) => toast.error(error.message),
});
const refetch = query.refetch;
const reportError = toast.error;
const refresh = useCallback(() => refetch().catch((error) => reportError(error.message)), [refetch, reportError]);
// UI event handlers intentionally resolve after the mutation's shared error
// presentation. Creation retains rejection for useForm's inline errors.
return {
schedules: query.data?.schedules ?? EMPTY_SCHEDULES,
timezone: query.data?.timezone,
isLoading: query.isLoading,
refresh,
create: create.mutate,
toggle: (schedule) => toggle.mutate(schedule).catch(() => undefined),
remove: (id) => remove.mutate(id).catch(() => undefined),
};
}
+35 -26
View File
@@ -18,6 +18,7 @@ export default function useForm({ initialValues = {}, validate, onSubmit }) {
const initialValuesRef = useRef(initialValues);
const validateRef = useRef(validate);
const onSubmitRef = useRef(onSubmit);
const submitInFlight = useRef(false);
validateRef.current = validate;
onSubmitRef.current = onSubmit;
@@ -49,34 +50,42 @@ export default function useForm({ initialValues = {}, validate, onSubmit }) {
const handleSubmit = useCallback(async (event) => {
event?.preventDefault?.();
const values = state.values;
const errors = normalizeFieldErrors(
validateRef.current ? await validateRef.current(values) : {},
);
const touched = touchedFieldsFor(values);
if (Object.keys(errors).length > 0) {
dispatch({ type: 'validationFailed', errors, touched });
return { ok: false, errors };
}
dispatch({ type: 'submitStarted' });
// A second submit event can arrive before React commits disabled buttons,
// or while asynchronous validation is still running.
if (submitInFlight.current) return { ok: false, pending: true };
submitInFlight.current = true;
try {
const result = await onSubmitRef.current?.(values);
dispatch({ type: 'submitFinished' });
return { ok: true, value: result };
} catch (error) {
const mapped = mapServerFormError(error);
const failedTouched = Object.fromEntries(
Object.keys(mapped.fieldErrors).map((name) => [name, true]),
const values = state.values;
const errors = normalizeFieldErrors(
validateRef.current ? await validateRef.current(values) : {},
);
dispatch({
type: 'submitFailed',
errors: mapped.fieldErrors,
touched: failedTouched,
submitError: mapped.formError,
});
return { ok: false, error, errors: mapped.fieldErrors };
const touched = touchedFieldsFor(values);
if (Object.keys(errors).length > 0) {
dispatch({ type: 'validationFailed', errors, touched });
return { ok: false, errors };
}
dispatch({ type: 'submitStarted' });
try {
const result = await onSubmitRef.current?.(values);
dispatch({ type: 'submitFinished' });
return { ok: true, value: result };
} catch (error) {
const mapped = mapServerFormError(error);
const failedTouched = Object.fromEntries(
Object.keys(mapped.fieldErrors).map((name) => [name, true]),
);
dispatch({
type: 'submitFailed',
errors: mapped.fieldErrors,
touched: failedTouched,
submitError: mapped.formError,
});
return { ok: false, error, errors: mapped.fieldErrors };
}
} finally {
submitInFlight.current = false;
}
}, [state.values]);
+1 -14
View File
@@ -43,17 +43,4 @@ export function useRecipeCatalog() {
};
}
// Start one recipe run. Returns the deployment job id; errors are toasted by
// the mutation wrapper so callers can just navigate on success.
export async function runRecipe({ startRun, toast, t }, body, { serverName }) {
try {
const result = await startRun.mutateAsync(body);
toast.success(t('app.recipes.started', 'Recipe started on {{server}}.', {
server: serverName,
}));
return result.job_id;
} catch (err) {
toast.error(err.message || t('app.recipes.startFailed', 'Could not start the recipe'));
return null;
}
}
export { runRecipe } from '../utils/runRecipe.js';
+5
View File
@@ -388,6 +388,7 @@
"applicationBackupCreated": "Application backup created",
"applicationKey": "Application Key",
"applicationKeyId": "Application Key ID",
"applicationName": "Application Name",
"areYouSureYouWantTo": "Are you sure you want to delete this backup?",
"areYouSureYouWantTo3": "Are you sure you want to restore this backup? This may overwrite existing data.",
"areYouSureYouWantTo5": "Are you sure you want to remove this schedule?",
@@ -458,7 +459,9 @@
"saveStorageConfig": "Save Storage Config",
"schedule": "Schedule {{value}}",
"scheduleAdded": "Schedule added",
"scheduleDatabase": "Database (format: mysql:dbname or postgresql:dbname)",
"scheduleName": "Schedule Name",
"schedulePaths": "Paths (comma-separated)",
"scheduleRemoved": "Schedule removed",
"searchSnapshots": "Search snapshots…",
"secretKey": "Secret Key",
@@ -490,6 +493,7 @@
"latestSnapshots": "Latest snapshots",
"manage": "Manage",
"nextScheduled": "Next scheduled",
"noScheduledRun": "no scheduled run reported",
"noSnapshotsYet": "No snapshots yet",
"nothingHasRunYetTheFirst": "Nothing has run yet — the first backup will show up here.",
"protectedResources": "Protected resources",
@@ -4319,6 +4323,7 @@
"disable": "Disable {{name}}",
"enable": "Enable {{name}}",
"frequency": "Frequency",
"invalidSchedule": "Invalid schedule",
"lastRun": "Last run",
"next": "Next",
"policy": "Policy",
+28 -150
View File
@@ -21,6 +21,8 @@ import {
} from '@/components/ds/grid';
import BackupsOverview from '../components/backups/BackupsOverview';
import SchedulesTable from '../components/backups/SchedulesTable';
import AddScheduleModal from '../components/backups/AddScheduleModal';
import { useBackupSchedules } from '../hooks/useBackupSchedules';
import StorageDestinations from '../components/backups/StorageDestinations';
import { useTopbarActions, useTopbarChrome } from '@/hooks/useTopbarActions';
import { useTableSort } from '@/hooks/useTableSort';
@@ -127,7 +129,8 @@ const Backups = () => {
const { confirm } = useConfirm();
const [backups, setBackups] = useState([]);
const [stats, setStats] = useState(null);
const [schedules, setSchedules] = useState([]);
const scheduleStore = useBackupSchedules();
const { schedules, refresh: reloadSchedules } = scheduleStore;
const [config, setConfig] = useState(null);
const [storageConfig, setStorageConfig] = useState(null);
const [costSummary, setCostSummary] = useState(null);
@@ -169,16 +172,6 @@ const Backups = () => {
fileName: ''
});
// Schedule form state
const [scheduleForm, setScheduleForm] = useState({
name: '',
backupType: 'application',
target: '',
scheduleTime: '02:00',
days: ['daily'],
uploadRemote: false
});
// Config form state
const [configForm, setConfigForm] = useState({
enabled: false,
@@ -198,17 +191,12 @@ const Backups = () => {
keep_local_copy: true
});
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
const loadData = useCallback(async ({ refreshSchedules = true } = {}) => {
try {
setLoading(true);
const [backupsRes, statsRes, schedulesRes, configRes, appsRes, storageRes, costRes, ratesRes] = await Promise.all([
const [backupsRes, statsRes, configRes, appsRes, storageRes, costRes, ratesRes] = await Promise.all([
api.getBackups(),
api.getBackupStats(),
api.getBackupSchedules(),
api.getBackupConfig(),
api.getApps(),
api.getStorageConfig().catch(() => null),
@@ -218,8 +206,8 @@ const Backups = () => {
setBackups(backupsRes.backups || []);
setStats(statsRes);
setSchedules(schedulesRes.schedules || []);
setConfig(configRes);
if (refreshSchedules) await reloadSchedules();
// GET /apps responds { apps: [...] } — reading `.applications` here
// silently produced [] on every load, so the app picker in the
// backup dialogs was permanently empty.
@@ -250,7 +238,11 @@ const Backups = () => {
} finally {
setLoading(false);
}
};
}, [reloadSchedules]);
useEffect(() => {
loadData({ refreshSchedules: false });
}, [loadData]);
const handleCreateBackup = async (e) => {
e.preventDefault();
@@ -342,49 +334,18 @@ const Backups = () => {
}
};
const handleAddSchedule = async (e) => {
e.preventDefault();
try {
await api.addBackupSchedule(
scheduleForm.name,
scheduleForm.backupType,
scheduleForm.target,
scheduleForm.scheduleTime,
scheduleForm.days,
scheduleForm.uploadRemote
);
toast.success(t('app.backups.scheduleAdded', 'Schedule added'));
window.dispatchEvent(new CustomEvent('serverkit:walkthrough-signal', {
detail: { type: 'backup-schedule-created' },
}));
setShowScheduleModal(false);
resetScheduleForm();
loadData();
} catch (err) {
toast.error(err.message);
}
};
const handleToggleSchedule = async (schedule) => {
try {
await api.updateBackupSchedule(schedule.id, { enabled: !schedule.enabled });
toast.success(t('app.backups.schedule', 'Schedule {{value}}', { value: schedule.enabled ? 'disabled' : 'enabled' }));
loadData();
} catch (err) {
toast.error(err.message);
}
const handleScheduleCreated = () => {
toast.success(t('app.backups.scheduleAdded', 'Schedule added'));
window.dispatchEvent(new CustomEvent('serverkit:walkthrough-signal', {
detail: { type: 'backup-schedule-created' },
}));
setShowScheduleModal(false);
};
const handleRemoveSchedule = async (scheduleId) => {
const confirmed = await confirm({ title: t('app.backups.removeSchedule', 'Remove Schedule'), message: t('app.backups.areYouSureYouWantTo5', 'Are you sure you want to remove this schedule?') });
if (!confirmed) return;
try {
await api.removeBackupSchedule(scheduleId);
toast.success(t('app.backups.scheduleRemoved', 'Schedule removed'));
loadData();
} catch (err) {
toast.error(err.message);
}
await scheduleStore.remove(scheduleId);
};
const handleSaveConfig = async (e) => {
@@ -471,17 +432,6 @@ const Backups = () => {
});
};
const resetScheduleForm = () => {
setScheduleForm({
name: '',
backupType: 'application',
target: '',
scheduleTime: '02:00',
days: ['daily'],
uploadRemote: false
});
};
const formatTimestamp = (timestamp) => {
return new Date(timestamp).toLocaleString();
};
@@ -858,7 +808,7 @@ const Backups = () => {
schedules={schedules}
retentionDays={config?.retention_days || 30}
remoteLabel={PROVIDER_LABELS[storageConfig?.provider] || 'Local disk'}
onToggle={handleToggleSchedule}
onToggle={scheduleStore.toggle}
onRemove={handleRemoveSchedule}
/>
)}
@@ -1315,86 +1265,14 @@ const Backups = () => {
</form>
</Modal>
{/* Add Schedule Modal */}
<Modal open={showScheduleModal} onClose={() => setShowScheduleModal(false)} title={t('app.backups.addBackupSchedule', 'Add Backup Schedule')}>
<form onSubmit={handleAddSchedule} data-walkthrough="backup-schedule-form">
<div className="form-group">
<label>{t('app.backups.scheduleName', 'Schedule Name')}</label>
<Input
type="text"
value={scheduleForm.name}
onChange={(e) => setScheduleForm({...scheduleForm, name: e.target.value})}
placeholder={t('app.backups.dailyAppBackup', 'Daily App Backup')}
required
/>
</div>
<div className="form-group">
<label>{t('app.backups.backupType', 'Backup Type')}</label>
<select
value={scheduleForm.backupType}
onChange={(e) => setScheduleForm({...scheduleForm, backupType: e.target.value})}
>
<option value="application">{t('app.backups.application', 'Application')}</option>
<option value="database">{t('app.backups.database', 'Database')}</option>
<option value="files">{t('app.backups.filesDirectories', 'Files / Directories')}</option>
</select>
</div>
<div className="form-group">
<label>
{scheduleForm.backupType === 'files'
? 'Paths (comma-separated)'
: scheduleForm.backupType === 'database'
? 'Database (format: mysql:dbname or postgresql:dbname)'
: 'Application Name'
}
</label>
<Input
type="text"
value={scheduleForm.target}
onChange={(e) => setScheduleForm({...scheduleForm, target: e.target.value})}
placeholder={
scheduleForm.backupType === 'files'
? '/etc/nginx,/var/www/config'
: scheduleForm.backupType === 'database'
? 'mysql:mydb'
: 'my-app'
}
required
/>
</div>
<div className="form-group">
<label>{t('common.labels.time', 'Time')}</label>
<Input
type="time"
value={scheduleForm.scheduleTime}
onChange={(e) => setScheduleForm({...scheduleForm, scheduleTime: e.target.value})}
required
/>
</div>
{storageConfig?.provider !== 'local' && (
<div className="form-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={scheduleForm.uploadRemote}
onChange={(e) => setScheduleForm({...scheduleForm, uploadRemote: e.target.checked})}
/>
<span>{t('app.backups.uploadToRemoteStorageAfterBackup', 'Upload to remote storage after backup')}</span>
</label>
</div>
)}
<div className="modal-actions">
<Button type="button" variant="outline" onClick={() => setShowScheduleModal(false)}>
{t('common.actions.cancel', 'Cancel')}
</Button>
<Button type="submit" data-walkthrough="backup-schedule-submit">{t('app.backups.addSchedule', 'Add Schedule')}</Button>
</div>
</form>
</Modal>
<AddScheduleModal
open={showScheduleModal}
onClose={() => setShowScheduleModal(false)}
onCreate={scheduleStore.create}
onCreated={handleScheduleCreated}
remoteEnabled={Boolean(storageConfig?.provider && storageConfig.provider !== 'local')}
timezone={scheduleStore.timezone || schedules[0]?.timezone}
/>
{/* Restore Modal */}
<Modal open={showRestoreModal && !!selectedBackup} onClose={() => setShowRestoreModal(false)} title={t('app.backups.restoreBackup', 'Restore Backup')}>
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getInvitations } from '../auth.js';
import { getProcesses, killProcess } from '../system.js';
import { getSSHKeys } from '../security.js';
import { compareServerMetrics, getFleetComparison, searchFleet, exportFleetCsv } from '../servers.js';
import { suggestSubdomain } from '../files.js';
import { getDeploymentJobLogs } from '../deploymentJobs.js';
const capture = { request: async (url, options) => ({ url: new URL(url, 'https://panel.test'), options }) };
const special = 'a &admin=true+# café/東京?%';
test('user-entered query values cannot add parameters or URL fragments', async () => {
for (const [method, args, key] of [
[getInvitations, [special], 'status'],
[getSSHKeys, [special], 'user'],
[getProcesses, [25, special], 'sort'],
[searchFleet, [special, special], 'type'],
]) {
const { url } = await method.call(capture, ...args);
assert.equal(url.searchParams.get(key), special);
assert.equal(url.searchParams.has('admin'), false);
assert.equal(url.hash, '');
}
const { url } = await searchFleet.call(capture, special, special);
assert.equal(url.searchParams.get('q'), special); // Existing encoding is not doubled.
});
test('numeric zero, false, optional omission and CSV array contracts stay intact', async () => {
assert.equal((await getProcesses.call(capture, 0)).url.searchParams.get('limit'), '0');
assert.equal((await killProcess.call(capture, 42, false)).url.searchParams.get('force'), 'false');
assert.equal((await getInvitations.call(capture, '')).url.search, '');
assert.equal((await getDeploymentJobLogs.call(capture, 42, null)).url.search, '');
for (const method of [compareServerMetrics, getFleetComparison]) {
const { url } = await method.call(capture, ['one&admin=1', 'two space'], 'cpu', '24h');
assert.deepEqual(url.searchParams.getAll('ids'), ['one&admin=1,two space']);
assert.equal(url.searchParams.has('admin'), false);
}
const { url } = await suggestSubdomain.call(capture, 0, special);
assert.equal(url.searchParams.get('application_id'), '0');
assert.equal(url.searchParams.get('base'), special);
});
test('CSV download query encoding preserves authenticated download behavior', async () => {
const original = globalThis.fetch;
let received;
globalThis.fetch = async (url, options) => {
received = { url: new URL(url), options };
return { blob: async () => 'csv' };
};
try {
const result = await exportFleetCsv.call({ baseUrl: 'https://panel.test/api', getToken: () => 'token' }, ['one', 'two'], special, '24h');
assert.equal(result, 'csv');
assert.equal(received.url.searchParams.get('metric'), special);
assert.equal(received.url.searchParams.get('ids'), 'one,two');
assert.equal(received.options.headers.Authorization, 'Bearer token');
} finally {
globalThis.fetch = original;
}
});
+5 -5
View File
@@ -268,7 +268,7 @@ export async function exportEnvFile(appId, includeSecrets = true) {
}
export async function getEnvVarHistory(appId, limit = 50) {
return this.request(`/apps/${appId}/env/history?limit=${limit}`);
return this.request(`/apps/${appId}/env/history?limit=${encodeURIComponent(limit)}`);
}
export async function clearEnvVars(appId) {
@@ -285,7 +285,7 @@ export async function getComposeServices(appId) {
// Docker App Logs and Status
export async function getDockerAppLogs(appId, lines = 100) {
return this.request(`/apps/${appId}/logs?lines=${lines}`);
return this.request(`/apps/${appId}/logs?lines=${encodeURIComponent(lines)}`);
}
export async function getDockerAppStatus(appId) {
@@ -492,7 +492,7 @@ export async function triggerBuild(appId, noCache = false) {
}
export async function getBuildLogs(appId, limit = 20) {
return this.request(`/builds/apps/${appId}/build-logs?limit=${limit}`);
return this.request(`/builds/apps/${appId}/build-logs?limit=${encodeURIComponent(limit)}`);
}
export async function getBuildLogDetail(appId, timestamp) {
@@ -511,11 +511,11 @@ export async function deployApp(appId, options = {}) {
}
export async function getDeployments(appId, limit = 20, offset = 0) {
return this.request(`/builds/apps/${appId}/deployments?limit=${limit}&offset=${offset}`);
return this.request(`/builds/apps/${appId}/deployments?limit=${encodeURIComponent(limit)}&offset=${encodeURIComponent(offset)}`);
}
export async function getDeploymentDetail(deploymentId, includeLogs = false) {
return this.request(`/builds/deployments/${deploymentId}?include_logs=${includeLogs}`);
return this.request(`/builds/deployments/${deploymentId}?include_logs=${encodeURIComponent(includeLogs)}`);
}
export async function getDeploymentDiff(deploymentId) {
+7 -7
View File
@@ -162,7 +162,7 @@ export async function getPermissionTemplates() {
// Admin - Invitations endpoints
export async function getInvitations(status) {
const query = status ? `?status=${status}` : '';
const query = status ? `?status=${encodeURIComponent(status)}` : '';
return this.request(`/admin/invitations/${query}`);
}
@@ -362,7 +362,7 @@ export async function testEventSubscription(id) {
}
export async function getEventDeliveries(id, page = 1) {
return this.request(`/event-subscriptions/${id}/deliveries?page=${page}`);
return this.request(`/event-subscriptions/${id}/deliveries?page=${encodeURIComponent(page)}`);
}
export async function retryEventDelivery(subId, deliveryId) {
@@ -371,21 +371,21 @@ export async function retryEventDelivery(subId, deliveryId) {
// API Analytics
export async function getApiAnalyticsOverview(period = '24h') {
return this.request(`/api-analytics/overview?period=${period}`);
return this.request(`/api-analytics/overview?period=${encodeURIComponent(period)}`);
}
export async function getApiAnalyticsEndpoints(period = '24h', limit = 20) {
return this.request(`/api-analytics/endpoints?period=${period}&limit=${limit}`);
return this.request(`/api-analytics/endpoints?period=${encodeURIComponent(period)}&limit=${encodeURIComponent(limit)}`);
}
export async function getApiAnalyticsErrors(period = '24h') {
return this.request(`/api-analytics/errors?period=${period}`);
return this.request(`/api-analytics/errors?period=${encodeURIComponent(period)}`);
}
export async function getApiAnalyticsTimeseries(period = '24h', interval = 'hour') {
return this.request(`/api-analytics/timeseries?period=${period}&interval=${interval}`);
return this.request(`/api-analytics/timeseries?period=${encodeURIComponent(period)}&interval=${encodeURIComponent(interval)}`);
}
export async function getApiKeyUsage(keyId, period = '24h') {
return this.request(`/api-analytics/keys/${keyId}/usage?period=${period}`);
return this.request(`/api-analytics/keys/${keyId}/usage?period=${encodeURIComponent(period)}`);
}
+1 -1
View File
@@ -6,7 +6,7 @@ export async function getBandwidthApps() {
}
export async function getAppBandwidth(appId, days = 90) {
return this.request(`/bandwidth/apps/${appId}?days=${days}`);
return this.request(`/bandwidth/apps/${appId}?days=${encodeURIComponent(days)}`);
}
export async function runBandwidthAggregate(day = null) {
+1 -1
View File
@@ -66,7 +66,7 @@ export async function applyWaf(appId) {
}
export async function getWafEvents(appId, limit = 50) {
return this.request(`/waf/applications/${appId}/events?limit=${limit}`);
return this.request(`/waf/applications/${appId}/events?limit=${encodeURIComponent(limit)}`);
}
export async function getWafStatus() {
+2 -2
View File
@@ -125,7 +125,7 @@ export async function grantPostgreSQLPrivileges(username, database, privileges =
// Backups
export async function getDatabaseBackups(type = null) {
const params = type ? `?type=${type}` : '';
const params = type ? `?type=${encodeURIComponent(type)}` : '';
return this.request(`/databases/backups${params}`);
}
@@ -134,7 +134,7 @@ export async function deleteDatabaseBackup(filename) {
}
export async function generateDatabasePassword(length = 16) {
return this.request(`/databases/generate-password?length=${length}`);
return this.request(`/databases/generate-password?length=${encodeURIComponent(length)}`);
}
// Query Execution
+1 -1
View File
@@ -17,7 +17,7 @@ export async function getDeploymentJob(jobId, includeLogs = true, includePlan =
}
export async function getDeploymentJobLogs(jobId, afterId = null) {
const suffix = afterId ? `?after_id=${afterId}` : '';
const suffix = afterId ? `?after_id=${encodeURIComponent(afterId)}` : '';
return this.request(`/deployment-jobs/${jobId}/logs${suffix}`);
}
+1 -1
View File
@@ -72,7 +72,7 @@ export async function applyDNSPreset(zoneId, preset, variables = {}) {
}
export async function checkDNSPropagation(domain, type = 'A') {
return this.request(`/dns/propagation/${domain}?type=${type}`);
return this.request(`/dns/propagation/${domain}?type=${encodeURIComponent(type)}`);
}
export async function getDomainRoutingDiagnostics(appId) {
+1 -1
View File
@@ -15,7 +15,7 @@ export async function getDockerDiskUsage() {
// Containers
export async function getContainers(all = true) {
return this.request(`/docker/containers?all=${all}`);
return this.request(`/docker/containers?all=${encodeURIComponent(all)}`);
}
export async function getContainer(containerId) {
+10 -10
View File
@@ -95,7 +95,7 @@ export async function getFileTypeBreakdown(path = '/home', maxDepth = 3) {
export async function downloadFile(path) {
const token = this.getToken();
const url = `${this.baseUrl}/files/download?path=${encodeURIComponent(path)}`;
window.open(`${url}&token=${token}`, '_blank');
window.open(`${url}&token=${encodeURIComponent(token)}`, '_blank');
}
export async function getFileBlob(path) {
@@ -201,7 +201,7 @@ export async function controlFTPService(action, service = null) {
}
export async function getFTPConfig(service = null) {
const params = service ? `?service=${service}` : '';
const params = service ? `?service=${encodeURIComponent(service)}` : '';
return this.request(`/ftp/config${params}`);
}
@@ -255,7 +255,7 @@ export async function disconnectFTPSession(pid) {
}
export async function getFTPLogs(lines = 100) {
return this.request(`/ftp/logs?lines=${lines}`);
return this.request(`/ftp/logs?lines=${encodeURIComponent(lines)}`);
}
export async function installFTPServer(service = 'vsftpd') {
@@ -311,7 +311,7 @@ export async function toggleWebhook(webhookId) {
}
export async function getGitWebhookLogs(webhookId, limit = 50) {
return this.request(`/git/webhooks/${webhookId}/logs?limit=${limit}`);
return this.request(`/git/webhooks/${webhookId}/logs?limit=${encodeURIComponent(limit)}`);
}
export async function testWebhook(webhookId) {
@@ -324,7 +324,7 @@ export async function getGiteaStatus() {
}
export async function getGiteaRepositories(limit = 50) {
return this.request(`/git/repos?limit=${limit}`);
return this.request(`/git/repos?limit=${encodeURIComponent(limit)}`);
}
export async function getGiteaBranches(owner, repo) {
@@ -333,11 +333,11 @@ export async function getGiteaBranches(owner, repo) {
// Git Deployments
export async function getAppDeployments(appId, limit = 20) {
return this.request(`/git/deployments/app/${appId}?limit=${limit}`);
return this.request(`/git/deployments/app/${appId}?limit=${encodeURIComponent(limit)}`);
}
export async function getDeployment(deploymentId, includeLogs = false) {
return this.request(`/git/deployments/${deploymentId}?logs=${includeLogs}`);
return this.request(`/git/deployments/${deploymentId}?logs=${encodeURIComponent(includeLogs)}`);
}
export async function triggerGitDeploy(appId, branch = null) {
@@ -355,7 +355,7 @@ export async function rollbackDeployment(appId, targetVersion = null) {
}
export async function getWebhookDeployments(webhookId, limit = 20) {
return this.request(`/git/deployments/webhook/${webhookId}?limit=${limit}`);
return this.request(`/git/deployments/webhook/${webhookId}?limit=${encodeURIComponent(limit)}`);
}
// Domains endpoints
@@ -410,7 +410,7 @@ export async function verifyDomain(domainId) {
// prefill the "give this a subdomain" action.
export async function suggestSubdomain(applicationId, base) {
const q = base ? `&base=${encodeURIComponent(base)}` : '';
return this.request(`/domains/suggest-subdomain?application_id=${applicationId}${q}`);
return this.request(`/domains/suggest-subdomain?application_id=${encodeURIComponent(applicationId)}${q}`);
}
// Base domains a site can be published under (<slug>.<base>), for the picker.
@@ -482,7 +482,7 @@ export async function runStatusCheck(compId) {
}
export async function getStatusCheckHistory(compId, hours = 24) {
return this.request(`/status/components/${compId}/history?hours=${hours}`);
return this.request(`/status/components/${compId}/history?hours=${encodeURIComponent(hours)}`);
}
export async function getStatusPageIncidents(pageId) {
+2 -2
View File
@@ -2,7 +2,7 @@
// Mounted by the backend at /api/v1/manifests.
export async function getManifest(projectId) {
return this.request(`/manifests?project_id=${projectId}`);
return this.request(`/manifests?project_id=${encodeURIComponent(projectId)}`);
}
export async function planManifest(projectId, body = {}) {
@@ -20,5 +20,5 @@ export async function applyManifest(projectId, body = {}) {
}
export async function scaffoldManifest(appId) {
return this.request(`/manifests/scaffold?app_id=${appId}`);
return this.request(`/manifests/scaffold?app_id=${encodeURIComponent(appId)}`);
}
+1 -1
View File
@@ -57,7 +57,7 @@ export async function getMonitorHistory(monitorId, params = {}) {
}
export async function getMonitorUptime(monitorId, days = 90) {
return this.request(`/monitors/${monitorId}/uptime?days=${days}`);
return this.request(`/monitors/${monitorId}/uptime?days=${encodeURIComponent(days)}`);
}
// --- Incidents ---
+6 -6
View File
@@ -23,7 +23,7 @@ export async function disableFirewall(firewall = null) {
}
export async function getFirewallRules(firewall = null) {
const params = firewall ? `?firewall=${firewall}` : '';
const params = firewall ? `?firewall=${encodeURIComponent(firewall)}` : '';
return this.request(`/firewall/rules${params}`);
}
@@ -162,7 +162,7 @@ export async function getSSLHealth(domain) {
}
export async function getSSLExpiryAlerts(days = 30) {
return this.request(`/ssl/expiry-alerts?days=${days}`);
return this.request(`/ssl/expiry-alerts?days=${encodeURIComponent(days)}`);
}
// Security (ClamAV, File Integrity) endpoints
@@ -193,15 +193,15 @@ export async function checkFileIntegrity() {
}
export async function getFailedLogins(hours = 24) {
return this.request(`/security/failed-logins?hours=${hours}`);
return this.request(`/security/failed-logins?hours=${encodeURIComponent(hours)}`);
}
export async function getSecurityEvents(limit = 100) {
return this.request(`/security/events?limit=${limit}`);
return this.request(`/security/events?limit=${encodeURIComponent(limit)}`);
}
// SSH Key endpoints
export async function getSSHKeys(user = 'root') {
return this.request(`/security/ssh-keys?user=${user}`);
return this.request(`/security/ssh-keys?user=${encodeURIComponent(user)}`);
}
export async function addSSHKey(key, user = 'root') {
@@ -212,7 +212,7 @@ export async function addSSHKey(key, user = 'root') {
}
export async function removeSSHKey(keyId, user = 'root') {
return this.request(`/security/ssh-keys/${keyId}?user=${user}`, {
return this.request(`/security/ssh-keys/${keyId}?user=${encodeURIComponent(user)}`, {
method: 'DELETE'
});
}
+21 -21
View File
@@ -117,7 +117,7 @@ export async function deleteServerGroup(id) {
// Remote Docker Operations (via agent)
export async function getRemoteContainers(serverId, all = false) {
return this.request(`/servers/${serverId}/docker/containers?all=${all}`);
return this.request(`/servers/${serverId}/docker/containers?all=${encodeURIComponent(all)}`);
}
export async function getRemoteContainer(serverId, containerId) {
@@ -143,7 +143,7 @@ export async function restartRemoteContainer(serverId, containerId) {
}
export async function removeRemoteContainer(serverId, containerId, force = false) {
return this.request(`/servers/${serverId}/docker/containers/${containerId}?force=${force}`, {
return this.request(`/servers/${serverId}/docker/containers/${containerId}?force=${encodeURIComponent(force)}`, {
method: 'DELETE'
});
}
@@ -170,7 +170,7 @@ export async function pullRemoteImage(serverId, image) {
}
export async function removeRemoteImage(serverId, imageId, force = false) {
return this.request(`/servers/${serverId}/docker/images/${imageId}?force=${force}`, {
return this.request(`/servers/${serverId}/docker/images/${imageId}?force=${encodeURIComponent(force)}`, {
method: 'DELETE'
});
}
@@ -180,7 +180,7 @@ export async function getRemoteVolumes(serverId) {
}
export async function removeRemoteVolume(serverId, volumeName, force = false) {
return this.request(`/servers/${serverId}/docker/volumes/${encodeURIComponent(volumeName)}?force=${force}`, {
return this.request(`/servers/${serverId}/docker/volumes/${encodeURIComponent(volumeName)}?force=${encodeURIComponent(force)}`, {
method: 'DELETE'
});
}
@@ -518,16 +518,16 @@ export async function remoteComposePull(serverId, projectPath, service = null) {
// Server Historical Metrics
export async function getServerMetricsHistory(serverId, period = '24h') {
return this.request(`/servers/${serverId}/metrics/history?period=${period}`);
return this.request(`/servers/${serverId}/metrics/history?period=${encodeURIComponent(period)}`);
}
export async function getServerMetricsAggregated(serverId, period = '24h', aggregation = 'hourly') {
return this.request(`/servers/${serverId}/metrics/aggregated?period=${period}&aggregation=${aggregation}`);
return this.request(`/servers/${serverId}/metrics/aggregated?period=${encodeURIComponent(period)}&aggregation=${encodeURIComponent(aggregation)}`);
}
export async function compareServerMetrics(serverIds, metric = 'cpu', period = '24h') {
const ids = Array.isArray(serverIds) ? serverIds.join(',') : serverIds;
return this.request(`/servers/metrics/compare?ids=${ids}&metric=${metric}&period=${period}`);
return this.request(`/servers/metrics/compare?ids=${encodeURIComponent(ids)}&metric=${encodeURIComponent(metric)}&period=${encodeURIComponent(period)}`);
}
export async function getMetricsRetentionStats() {
@@ -612,7 +612,7 @@ export async function getAllSecurityAlerts(options = {}) {
}
export async function getSecurityAlertCounts(serverId = null) {
const query = serverId ? `?server_id=${serverId}` : '';
const query = serverId ? `?server_id=${encodeURIComponent(serverId)}` : '';
return this.request(`/servers/security/alerts/counts${query}`);
}
@@ -669,7 +669,7 @@ export async function startRollout(data) {
}
export async function getRollouts(status) {
const query = status ? `?status=${status}` : '';
const query = status ? `?status=${encodeURIComponent(status)}` : '';
return this.request(`/servers/fleet/rollouts${query}`);
}
@@ -684,7 +684,7 @@ export async function cancelRollout(rolloutId) {
}
export async function startDiscovery(duration = 10) {
return this.request(`/servers/fleet/discovery?duration=${duration}`, {
return this.request(`/servers/fleet/discovery?duration=${encodeURIComponent(duration)}`, {
method: 'POST'
});
}
@@ -706,7 +706,7 @@ export async function rejectRegistration(serverId) {
}
export async function getQueuedCommands(serverId) {
const query = serverId ? `?server_id=${serverId}` : '';
const query = serverId ? `?server_id=${encodeURIComponent(serverId)}` : '';
return this.request(`/servers/fleet/commands/queued${query}`);
}
@@ -722,13 +722,13 @@ export async function getServerDiagnostics(serverId) {
// Fleet Monitor (Cross-Server Monitoring) endpoints
export async function getFleetHeatmap(groupId) {
const query = groupId ? `?group_id=${groupId}` : '';
const query = groupId ? `?group_id=${encodeURIComponent(groupId)}` : '';
return this.request(`/fleet-monitor/heatmap${query}`);
}
export async function getFleetComparison(serverIds, metric, period) {
const ids = serverIds.join(',');
return this.request(`/fleet-monitor/comparison?ids=${ids}&metric=${metric}&period=${period}`);
return this.request(`/fleet-monitor/comparison?ids=${encodeURIComponent(ids)}&metric=${encodeURIComponent(metric)}&period=${encodeURIComponent(period)}`);
}
export async function getFleetAlerts(params = {}) {
@@ -750,7 +750,7 @@ export async function resolveFleetAlert(alertId) {
}
export async function getFleetThresholds(serverId) {
const query = serverId ? `?server_id=${serverId}` : '';
const query = serverId ? `?server_id=${encodeURIComponent(serverId)}` : '';
return this.request(`/fleet-monitor/thresholds${query}`);
}
@@ -763,21 +763,21 @@ export async function deleteFleetThreshold(thresholdId) {
}
export async function getFleetAnomalies(serverId) {
const query = serverId ? `?server_id=${serverId}` : '';
const query = serverId ? `?server_id=${encodeURIComponent(serverId)}` : '';
return this.request(`/fleet-monitor/anomalies${query}`);
}
export async function getCapacityForecast(serverId, metric = 'disk') {
return this.request(`/fleet-monitor/forecast/${serverId}?metric=${metric}`);
return this.request(`/fleet-monitor/forecast/${serverId}?metric=${encodeURIComponent(metric)}`);
}
export async function searchFleet(query, type = 'any') {
return this.request(`/fleet-monitor/search?q=${encodeURIComponent(query)}&type=${type}`);
return this.request(`/fleet-monitor/search?q=${encodeURIComponent(query)}&type=${encodeURIComponent(type)}`);
}
export async function exportFleetCsv(serverIds, metric, period) {
const ids = serverIds.join(',');
const url = `${this.baseUrl}/fleet-monitor/export/csv?ids=${ids}&metric=${metric}&period=${period}`;
const url = `${this.baseUrl}/fleet-monitor/export/csv?ids=${encodeURIComponent(ids)}&metric=${encodeURIComponent(metric)}&period=${encodeURIComponent(period)}`;
const token = this.getToken();
const response = await fetch(url, {
headers: token ? { Authorization: `Bearer ${token}` } : {}
@@ -787,7 +787,7 @@ export async function exportFleetCsv(serverIds, metric, period) {
// Agent Plugins endpoints
export async function getAgentPlugins(status) {
const query = status ? `?status=${status}` : '';
const query = status ? `?status=${encodeURIComponent(status)}` : '';
return this.request(`/agent-plugins/${query}`);
}
@@ -851,7 +851,7 @@ export async function getPluginSpec() {
// Server Templates endpoints
export async function getServerTemplates(category) {
const query = category ? `?category=${category}` : '';
const query = category ? `?category=${encodeURIComponent(category)}` : '';
return this.request(`/server-templates/${query}`);
}
@@ -1000,7 +1000,7 @@ export async function getCloudProviderOptions(type) {
}
export async function getCloudServers(providerId) {
const query = providerId ? `?provider_id=${providerId}` : '';
const query = providerId ? `?provider_id=${encodeURIComponent(providerId)}` : '';
return this.request(`/cloud/servers${query}`);
}
+1 -1
View File
@@ -2,7 +2,7 @@
// Mounted by the backend at /api/v1/apps.
export async function getAppSnapshots(appId, limit = 50) {
return this.request(`/apps/${appId}/snapshots?limit=${limit}`);
return this.request(`/apps/${appId}/snapshots?limit=${encodeURIComponent(limit)}`);
}
export async function getAppSnapshot(appId, snapId) {
+11 -11
View File
@@ -146,7 +146,7 @@ export async function healthCheck() {
// Process endpoints
export async function getProcesses(limit = 50, sortBy = 'cpu') {
return this.request(`/processes?limit=${limit}&sort=${sortBy}`);
return this.request(`/processes?limit=${encodeURIComponent(limit)}&sort=${encodeURIComponent(sortBy)}`);
}
export async function getProcess(pid) {
@@ -154,7 +154,7 @@ export async function getProcess(pid) {
}
export async function killProcess(pid, force = false) {
return this.request(`/processes/${pid}?force=${force}`, { method: 'DELETE' });
return this.request(`/processes/${pid}?force=${encodeURIComponent(force)}`, { method: 'DELETE' });
}
export async function getServicesStatus() {
@@ -228,7 +228,7 @@ export async function previewNginxDiff(domain, config) {
}
export async function getNginxVhostLogs(domain, type = 'access', lines = 100) {
return this.request(`/nginx/advanced/logs/${domain}?type=${type}&lines=${lines}`);
return this.request(`/nginx/advanced/logs/${domain}?type=${encodeURIComponent(type)}&lines=${encodeURIComponent(lines)}`);
}
export async function getNginxLBMethods() {
@@ -241,15 +241,15 @@ export async function getLogFiles() {
}
export async function readLog(filepath, lines = 100) {
return this.request(`/logs/read?path=${encodeURIComponent(filepath)}&lines=${lines}`);
return this.request(`/logs/read?path=${encodeURIComponent(filepath)}&lines=${encodeURIComponent(lines)}`);
}
export async function searchLog(filepath, pattern, lines = 100) {
return this.request(`/logs/search?path=${encodeURIComponent(filepath)}&pattern=${encodeURIComponent(pattern)}&lines=${lines}`);
return this.request(`/logs/search?path=${encodeURIComponent(filepath)}&pattern=${encodeURIComponent(pattern)}&lines=${encodeURIComponent(lines)}`);
}
export async function getAppLogs(appName, type = 'access', lines = 100) {
return this.request(`/logs/app/${appName}?type=${type}&lines=${lines}`);
return this.request(`/logs/app/${appName}?type=${encodeURIComponent(type)}&lines=${encodeURIComponent(lines)}`);
}
export async function getJournalLogs(unit, lines = 100) {
@@ -431,7 +431,7 @@ export async function checkAlerts() {
}
export async function getAlertHistory(limit = 100) {
return this.request(`/monitoring/alerts/history?limit=${limit}`);
return this.request(`/monitoring/alerts/history?limit=${encodeURIComponent(limit)}`);
}
export async function getMonitoringConfig() {
@@ -480,7 +480,7 @@ export async function testWebhookAlert(webhookUrl) {
// Backup System endpoints
export async function getBackups(type = null) {
const params = type ? `?type=${type}` : '';
const params = type ? `?type=${encodeURIComponent(type)}` : '';
return this.request(`/backups${params}`);
}
@@ -703,11 +703,11 @@ export async function getUptimeStats() {
}
export async function getUptimeGraph(period = '24h') {
return this.request(`/uptime/graph?period=${period}`);
return this.request(`/uptime/graph?period=${encodeURIComponent(period)}`);
}
export async function getUptimeHistory(hours = 24) {
return this.request(`/uptime/history?hours=${hours}`);
return this.request(`/uptime/history?hours=${encodeURIComponent(hours)}`);
}
export async function startUptimeTracking() {
@@ -724,7 +724,7 @@ export async function getUptimeTrackingStatus() {
// Metrics History endpoints
export async function getMetricsHistory(period = '1h') {
return this.request(`/system/performance-history?period=${period}`);
return this.request(`/system/performance-history?period=${encodeURIComponent(period)}`);
}
export async function getMetricsStats() {
+1 -1
View File
@@ -48,7 +48,7 @@ export async function getTelemetryEventTypes(params = {}) {
}
export async function cleanupTelemetryEvents(days = 90) {
return this.request(`/telemetry/events?days=${days}`, { method: 'DELETE' });
return this.request(`/telemetry/events?days=${encodeURIComponent(days)}`, { method: 'DELETE' });
}
export async function emitTestTelemetryEvent(data = {}) {
+1 -1
View File
@@ -5,7 +5,7 @@ export async function getTestSandboxDistros() {
}
export async function getTestSandboxRuns(limit = 20) {
return this.request(`/test-sandbox/runs?limit=${limit}`);
return this.request(`/test-sandbox/runs?limit=${encodeURIComponent(limit)}`);
}
export async function startTestSandboxRun(distros, mode) {
@@ -2,41 +2,40 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { nextFire, untilLabel, frequencyLabel } from '../backupSchedule.js';
test('daily schedules use one clock and roll an elapsed slot across midnight', () => {
const now = new Date(2026, 8, 5, 23, 45);
const next = nextFire({ schedule_time: '00:15' }, now);
assert.equal(next.getDate(), 6);
assert.equal(untilLabel(next, now), 'in 30m');
assert.equal(now.getDate(), 5);
assert.equal(nextFire({ schedule_time: '23:45' }, now).getDate(), 6);
});
test('weekly schedules accept full and abbreviated weekdays, including next week', () => {
const now = new Date(2026, 8, 5, 12); // Saturday
assert.equal(nextFire({ schedule_time: '13:00', days: ['Saturday'] }, now).getDate(), 5);
assert.equal(nextFire({ schedule_time: '11:00', days: ['sat'] }, now).getDate(), 12);
assert.equal(frequencyLabel({ schedule_time: '02:00', days: ['sunday'] }), 'Weekly · Sun 02:00');
});
test('malformed schedules cannot silently normalize into a different run time', () => {
const now = new Date(2026, 8, 5);
for (const schedule_time of ['', '12', ':30', '24:00', '12:60', '-1:00', '12:30:00']) {
assert.equal(nextFire({ schedule_time }, now), null, schedule_time);
}
assert.equal(nextFire({ schedule_time: '12:30', days: ['noday'] }, now), null);
assert.equal(nextFire({ schedule_time: '12:30' }, new Date(NaN)), null);
});
test('spring-forward skips a wall-clock slot the server cannot execute', () => {
test('countdowns use the authoritative instant across browser timezones', () => {
const previous = process.env.TZ;
process.env.TZ = 'America/New_York';
try {
const now = new Date(2026, 2, 8, 0);
const next = nextFire({ schedule_time: '02:30', days: ['daily'] }, now);
assert.equal(next.getDate(), 9);
assert.equal(next.getHours(), 2);
for (const zone of ['UTC', 'America/Los_Angeles', 'Asia/Tokyo']) {
process.env.TZ = zone;
const next = nextFire({ schedule_time: '02:00', timezone: 'America/New_York', next_run_at: '2026-09-06T02:00:00-04:00' });
assert.equal(next.toISOString(), '2026-09-06T06:00:00.000Z');
assert.equal(untilLabel(next, new Date('2026-09-06T05:30:00Z')), 'in 30m');
}
} finally {
if (previous === undefined) delete process.env.TZ;
else process.env.TZ = previous;
}
});
test('unknown, invalid, disabled and timezone-less next runs are never guessed', () => {
for (const next_run_at of [undefined, null, '', 'invalid', '2026-09-06T02:00:00', 1788650000]) {
assert.equal(nextFire({ schedule_time: '02:00', next_run_at }), null);
}
const next_run_at = '2026-09-06T02:00:00Z';
assert.equal(nextFire({ enabled: false, next_run_at }), null);
assert.equal(nextFire({ schedule_error: 'Invalid time', next_run_at }), null);
assert.equal(nextFire({ enabled: true, next_run_at: null }), null); // Global pause.
});
test('DST folds use the backend offset and due slots do not move to tomorrow', () => {
const first = nextFire({ next_run_at: '2026-11-01T01:30:00-04:00' });
const second = nextFire({ next_run_at: '2026-11-01T01:30:00-05:00' });
assert.equal(second - first, 3600000);
assert.equal(untilLabel(first, first), 'due now');
assert.equal(untilLabel(first, second), 'due now');
});
test('frequency labels retain the server wall time and state its timezone', () => {
assert.equal(frequencyLabel({ schedule_time: '02:00', timezone: 'America/New_York' }), 'Daily · 02:00 America/New_York');
assert.equal(frequencyLabel({ schedule_time: '02:00', days: ['sunday'] }), 'Weekly · Sun 02:00');
});
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { runRecipe } from '../runRecipe.js';
test('recipe start awaits the shared mutation contract before returning its job', async () => {
const messages = [];
let resolve;
const body = { recipe_id: 'web', server_id: 7 };
const pending = runRecipe({
startRun: { mutate: (value) => {
assert.equal(value, body);
return new Promise((done) => { resolve = done; });
} },
toast: { success: (message) => messages.push(message), error: assert.fail },
t: (_key, fallback, values) => fallback.replace('{{server}}', values.server),
}, body, { serverName: 'Local' });
assert.deepEqual(messages, []);
resolve({ job_id: 'job-123' });
assert.equal(await pending, 'job-123');
assert.deepEqual(messages, ['Recipe started on Local.']);
});
test('failed recipe starts return no navigation target and report one error', async () => {
const messages = [];
const job = await runRecipe({
startRun: { mutate: async () => { throw new Error('Server is offline'); } },
toast: { success: assert.fail, error: (message) => messages.push(message) },
t: (_key, fallback) => fallback,
}, {}, { serverName: 'Local' });
assert.equal(job, null);
assert.deepEqual(messages, ['Server is offline']);
});
+10 -26
View File
@@ -3,31 +3,15 @@ const DAY_LABEL = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const scheduleDays = (schedule) => (Array.isArray(schedule.days) ? schedule.days : ['daily']);
const dayIndex = (day) => DAY_INDEX[String(day).slice(0, 3).toLowerCase()];
// Legacy schedules contain wall-clock time, without an IANA timezone. Execution
// is server-local; this preserves the existing browser-local *estimate*. An
// authoritative cross-zone countdown requires a next_run/timezone API field.
// Use a single explicit clock for both calculation and wording in each render.
export function nextFire(schedule, now) {
const match = /^(\d{1,2}):(\d{2})$/.exec(String(schedule.schedule_time || ''));
if (!match || !Number.isFinite(now?.getTime())) return null;
const hh = Number(match[1]);
const mm = Number(match[2]);
if (hh > 23 || mm > 59) return null;
const days = scheduleDays(schedule);
const daily = days.length === 0 || days.includes('daily');
const wanted = new Set(days.map(dayIndex).filter((day) => day != null));
if (!daily && wanted.size === 0) return null;
for (let offset = 0; offset <= 7; offset += 1) {
const candidate = new Date(now);
candidate.setDate(candidate.getDate() + offset);
candidate.setHours(hh, mm, 0, 0);
// Spring-forward can normalize a nonexistent 02:30 into 03:30. The
// scheduler matches wall time exactly, so skip that day's absent slot.
if (candidate.getHours() !== hh || candidate.getMinutes() !== mm) continue;
if (candidate.getTime() <= now.getTime()) continue;
if (daily || wanted.has(candidate.getDay())) return candidate;
}
return null;
// The scheduler owns wall-clock/DST calculations. Never turn schedule_time
// into a browser-local estimate: the browser and server may be in different
// zones. Require an explicit offset so malformed/older responses stay unknown.
export function nextFire(schedule) {
const value = schedule.next_run_at;
if (schedule.enabled === false || schedule.schedule_error || typeof value !== 'string'
|| !/(?:Z|[+-]\d{2}:\d{2})$/i.test(value)) return null;
const next = new Date(value);
return Number.isFinite(next.getTime()) ? next : null;
}
export function untilLabel(date, now) {
@@ -41,7 +25,7 @@ export function untilLabel(date, now) {
}
export function frequencyLabel(schedule) {
const time = schedule.schedule_time || '—';
const time = [schedule.schedule_time || '—', schedule.timezone].filter(Boolean).join(' ');
const days = scheduleDays(schedule);
if (days.length === 0 || days.includes('daily')) return `Daily · ${time}`;
if (days.length === 1) {
+14
View File
@@ -0,0 +1,14 @@
// useServerMutation exposes an async `mutate`; recipe navigation waits for its
// job ID, while this adapter owns the catalog's success/failure messages.
export async function runRecipe({ startRun, toast, t }, body, { serverName }) {
try {
const result = await startRun.mutate(body);
toast.success(t('app.recipes.started', 'Recipe started on {{server}}.', {
server: serverName,
}));
return result.job_id;
} catch (err) {
toast.error(err.message || t('app.recipes.startFailed', 'Could not start the recipe'));
return null;
}
}
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""Reproduce source counts without importing the app or opening its database.
Route declarations are source inventory, not live registered endpoint counts.
Use --collect-tests with the backend Python environment for a clean-checkout
pytest collection count. Collection does not mean the tests have passed.
"""
import argparse
import ast
from datetime import datetime, timezone
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import platform
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[1]
def source_counts(files):
routes = 0
blueprints = 0
methods = 0
dynamic_methods = 0
for source in files:
tree = ast.parse(source)
for node in ast.walk(tree):
if isinstance(node, ast.Call):
name = getattr(node.func, 'id', None) or getattr(node.func, 'attr', None)
if name == 'Blueprint':
blueprints += 1
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
for decorator in node.decorator_list:
if not isinstance(decorator, ast.Call) or not isinstance(decorator.func, ast.Attribute):
continue
name = decorator.func.attr
if name not in ('route', 'get', 'post', 'put', 'patch', 'delete'):
continue
routes += 1
if name != 'route':
methods += 1
continue
keyword = next((kw for kw in decorator.keywords if kw.arg == 'methods'), None)
try:
values = ast.literal_eval(keyword.value) if keyword else ['GET']
if not isinstance(values, (list, tuple, set)) or not all(isinstance(v, str) for v in values):
raise ValueError('nonliteral methods')
methods += len(set(values) - {'HEAD', 'OPTIONS'})
except (ValueError, TypeError):
dynamic_methods += 1
return {
'core_route_declarations': routes,
'core_blueprint_declarations': blueprints,
'explicit_method_route_pairs_excluding_head_options': methods,
'route_declarations_with_dynamic_methods': dynamic_methods,
}
def collect_tests():
spec = importlib.util.spec_from_file_location('count_tests', ROOT / 'backend/tests/check_test_count.py')
collector = importlib.util.module_from_spec(spec)
spec.loader.exec_module(collector)
# conftest normally sweeps shared temp databases. A private URL avoids
# touching another developer/test process's scratch database during census.
with tempfile.TemporaryDirectory(prefix='serverkit-census-') as directory:
before = os.environ.get('TEST_DATABASE_URL')
os.environ['TEST_DATABASE_URL'] = 'sqlite:///' + (Path(directory) / 'tests.db').as_posix()
try:
return collector.collect_count()
finally:
if before is None:
os.environ.pop('TEST_DATABASE_URL', None)
else:
os.environ['TEST_DATABASE_URL'] = before
def measure(include_tests=False):
tracked = subprocess.check_output(
['git', 'ls-files', '-z'], cwd=ROOT, stderr=subprocess.DEVNULL,
).decode('utf-8').split('\0')
api_paths = sorted(p for p in tracked if p.startswith('backend/app/api/') and p.endswith('.py'))
templates = sorted(p for p in tracked if Path(p).parent.as_posix() == 'backend/templates'
and Path(p).suffix in ('.yaml', '.yml'))
fingerprint = hashlib.sha256()
for path in api_paths + templates:
fingerprint.update(path.encode('utf-8') + b'\0' + (ROOT / path).read_bytes() + b'\0')
return {
'schema_version': 1,
'measured_at_utc': datetime.now(timezone.utc).isoformat(),
'source_revision': subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip(),
'measured_source_has_uncommitted_changes': subprocess.run(
['git', 'diff', '--quiet', 'HEAD', '--', 'backend/app/api', 'backend/templates'],
cwd=ROOT, check=False,
).returncode != 0,
'measured_source_sha256': fingerprint.hexdigest(),
'scope': 'Git-tracked core API Python declarations and root-level bundled app YAML templates; excludes extensions, dependencies and generated/install copies.',
'python_version': platform.python_version(),
'platform': platform.system(),
**source_counts([(ROOT / path).read_text(encoding='utf-8-sig') for path in api_paths]),
'core_api_source_files': len(api_paths),
'bundled_app_templates': len(templates),
'backend_tests_clean_collected': collect_tests() if include_tests else None,
'collection_scope': 'backend/tests, SERVERKIT_CLEAN_COLLECT=1; collected cases, not passed tests',
'runtime_memory_bytes': None,
'container_image_bytes': None,
}
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--collect-tests', action='store_true')
parser.add_argument('--output', type=Path)
args = parser.parse_args()
report = json.dumps(measure(args.collect_tests), indent=2) + '\n'
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(report, encoding='utf-8')
else:
print(report, end='')
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""Measure real GET requests; use only against an authorized local/staging panel.
JSON schema version 1: endpoints contain warmup and samples arrays plus summary.
Every attempt records elapsed_ms, status, response_bytes, app_ms, sql_ms,
sql_queries and error (null on success). Missing Server-Timing data stays null.
Summary distributions contain count/min/p50/p95/max, with linearly interpolated
percentiles. Summaries exclude warmup; request_count includes all attempts.
Body contents, credentials and query values are never included in reports.
"""
import argparse
from datetime import datetime, timezone
import ipaddress
from http.client import HTTPException
import json
import math
import os
from pathlib import Path
import re
import time
from urllib.error import HTTPError, URLError
from urllib.parse import parse_qsl, unquote, urlencode, urlsplit, urlunsplit
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
class NoRedirects(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def percentile(values, fraction):
if not values:
return None
ordered = sorted(values)
position = (len(ordered) - 1) * fraction
lower = math.floor(position)
upper = math.ceil(position)
return ordered[lower] + (ordered[upper] - ordered[lower]) * (position - lower)
def distribution(values):
present = [value for value in values if value is not None]
return {
'count': len(present), 'min': min(present) if present else None,
'p50': percentile(present, .5), 'p95': percentile(present, .95),
'max': max(present) if present else None,
}
def redact_path(path):
parsed = urlsplit(path)
query = urlencode([(key, '[redacted]') for key, _ in parse_qsl(parsed.query, keep_blank_values=True)])
return urlunsplit(('', '', parsed.path, query, ''))
def validate_target(base_url, paths, authenticated):
base = urlsplit(base_url)
if (base.scheme not in ('http', 'https') or not base.hostname or base.username is not None
or base.password is not None or base.query or base.fragment):
raise ValueError('base-url must be an HTTP(S) URL without credentials, query or fragment')
try:
base.port
except ValueError:
raise ValueError('base-url has an invalid port') from None
loopback = base.hostname.lower() == 'localhost'
try:
loopback = loopback or ipaddress.ip_address(base.hostname).is_loopback
except ValueError:
pass
if authenticated and base.scheme == 'http' and not loopback:
raise ValueError('credentials require HTTPS except for a loopback host')
if any(ord(char) < 32 for char in base_url):
raise ValueError('base-url contains control characters')
for path in paths:
parsed = urlsplit(path)
decoded = unquote(parsed.path)
if (parsed.scheme or parsed.netloc or parsed.fragment or not decoded.startswith('/api/')
or '\\' in decoded or any(part in ('.', '..') for part in decoded.split('/'))
or any(ord(char) < 32 for char in path + decoded)):
raise ValueError('each path must be a relative /api/ URL without traversal or fragments')
return base_url.rstrip('/')
def parse_server_timing(header):
result = {'app_ms': None, 'sql_ms': None, 'sql_queries': None}
# The panel emits unquoted numeric durations and a quoted query count.
# Ignore unrelated metrics or malformed values without inventing zeros.
for match in re.finditer(r'(?:^|,)\s*(app|db)\s*((?:;[^,]*)?)', header or ''):
name, params = match.groups()
duration = re.search(r'(?:^|;)\s*dur\s*=\s*([0-9]+(?:\.[0-9]+)?)\s*(?:;|$)', params)
if duration:
value = float(duration.group(1))
if math.isfinite(value):
result['app_ms' if name == 'app' else 'sql_ms'] = value
if name == 'db':
queries = re.search(r'(?:^|;)\s*desc\s*=\s*"([0-9]+) queries"', params)
if queries:
result['sql_queries'] = int(queries.group(1))
return result
def sample_get(opener, url, headers, timeout):
started = time.perf_counter()
sample = {'elapsed_ms': None, 'status': None, 'response_bytes': None,
'app_ms': None, 'sql_ms': None, 'sql_queries': None, 'error': None}
response = None
try:
try:
response = opener.open(Request(url, headers=headers, method='GET'), timeout=timeout)
except HTTPError as exc:
response = exc
sample['status'] = response.status
sample.update(parse_server_timing(', '.join(response.headers.get_all('Server-Timing', []))))
if 300 <= response.status < 400:
sample['error'] = 'redirect_refused'
elif not 200 <= response.status < 300:
sample['error'] = 'http_error'
size = 0
while True:
chunk = response.read(65536)
if not chunk:
break
size += len(chunk)
sample['response_bytes'] = size
except (OSError, URLError, ValueError, HTTPException) as exc:
# Exception messages may contain the URL/query or proxy credentials.
sample['error'] = f'transport_error:{type(exc).__name__}'
finally:
if response is not None:
response.close()
sample['elapsed_ms'] = (time.perf_counter() - started) * 1000
return sample
def summarize(samples):
statuses = {}
for sample in samples:
key = str(sample['status']) if sample['status'] is not None else 'unavailable'
statuses[key] = statuses.get(key, 0) + 1
return {
'request_count': len(samples), 'failed_count': sum(bool(sample['error']) for sample in samples),
'statuses': statuses,
**{field: distribution([sample[field] for sample in samples])
for field in ('elapsed_ms', 'response_bytes', 'app_ms', 'sql_ms', 'sql_queries')},
}
def profile(base_url, paths, *, samples=5, warmup=1, timeout=30, headers=None):
headers = headers or {}
base_url = validate_target(base_url, paths, bool(headers))
# Direct requests avoid implicit proxy authentication/routing from the shell.
opener = build_opener(ProxyHandler({}), NoRedirects())
report = {'schema_version': 1, 'generated_at': datetime.now(timezone.utc).isoformat(),
'base_url': base_url, 'method': 'GET', 'request_count': 0,
'failed_count': 0, 'endpoints': []}
for path in paths:
endpoint = {'path': redact_path(path), 'warmup': [], 'samples': []}
for phase, count in (('warmup', warmup), ('samples', samples)):
for _ in range(count):
sample = sample_get(opener, base_url + path, headers, timeout)
endpoint[phase].append(sample)
report['request_count'] += 1
report['failed_count'] += bool(sample['error'])
endpoint['summary'] = summarize(endpoint['samples'])
report['endpoints'].append(endpoint)
return report
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--base-url', default='http://127.0.0.1:47927')
parser.add_argument('--path', action='append', help='repeatable relative /api/ URL; default /api/v1/system/health')
parser.add_argument('--samples', type=int, default=5)
parser.add_argument('--warmup', type=int, default=1)
parser.add_argument('--timeout', type=float, default=30, help='request timeout in seconds (default 30)')
parser.add_argument('--output', type=Path, help='write JSON to this file instead of stdout')
parser.epilog = ('Optional authentication: SERVERKIT_PROFILE_TOKEN or SERVERKIT_PROFILE_API_KEY environment '
'variable (mutually exclusive). Redirects are refused; credentials require HTTPS except '
'on loopback. Exit 1 means one or more attempts failed, including warmup.')
args = parser.parse_args(argv)
if args.samples < 1 or args.warmup < 0 or not math.isfinite(args.timeout) or args.timeout <= 0:
parser.error('samples must be positive, warmup nonnegative, and timeout finite and positive')
token = os.environ.get('SERVERKIT_PROFILE_TOKEN')
api_key = os.environ.get('SERVERKIT_PROFILE_API_KEY')
if token and api_key:
parser.error('set only one of SERVERKIT_PROFILE_TOKEN and SERVERKIT_PROFILE_API_KEY')
headers = {'Authorization': f'Bearer {token}'} if token else {'X-API-Key': api_key} if api_key else {}
if any('\r' in value or '\n' in value for value in headers.values()):
parser.error('authentication value contains a newline')
try:
report = profile(args.base_url, args.path or ['/api/v1/system/health'], samples=args.samples,
warmup=args.warmup, timeout=args.timeout, headers=headers)
except ValueError as exc:
parser.error(str(exc))
output = json.dumps(report, indent=2, allow_nan=False) + '\n'
if args.output:
args.output.write_text(output, encoding='utf-8')
else:
print(output, end='')
return 1 if report['failed_count'] else 0
if __name__ == '__main__':
raise SystemExit(main())
+141
View File
@@ -0,0 +1,141 @@
"""Stdlib-only tests: python -m unittest discover -s scripts/test -p test_profile_api.py."""
from contextlib import redirect_stdout
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import importlib.util
import io
import json
import os
from pathlib import Path
import threading
import unittest
from unittest.mock import patch
SPEC = importlib.util.spec_from_file_location('profile_api', Path(__file__).parents[1] / 'profile-api.py')
profile_api = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(profile_api)
class MetricsTests(unittest.TestCase):
def test_percentiles_and_absent_metrics(self):
self.assertEqual(profile_api.percentile([30, 10, 20], .5), 20)
self.assertEqual(profile_api.percentile([10, 20, 30], .95), 29)
self.assertEqual(profile_api.percentile([10], .95), 10)
self.assertEqual(profile_api.distribution([None, None]),
{'count': 0, 'min': None, 'p50': None, 'p95': None, 'max': None})
def test_header_and_missing_header(self):
self.assertEqual(profile_api.parse_server_timing('app;dur=12.50, db;dur=2.100;desc="4 queries"'),
{'app_ms': 12.5, 'sql_ms': 2.1, 'sql_queries': 4})
self.assertEqual(profile_api.parse_server_timing(None),
{'app_ms': None, 'sql_ms': None, 'sql_queries': None})
self.assertEqual(profile_api.parse_server_timing('app;dur=nan, db;dur=-1'),
{'app_ms': None, 'sql_ms': None, 'sql_queries': None})
def test_query_values_redacted_including_repeated_and_empty(self):
redacted = profile_api.redact_path('/api/v1/find?search=secret+text&search=password&empty=&flag')
self.assertEqual(redacted, '/api/v1/find?search=%5Bredacted%5D&search=%5Bredacted%5D&empty=%5Bredacted%5D&flag=%5Bredacted%5D')
def test_targets_reject_auth_leaks_and_non_api_paths(self):
for base in ['http://example.com', 'https://user:secret@example.com',
'https://example.com?secret=key', 'ftp://localhost']:
with self.subTest(base=base), self.assertRaises(ValueError):
profile_api.validate_target(base, ['/api/v1/health'], True)
for path in ['https://other.test/api/v1', '//other.test/api/v1', '/api/../admin',
'/api/%2e%2e/admin', '/admin', '/api/v1#secret', '/api/%0dheader']:
with self.subTest(path=path), self.assertRaises(ValueError):
profile_api.validate_target('https://example.com', [path], False)
for base in ['http://localhost:1234', 'http://127.0.0.1:1234', 'http://[::1]:1234', 'https://example.com']:
self.assertEqual(profile_api.validate_target(base, ['/api/v1/health'], True), base)
class LocalHTTPTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.requests = []
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
cls.requests.append((self.path, self.headers.get('Authorization'), self.headers.get('X-API-Key')))
if self.path.startswith('/api/redirect'):
self.send_response(302)
self.send_header('Location', '/api/secret-destination')
elif self.path.startswith('/api/fail'):
self.send_response(503)
else:
self.send_response(200)
if self.path.startswith('/api/profiled'):
self.send_header('Server-Timing', 'app;dur=12.5, db;dur=2.5;desc="3 queries"')
self.end_headers()
self.wfile.write(b'private response body')
def log_message(self, *args):
pass
cls.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
cls.base = f'http://127.0.0.1:{cls.server.server_port}'
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
cls.thread.start()
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
cls.server.server_close()
cls.thread.join(timeout=5)
def setUp(self):
self.requests.clear()
def test_real_samples_warmup_bytes_headers_and_redaction(self):
result = profile_api.profile(self.base, ['/api/profiled?search=hidden-query'],
samples=3, warmup=1, headers={'Authorization': 'Bearer hidden-token'})
endpoint = result['endpoints'][0]
self.assertEqual(result['request_count'], 4)
self.assertEqual(endpoint['summary']['request_count'], 3)
self.assertEqual(endpoint['summary']['statuses'], {'200': 3})
self.assertEqual(endpoint['summary']['response_bytes']['min'], len(b'private response body'))
self.assertEqual(endpoint['summary']['sql_queries']['p95'], 3)
self.assertGreater(endpoint['summary']['elapsed_ms']['min'], 0)
self.assertEqual(self.requests[0], ('/api/profiled?search=hidden-query', 'Bearer hidden-token', None))
serialized = json.dumps(result)
for secret in ['hidden-query', 'hidden-token', 'private response body']:
self.assertNotIn(secret, serialized)
def test_missing_header_stays_null(self):
report = profile_api.profile(self.base, ['/api/plain'], samples=1, warmup=0)
endpoint = report['endpoints'][0]
self.assertIsNone(endpoint['samples'][0]['sql_queries'])
self.assertIsNone(endpoint['summary']['app_ms']['p50'])
def test_http_failure_and_redirect_are_reported_without_following(self):
report = profile_api.profile(self.base, ['/api/redirect', '/api/fail'], samples=1, warmup=0,
headers={'X-API-Key': 'secret-key'})
self.assertEqual(report['failed_count'], 2)
self.assertEqual([entry['samples'][0]['error'] for entry in report['endpoints']],
['redirect_refused', 'http_error'])
self.assertEqual([path for path, _, _ in self.requests], ['/api/redirect', '/api/fail'])
self.assertNotIn('secret-key', json.dumps(report))
def test_cli_failure_exit_and_mutually_exclusive_credentials(self):
output = io.StringIO()
with patch.dict(os.environ, {}, clear=True), redirect_stdout(output):
code = profile_api.main(['--base-url', self.base, '--path', '/api/fail', '--samples', '1', '--warmup', '0'])
self.assertEqual(code, 1)
self.assertEqual(json.loads(output.getvalue())['failed_count'], 1)
with patch.dict(os.environ, {'SERVERKIT_PROFILE_TOKEN': 'one', 'SERVERKIT_PROFILE_API_KEY': 'two'}):
with patch('sys.stderr', io.StringIO()), self.assertRaises(SystemExit) as failure:
profile_api.main([])
self.assertEqual(failure.exception.code, 2)
def test_transport_error_does_not_echo_exception_url(self):
opener = profile_api.build_opener()
with patch.object(opener, 'open', side_effect=profile_api.URLError('https://host?token=secret')):
sample = profile_api.sample_get(opener, self.base + '/api/test?token=secret', {}, 1)
self.assertEqual(sample['error'], 'transport_error:URLError')
self.assertIsNone(sample['status'])
self.assertIsNone(sample['response_bytes'])
self.assertNotIn('secret', json.dumps(sample))
if __name__ == '__main__':
unittest.main()