v0.5.30-beta - rollen, app-store-filter en twee planningsfouten
Some checks failed
Deploy server-up (dev) / deploy (push) Failing after 2s
Some checks failed
Deploy server-up (dev) / deploy (push) Failing after 2s
Rollen per gebruiker: - Drie rollen: beheerder (alles), operator (stacks, containers en backups) en alleen lezen. Afgedwongen in de before_request-guard, niet alleen in de UI. - Eigen wachtwoord wijzigen mag iedereen; de laatste beheerder kan niet gedegradeerd of verwijderd worden. - Proxy-gebruikers zonder eigen account krijgen AUTH.proxy_role; bestaat er wel een lokaal account met die naam, dan wint dat account. - Accounts van voor deze versie hebben geen rol en gelden als beheerder. Rechtenescalatie verholpen: - set_password verving het hele gebruikersrecord, waardoor het role-veld wegviel. Een ontbrekende rol geldt als beheerder (nodig voor bestaande accounts), dus elke operator of viewer kon zichzelf tot beheerder promoveren door zijn eigen wachtwoord te wijzigen. Gevonden bij het naspelen van de rollenflow tegen een draaiende instantie. Geplande backups draaiden nooit: - De scheduler stempelde ook taken af die zichzelf hadden overgeslagen. De backup-taak bewaakt zelf het ingestelde uur, dus die werd afgestempeld op het moment van de eerste tick; 24 uur later viel het volgende moment op precies dat verkeerde tijdstip. Een taak stempelt nu alleen af bij True. De backup-taak draait op een kort interval en beslist zelf, zodat een gewijzigde planning meteen werkt. BACKUP_SCHEDULE_DAY toegevoegd voor wekelijks. Verder: - Zoekveld en tagfilters in de app store; repo's zonder treffers vallen weg. - Opstartcontrole op de meegeleverde front-end-bestanden: mislukt het downloaden tijdens de image-build, dan laadde de UI zonder opmaak terwijl de server prima leek te draaien. Nu een duidelijke melding in het log. - 227 tests (15 nieuwe voor rollen, 15 voor de scheduler). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
This commit is contained in:
parent
ae99df2fc7
commit
f40fd39ed9
12 changed files with 660 additions and 38 deletions
55
CHANGELOG.md
55
CHANGELOG.md
|
|
@ -1,3 +1,58 @@
|
|||
# v0.5.30-beta — Rollen, app-store-filter en twee planningsfouten
|
||||
|
||||
## 👥 Rollen per gebruiker
|
||||
|
||||
Tot nu toe had elk account volledige toegang. Nu drie rollen:
|
||||
|
||||
| Rol | Mag |
|
||||
|---|---|
|
||||
| **Beheerder** | alles |
|
||||
| **Operator** | stacks, containers en backups beheren — niet de instellingen, gebruikers, repo's, netwerken of modules |
|
||||
| **Alleen lezen** | alleen bekijken |
|
||||
|
||||
- Afgedwongen in de `before_request`-guard, niet alleen in de interface.
|
||||
- Je eigen wachtwoord wijzigen mag iedereen, ongeacht rol.
|
||||
- De laatste beheerder kan niet gedegradeerd of verwijderd worden.
|
||||
- Wie via de reverse proxy binnenkomt zonder eigen account krijgt de rol uit
|
||||
`AUTH.proxy_role`; bestaat er wél een lokaal account met die naam, dan wint
|
||||
dat account.
|
||||
- Accounts van vóór deze versie hebben geen rol en gelden als beheerder.
|
||||
|
||||
## 🔒 Rechtenescalatie verholpen
|
||||
|
||||
Bij het wijzigen van een wachtwoord werd het hele gebruikersrecord vervangen,
|
||||
waardoor het `role`-veld wegviel. Omdat een ontbrekende rol als beheerder geldt
|
||||
(nodig voor bestaande accounts), kon **elke operator of viewer zichzelf tot
|
||||
beheerder promoveren door zijn eigen wachtwoord te wijzigen.** Gevonden bij het
|
||||
naspelen van de rollenflow; de rol blijft nu behouden en een test dekt het af.
|
||||
|
||||
## ⏰ Geplande backups draaiden nooit
|
||||
|
||||
De scheduler stempelde ook taken af die zichzelf hadden overgeslagen. De
|
||||
backup-taak bewaakt zelf of het het ingestelde uur is, dus die werd afgestempeld
|
||||
op het moment van de eerste tick — waarna het volgende moment 24 uur later op
|
||||
precies dat verkeerde tijdstip viel. Resultaat: de geplande backup kwam nooit
|
||||
aan de beurt.
|
||||
|
||||
Een taak stempelt nu alleen af als hij `True` teruggeeft. De backup-taak draait
|
||||
daardoor op een kort interval en bepaalt zelf wanneer het zover is, zodat een
|
||||
gewijzigde planning ook meteen werkt zonder herstart. Voor wekelijkse backups is
|
||||
`BACKUP_SCHEDULE_DAY` toegevoegd.
|
||||
|
||||
## 🔎 Zoeken en filteren in de app store
|
||||
|
||||
Zoekveld over naam, omschrijving en tags, plus klikbare tagfilters (de veertien
|
||||
meestgebruikte, op frequentie gesorteerd). Repo's zonder treffers vallen weg.
|
||||
|
||||
## 🔧 Overig
|
||||
|
||||
- Opstartcontrole op de meegeleverde front-end-bestanden. Mislukt het downloaden
|
||||
tijdens de image-build, dan laadde de interface zonder opmaak terwijl de
|
||||
server prima leek te draaien; nu staat er een duidelijke melding in het log.
|
||||
- 227 tests.
|
||||
|
||||
---
|
||||
|
||||
# v0.5.20-beta — Vertalingen, SSO-scherm, complete backups en app-updates
|
||||
|
||||
## 🌍 Vertalingen en SSO
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.5.20-beta
|
||||
0.5.30-beta
|
||||
|
|
|
|||
115
server-up/app.py
115
server-up/app.py
|
|
@ -1,7 +1,7 @@
|
|||
"""Server Up — Docker Manager."""
|
||||
from __future__ import annotations
|
||||
import inspect, json, os, re, secrets, shutil, subprocess, sys, time
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure app dir is in sys.path so modules can import 'from modules.base' and 'from core'
|
||||
|
|
@ -152,6 +152,30 @@ PUBLIC_PATHS = frozenset({
|
|||
})
|
||||
|
||||
|
||||
# Routes die alleen een beheerder mag aanroepen: alles wat de installatie zelf
|
||||
# verandert (accounts, instellingen, repo's, netwerken, modules, updates).
|
||||
# Stacks beheren mag een operator ook; een viewer mag helemaal niets muteren.
|
||||
ADMIN_ONLY_PREFIXES = (
|
||||
"/api/auth/users", "/api/auth/mode", "/api/settings", "/api/networks",
|
||||
"/api/repos", "/api/modrepos", "/api/modules", "/api/wizard",
|
||||
"/api/update/", "/api/audit/clear", "/api/docker/restart",
|
||||
"/api/docker/images",
|
||||
)
|
||||
|
||||
# Uitzondering: je eigen wachtwoord wijzigen mag iedereen.
|
||||
ROLE_EXEMPT = ("/api/auth/password", "/api/auth/logout")
|
||||
|
||||
|
||||
def _mag_muteren(rol: str, pad: str) -> bool:
|
||||
if pad in ROLE_EXEMPT:
|
||||
return True
|
||||
if rol == "admin":
|
||||
return True
|
||||
if rol == "operator":
|
||||
return not pad.startswith(ADMIN_ONLY_PREFIXES)
|
||||
return False # viewer
|
||||
|
||||
|
||||
@app.before_request
|
||||
def require_auth():
|
||||
p = request.path
|
||||
|
|
@ -163,6 +187,9 @@ def require_auth():
|
|||
if request.method not in ("GET", "HEAD", "OPTIONS"):
|
||||
if not auth.verify_csrf(request.headers.get("X-CSRF-Token")):
|
||||
return jsonify(ok=False, msg="Ongeldig of ontbrekend CSRF-token"), 403
|
||||
if not _mag_muteren(auth.current_role(), p):
|
||||
return jsonify(ok=False,
|
||||
msg="Je rol geeft geen toegang tot deze actie"), 403
|
||||
return None
|
||||
if auth.needs_setup():
|
||||
return jsonify(ok=False, msg="Server Up is nog niet ingesteld",
|
||||
|
|
@ -175,6 +202,7 @@ def api_auth_me():
|
|||
user = auth.current_user()
|
||||
a = auth.settings()
|
||||
return jsonify(authenticated=bool(user), user=user or "",
|
||||
role=auth.current_role() if user else "",
|
||||
needs_setup=auth.needs_setup(), mode=a.get("mode", "local"),
|
||||
version=VERSION,
|
||||
csrf_token=auth.csrf_token() if user else "")
|
||||
|
|
@ -233,18 +261,31 @@ def api_auth_password():
|
|||
@app.route("/api/auth/users")
|
||||
def api_auth_users():
|
||||
a = auth.settings()
|
||||
return jsonify(users=auth.users(), mode=a.get("mode", "local"),
|
||||
return jsonify(users=[{"name": n, "role": auth.role_of(n)} for n in auth.users()],
|
||||
roles=list(auth.ROLES),
|
||||
mode=a.get("mode", "local"),
|
||||
proxy_header=a.get("proxy_header", ""),
|
||||
proxy_role=a.get("proxy_role", "admin"),
|
||||
trusted_proxies=a.get("trusted_proxies", []))
|
||||
|
||||
|
||||
@app.route("/api/auth/users", methods=["POST"])
|
||||
def api_auth_users_add():
|
||||
d = request.json or {}
|
||||
ok, msg = auth.create_user(d.get("username", ""), d.get("password", ""))
|
||||
ok, msg = auth.create_user(d.get("username", ""), d.get("password", ""),
|
||||
d.get("role", auth.DEFAULT_ROLE))
|
||||
if ok:
|
||||
audit.log("auth", "user_add", "ok", ref=d.get("username", ""),
|
||||
ip=request.remote_addr)
|
||||
detail=d.get("role", ""), ip=request.remote_addr)
|
||||
return jsonify(ok=ok, msg=msg), (200 if ok else 400)
|
||||
|
||||
|
||||
@app.route("/api/auth/users/<name>/role", methods=["PUT"])
|
||||
def api_auth_user_role(name):
|
||||
ok, msg = auth.set_role(name, (request.json or {}).get("role", ""))
|
||||
if ok:
|
||||
audit.log("auth", "user_role", "ok", ref=name,
|
||||
detail=(request.json or {}).get("role", ""), ip=request.remote_addr)
|
||||
return jsonify(ok=ok, msg=msg), (200 if ok else 400)
|
||||
|
||||
|
||||
|
|
@ -275,7 +316,10 @@ def api_auth_mode():
|
|||
return jsonify(ok=False,
|
||||
msg="In modus 'proxy' vervalt de lokale login. Bevestig "
|
||||
"dat de reverse proxy werkt voordat je omschakelt."), 400
|
||||
a.update({"mode": mode,
|
||||
proxy_rol = d.get("proxy_role", a.get("proxy_role", "admin"))
|
||||
if proxy_rol not in auth.ROLES:
|
||||
return jsonify(ok=False, msg="Onbekende rol voor proxy-gebruikers"), 400
|
||||
a.update({"mode": mode, "proxy_role": proxy_rol,
|
||||
"proxy_header": (d.get("proxy_header") or a.get("proxy_header") or "").strip(),
|
||||
"trusted_proxies": [str(p).strip() for p in proxies if str(p).strip()]})
|
||||
cfg.patch({"AUTH": a})
|
||||
|
|
@ -1243,8 +1287,7 @@ def api_backup_create(stack):
|
|||
jobs.log(qq, "section", f"Backup: {stack}")
|
||||
meta = backups.create(stack, reden="handmatig",
|
||||
log_fn=lambda m: jobs.log(qq, "dim", m))
|
||||
for weg in backups.prune(stack, log_fn=lambda m: jobs.log(qq, "dim", m)):
|
||||
pass
|
||||
backups.prune(stack, log_fn=lambda m: jobs.log(qq, "dim", m))
|
||||
jobs.log(qq, "ok", f"Backup klaar ({meta['file']})")
|
||||
audit.log("backups", "create", "ok", ref=stack, ip=request.remote_addr)
|
||||
jobs.finish(jid, "done")
|
||||
|
|
@ -2111,17 +2154,24 @@ def _apply_boot_repos():
|
|||
audit.log("core", "boot_repo", "ok", ref=r.get("id"), detail=r.get("url"))
|
||||
|
||||
|
||||
def _geplande_backups():
|
||||
"""Backup van alle stacks, aangestuurd door de scheduler."""
|
||||
def _geplande_backups() -> bool:
|
||||
"""Backup van alle stacks, aangestuurd door de scheduler.
|
||||
|
||||
Retourneert True als er daadwerkelijk gedraaid is; False betekent "nog niet
|
||||
het juiste moment" en zorgt dat de scheduler de taak níét afstempelt.
|
||||
"""
|
||||
c = cfg.load()
|
||||
plan = (c.get("BACKUP_SCHEDULE") or "off").lower()
|
||||
if plan not in ("daily", "weekly"):
|
||||
return
|
||||
return False
|
||||
if not scheduler.binnen_venster(int(c.get("BACKUP_SCHEDULE_HOUR") or 3)):
|
||||
return
|
||||
return False
|
||||
# Wekelijks: alleen op de ingestelde weekdag (standaard maandag).
|
||||
if plan == "weekly" and datetime.now().weekday() != int(c.get("BACKUP_SCHEDULE_DAY") or 0):
|
||||
return False
|
||||
lib = Path(c["LIBRARY_DIR"]).resolve()
|
||||
if not lib.is_dir():
|
||||
return
|
||||
return False
|
||||
for d in sorted(lib.iterdir()):
|
||||
if not d.is_dir() or d.name.startswith(".") or not docker.has_compose(d):
|
||||
continue
|
||||
|
|
@ -2133,16 +2183,25 @@ def _geplande_backups():
|
|||
print(f" [backup] {d.name} mislukt: {e}")
|
||||
audit.log("backups", "scheduled", "error", ref=d.name, detail=str(e))
|
||||
backups.prune()
|
||||
return True
|
||||
|
||||
|
||||
def _check_stack_updates() -> bool:
|
||||
"""Updatecheck voor alle stacks; altijd True zodat de scheduler afstempelt
|
||||
(check_all geeft een lege dict terug als er geen stacks zijn)."""
|
||||
stackupdates.check_all(force=True)
|
||||
return True
|
||||
|
||||
|
||||
def _registreer_taken():
|
||||
"""Periodieke taken aanmelden bij de scheduler."""
|
||||
c = cfg.load()
|
||||
# Dagelijks/wekelijks; de venstercheck in _geplande_backups bepaalt het uur.
|
||||
uren = 24.0 if (c.get("BACKUP_SCHEDULE") or "off") != "weekly" else 24.0 * 7
|
||||
scheduler.register("backups", _geplande_backups, interval_uren=uren)
|
||||
scheduler.register("stack_updates",
|
||||
lambda: stackupdates.check_all(force=True),
|
||||
"""Periodieke taken aanmelden bij de scheduler.
|
||||
|
||||
De backup-taak krijgt bewust een kort interval: hij bewaakt zélf of het
|
||||
juiste uur en de juiste dag is, en stempelt pas af als hij gedraaid heeft.
|
||||
Zo werkt een gewijzigde planning meteen, zonder herstart.
|
||||
"""
|
||||
scheduler.register("backups", _geplande_backups, interval_uren=0.5)
|
||||
scheduler.register("stack_updates", _check_stack_updates,
|
||||
interval_uren=24.0, enabled_key="STACK_UPDATE_CHECK")
|
||||
|
||||
|
||||
|
|
@ -2182,10 +2241,28 @@ def _boot_sync():
|
|||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _controleer_assets():
|
||||
"""Waarschuw als de meegeleverde front-end-bestanden ontbreken.
|
||||
|
||||
Die worden tijdens de image-build gedownload (zie Dockerfile). Mislukt dat,
|
||||
dan laadt de interface zonder opmaak en zonder Alpine — een lege pagina dus.
|
||||
Zonder deze melding is dat lastig te herleiden, want de server draait prima.
|
||||
"""
|
||||
ontbreekt = [naam for naam in ("vendor/tailwind.js", "vendor/alpine.min.js",
|
||||
"vendor/htmx.min.js", "fonts/mdi.min.css")
|
||||
if not (APP / "static" / naam).is_file()]
|
||||
if ontbreekt:
|
||||
print("\n⚠ Front-end-bestanden ontbreken: " + ", ".join(ontbreekt))
|
||||
print(" De interface laadt dan zonder opmaak. Bouw het image opnieuw")
|
||||
print(" (docker compose build --pull) of draai met SU_DEV=1 lokaal.\n")
|
||||
return not ontbreekt
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(os.environ.get("PORT", 5000))
|
||||
host = os.environ.get("SU_BIND", "0.0.0.0")
|
||||
print(f"\n🚀 Server Up {VERSION} · http://{host}:{port}\n")
|
||||
_controleer_assets()
|
||||
audit.init()
|
||||
i18n.load()
|
||||
print("Boot repos:")
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ DEFAULTS = {
|
|||
"BACKUP_MAX_AGE_DAYS": 0,
|
||||
"BACKUP_SCHEDULE": "off", # off | daily | weekly
|
||||
"BACKUP_SCHEDULE_HOUR": 3,
|
||||
"BACKUP_SCHEDULE_DAY": 0, # weekly: 0 = maandag … 6 = zondag
|
||||
"BACKUP_BEFORE_UPDATE": True,
|
||||
"BACKUP_BEFORE_REMOVE": True,
|
||||
# Laatste uitvoering van geplande taken (beheerd door core.scheduler).
|
||||
|
|
|
|||
|
|
@ -34,11 +34,16 @@ _FAILS_LOCK = threading.Lock()
|
|||
_MAX_FAILS = 5
|
||||
_LOCKOUT_SECONDS = 300
|
||||
|
||||
# Rollen, van veel naar weinig rechten.
|
||||
ROLES = ("admin", "operator", "viewer")
|
||||
DEFAULT_ROLE = "admin"
|
||||
|
||||
DEFAULT_AUTH = {
|
||||
"mode": "local", # local | proxy | both
|
||||
"users": {}, # {naam: {salt, hash}}
|
||||
"users": {}, # {naam: {salt, hash, role}}
|
||||
"proxy_header": "Remote-User",
|
||||
"trusted_proxies": [], # IP's of CIDR's die de header mogen zetten
|
||||
"proxy_role": "admin", # rol voor wie via de proxy binnenkomt
|
||||
"session_hours": 12,
|
||||
}
|
||||
|
||||
|
|
@ -139,21 +144,51 @@ def needs_setup() -> bool:
|
|||
return not a["users"]
|
||||
|
||||
|
||||
def create_user(name: str, password: str) -> tuple[bool, str]:
|
||||
def role_of(name: str) -> str:
|
||||
"""Rol van een gebruiker. Accounts van vóór de rollen gelden als admin."""
|
||||
rec = settings()["users"].get(name) or {}
|
||||
rol = rec.get("role")
|
||||
return rol if rol in ROLES else DEFAULT_ROLE
|
||||
|
||||
|
||||
def admins() -> list[str]:
|
||||
a = settings()
|
||||
return [n for n in a["users"] if role_of(n) == "admin"]
|
||||
|
||||
|
||||
def create_user(name: str, password: str, role: str = DEFAULT_ROLE) -> tuple[bool, str]:
|
||||
name = (name or "").strip()
|
||||
if not name or len(name) > 64:
|
||||
return False, "Ongeldige gebruikersnaam."
|
||||
if role not in ROLES:
|
||||
return False, "Onbekende rol."
|
||||
problem = password_problem(password)
|
||||
if problem:
|
||||
return False, problem
|
||||
a = settings()
|
||||
if name in a["users"]:
|
||||
return False, "Gebruiker bestaat al."
|
||||
a["users"][name] = hash_password(password)
|
||||
rec = hash_password(password)
|
||||
rec["role"] = role
|
||||
a["users"][name] = rec
|
||||
_save(a)
|
||||
return True, "Account aangemaakt."
|
||||
|
||||
|
||||
def set_role(name: str, role: str) -> tuple[bool, str]:
|
||||
if role not in ROLES:
|
||||
return False, "Onbekende rol."
|
||||
a = settings()
|
||||
if name not in a["users"]:
|
||||
return False, "Gebruiker niet gevonden."
|
||||
# Zonder admin kun je niemand meer beheren en jezelf niet terugzetten.
|
||||
if role != "admin" and admins() == [name]:
|
||||
return False, "Dit is de laatste beheerder — die rol kan niet weg."
|
||||
a["users"][name]["role"] = role
|
||||
_save(a)
|
||||
return True, f"Rol van {name} is nu {role}."
|
||||
|
||||
|
||||
def set_password(name: str, password: str) -> tuple[bool, str]:
|
||||
problem = password_problem(password)
|
||||
if problem:
|
||||
|
|
@ -161,7 +196,12 @@ def set_password(name: str, password: str) -> tuple[bool, str]:
|
|||
a = settings()
|
||||
if name not in a["users"]:
|
||||
return False, "Gebruiker niet gevonden."
|
||||
a["users"][name] = hash_password(password)
|
||||
# Het hele record vervangen zou de rol wissen. Een ontbrekende rol geldt als
|
||||
# admin (voor accounts van vóór de rollen), dus dan zou iedereen zichzelf
|
||||
# tot beheerder promoveren door z'n wachtwoord te wijzigen.
|
||||
rec = hash_password(password)
|
||||
rec["role"] = role_of(name)
|
||||
a["users"][name] = rec
|
||||
_save(a)
|
||||
return True, "Wachtwoord gewijzigd."
|
||||
|
||||
|
|
@ -172,6 +212,8 @@ def delete_user(name: str) -> tuple[bool, str]:
|
|||
return False, "Gebruiker niet gevonden."
|
||||
if len(a["users"]) == 1 and a.get("mode") != "proxy":
|
||||
return False, "De laatste gebruiker kan niet verwijderd worden."
|
||||
if admins() == [name] and a.get("mode") != "proxy":
|
||||
return False, "Dit is de laatste beheerder."
|
||||
del a["users"][name]
|
||||
_save(a)
|
||||
return True, "Gebruiker verwijderd."
|
||||
|
|
@ -263,6 +305,24 @@ def start_session(name: str, via: str = "local"):
|
|||
session["csrf"] = secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def current_role() -> str:
|
||||
"""Rol van de huidige gebruiker.
|
||||
|
||||
Wie via de proxy binnenkomt staat niet in `users`; die krijgt de rol uit
|
||||
`AUTH.proxy_role`, tenzij er toevallig een lokaal account met dezelfde naam
|
||||
bestaat — dan wint dat account.
|
||||
"""
|
||||
naam = session.get("user")
|
||||
if not naam:
|
||||
return ""
|
||||
if naam in settings()["users"]:
|
||||
return role_of(naam)
|
||||
if session.get("via") == "proxy":
|
||||
rol = settings().get("proxy_role")
|
||||
return rol if rol in ROLES else DEFAULT_ROLE
|
||||
return DEFAULT_ROLE
|
||||
|
||||
|
||||
def end_session():
|
||||
session.clear()
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,13 @@ _gestart = False
|
|||
def register(naam: str, fn, interval_uren: float = 24.0, enabled_key: str = ""):
|
||||
"""Registreer een periodieke taak.
|
||||
|
||||
`fn` retourneert **True** als het werk daadwerkelijk gedaan is, en False of
|
||||
None als het moment nog niet daar was. Alleen bij True wordt de taak
|
||||
afgestempeld. Dat onderscheid is essentieel voor taken die zelf nog een
|
||||
tijdvenster bewaken: zou een overgeslagen taak ook afgestempeld worden, dan
|
||||
verschuift het volgende moment naar precies dat verkeerde tijdstip en komt
|
||||
de taak nooit meer aan de beurt.
|
||||
|
||||
`enabled_key` verwijst naar een config-sleutel; staat die op iets falsy, dan
|
||||
wordt de taak overgeslagen (maar blijft hij geregistreerd).
|
||||
"""
|
||||
|
|
@ -66,13 +73,15 @@ def run_due(log_fn=None) -> list[str]:
|
|||
continue
|
||||
if log_fn:
|
||||
log_fn(f"[scheduler] {naam}")
|
||||
taak["fn"]()
|
||||
if taak["fn"]() is not True:
|
||||
continue # overgeslagen: niet afstempelen
|
||||
_markeer(naam)
|
||||
gedraaid.append(naam)
|
||||
except Exception:
|
||||
# Eén kapotte taak mag de lus niet stoppen.
|
||||
# Eén kapotte taak mag de lus niet stoppen. Wél afstempelen, anders
|
||||
# blijft een structureel falende taak elke tick opnieuw proberen.
|
||||
print(f" [scheduler] {naam} faalde:\n{traceback.format_exc()}")
|
||||
_markeer(naam) # niet blijven herhalen in een lus
|
||||
_markeer(naam)
|
||||
return gedraaid
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -330,14 +330,28 @@ tailwind.config = {
|
|||
|
||||
<!-- ══ App Store ══ -->
|
||||
<section x-show="page === 'app_store'" x-cloak class="anim">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-5">
|
||||
<p class="text-sm text-t2 dark:text-t2-dark flex-1" x-text="t('app_store')">App Store</p>
|
||||
<div class="flex flex-wrap items-center gap-2 mb-4">
|
||||
<div class="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<span class="mdi mdi-magnify absolute left-3.5 top-1/2 -translate-y-1/2 text-dm dark:text-dm-dark text-lg"></span>
|
||||
<input x-model="storeQuery" :placeholder="t('search_apps')" class="form-input !pl-10">
|
||||
</div>
|
||||
<button @click="syncAllRepos()" class="btn"><span class="mdi mdi-source-pull"></span> <span x-text="t('pull')">Pull</span></button>
|
||||
<button @click="modal='add-repo'" class="btn btn-primary"><span class="mdi mdi-plus"></span> <span x-text="t('add_repo')">Repository</span></button>
|
||||
</div>
|
||||
<!-- Tags uit alle repo's samen; klikken filtert de lijst. -->
|
||||
<div class="flex flex-wrap gap-1.5 mb-5" x-show="storeTags.length">
|
||||
<button class="text-[11px] font-semibold px-2.5 py-1 rounded-lg border transition"
|
||||
:class="!storeTag ? 'border-ac bg-ac/10 text-ac dark:text-ac-dark' : 'border-bd dark:border-bd-dark text-t2 dark:text-t2-dark hover:border-bd2'"
|
||||
@click="storeTag = ''" x-text="t('all')">Alle</button>
|
||||
<template x-for="tag in storeTags" :key="tag">
|
||||
<button class="text-[11px] font-mono px-2.5 py-1 rounded-lg border transition"
|
||||
:class="storeTag === tag ? 'border-ac bg-ac/10 text-ac dark:text-ac-dark' : 'border-bd dark:border-bd-dark text-t2 dark:text-t2-dark hover:border-bd2'"
|
||||
@click="storeTag = (storeTag === tag ? '' : tag)" x-text="tag"></button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="space-y-7">
|
||||
<template x-for="repo in repos" :key="repo.id">
|
||||
<div>
|
||||
<div x-show="!storeQuery && !storeTag || filteredStore(repo.id).length">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="mdi mdi-source-branch text-ac dark:text-ac-dark text-lg"></span>
|
||||
<span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="repo.name"></span>
|
||||
|
|
@ -346,7 +360,7 @@ tailwind.config = {
|
|||
<button @click="deleteRepo(repo.id)" class="btn-icon w-9 h-9 !text-er hover:!border-er/40"><span class="mdi mdi-delete-outline"></span></button>
|
||||
</div>
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<template x-for="stack in (storeStacks[repo.id] || [])" :key="stack.dir">
|
||||
<template x-for="stack in filteredStore(repo.id)" :key="stack.dir">
|
||||
<div class="card card-hover p-4 flex flex-col">
|
||||
<div class="flex items-start gap-3 mb-3">
|
||||
<template x-if="stack.logo_url">
|
||||
|
|
@ -479,20 +493,30 @@ tailwind.config = {
|
|||
<div>
|
||||
<div class="form-label" x-text="t('users')">Gebruikers</div>
|
||||
<div class="space-y-1.5 mb-3">
|
||||
<template x-for="u in users" :key="u">
|
||||
<template x-for="u in users" :key="u.name">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="mdi mdi-account-outline text-t2 dark:text-t2-dark"></span>
|
||||
<span class="text-tx dark:text-tx-dark flex-1" x-text="u"></span>
|
||||
<span class="text-tx dark:text-tx-dark flex-1 truncate" x-text="u.name"></span>
|
||||
<select class="form-input !h-8 !w-auto !text-xs !px-2" :value="u.role"
|
||||
@change="setRole(u.name, $event.target.value)">
|
||||
<template x-for="r in roles" :key="r">
|
||||
<option :value="r" x-text="t('role_'+r)"></option>
|
||||
</template>
|
||||
</select>
|
||||
<button class="btn-sm !h-7 !px-2 btn-danger" x-show="users.length > 1"
|
||||
@click="delUser(u)"><span class="mdi mdi-delete-outline"></span></button>
|
||||
@click="delUser(u.name)"><span class="mdi mdi-delete-outline"></span></button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<input class="form-input" x-model="newUser.username" :placeholder="t('username')">
|
||||
<input type="password" class="form-input" x-model="newUser.password" :placeholder="t('password_min')">
|
||||
<select class="form-input" x-model="newUser.role">
|
||||
<template x-for="r in roles" :key="r"><option :value="r" x-text="t('role_'+r)"></option></template>
|
||||
</select>
|
||||
<button class="btn btn-sm" @click="addUser()"><span class="mdi mdi-account-plus-outline"></span> <span x-text="t('add_user')">Gebruiker toevoegen</span></button>
|
||||
</div>
|
||||
<p class="text-[11px] text-dm dark:text-dm-dark mt-2" x-text="t('roles_hint')"></p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="form-label" x-text="t('change_own_password')">Eigen wachtwoord wijzigen</div>
|
||||
|
|
@ -524,6 +548,13 @@ tailwind.config = {
|
|||
<input class="form-input font-mono" x-model="authCfg.proxy_header" placeholder="Remote-User">
|
||||
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('proxy_header_hint')"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" x-text="t('proxy_role')">Rol voor proxy-gebruikers</label>
|
||||
<select class="form-input" x-model="authCfg.proxy_role">
|
||||
<template x-for="r in roles" :key="r"><option :value="r" x-text="t('role_'+r)"></option></template>
|
||||
</select>
|
||||
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('proxy_role_hint')"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" x-text="t('trusted_proxies')">Vertrouwde proxy-adressen</label>
|
||||
<textarea class="form-input font-mono !h-auto py-2" rows="3"
|
||||
|
|
@ -1186,7 +1217,8 @@ function app() {
|
|||
// is; tot die tijd staat het login- of setupscherm ervoor.
|
||||
auth: { ready: false, authenticated: false, user: '', needsSetup: false, mode: 'local', csrf: '' },
|
||||
loginForm: { username: '', password: '', password2: '', error: '', busy: false },
|
||||
users: [], newUser: { username: '', password: '' },
|
||||
users: [], roles: ['admin','operator','viewer'],
|
||||
newUser: { username: '', password: '', role: 'operator' },
|
||||
pwForm: { current: '', new: '', new2: '' },
|
||||
authCfg: { mode: 'local', proxy_header: 'Remote-User', trusted_text: '' },
|
||||
|
||||
|
|
@ -1204,6 +1236,7 @@ function app() {
|
|||
docker: { running: 0, containers: 0, images: 0 },
|
||||
stacks: [],
|
||||
stackQuery: '',
|
||||
storeQuery: '', storeTag: '',
|
||||
images: [],
|
||||
audit: [],
|
||||
repos: [],
|
||||
|
|
@ -1398,16 +1431,27 @@ function app() {
|
|||
async loadUsers() {
|
||||
const d = await this.rpc('/api/auth/users');
|
||||
this.users = d.users || [];
|
||||
this.roles = d.roles || this.roles;
|
||||
this.authCfg = {
|
||||
mode: d.mode || 'local',
|
||||
proxy_header: d.proxy_header || 'Remote-User',
|
||||
proxy_role: d.proxy_role || 'admin',
|
||||
trusted_text: (d.trusted_proxies || []).join('\n'),
|
||||
};
|
||||
},
|
||||
async setRole(name, role) {
|
||||
const d = await this.req('/api/auth/users/' + encodeURIComponent(name) + '/role',
|
||||
{ method: 'PUT', json: { role } });
|
||||
this.toast(d.msg || this.t(d.ok ? 'saved' : 'error'), d.ok ? 'ok' : 'er');
|
||||
this.loadUsers();
|
||||
// Je eigen rol kan gewijzigd zijn; dat bepaalt wat de UI toont.
|
||||
if (name === this.auth.user) this.loadAuth();
|
||||
},
|
||||
async saveAuthMode() {
|
||||
const body = {
|
||||
mode: this.authCfg.mode,
|
||||
proxy_header: this.authCfg.proxy_header,
|
||||
proxy_role: this.authCfg.proxy_role,
|
||||
trusted_proxies: this.authCfg.trusted_text.split('\n').map(s => s.trim()).filter(Boolean),
|
||||
};
|
||||
// De server weigert 'proxy' zonder bevestiging: die modus schakelt de
|
||||
|
|
@ -1501,6 +1545,33 @@ function app() {
|
|||
async loadDashboard() { await this.loadDocker(); await this.loadStacks(); },
|
||||
|
||||
async loadStacks() { const d = await this.rpc('/api/stacks'); this.stacks = Array.isArray(d) ? d : (d.stacks || []); },
|
||||
// Apps filteren op naam, omschrijving en tags; tagfilter werkt aanvullend.
|
||||
filteredStore(repoId) {
|
||||
const lijst = this.storeStacks[repoId] || [];
|
||||
const q = this.storeQuery.trim().toLowerCase();
|
||||
return lijst.filter(s => {
|
||||
if (this.storeTag && !(s.tags || []).includes(this.storeTag)) return false;
|
||||
if (!q) return true;
|
||||
return (s.name || '').toLowerCase().includes(q)
|
||||
|| (s.dir || '').toLowerCase().includes(q)
|
||||
|| (s.description || '').toLowerCase().includes(q)
|
||||
|| (s.tags || []).some(t => String(t).toLowerCase().includes(q));
|
||||
});
|
||||
},
|
||||
// Alle tags uit alle repo's, op frequentie gesorteerd zodat de bruikbaarste
|
||||
// filters vooraan staan.
|
||||
get storeTags() {
|
||||
const telling = {};
|
||||
for (const lijst of Object.values(this.storeStacks || {})) {
|
||||
for (const s of lijst) for (const t of (s.tags || [])) {
|
||||
telling[t] = (telling[t] || 0) + 1;
|
||||
}
|
||||
}
|
||||
return Object.keys(telling)
|
||||
.sort((a, b) => telling[b] - telling[a] || a.localeCompare(b))
|
||||
.slice(0, 14);
|
||||
},
|
||||
|
||||
get filteredStacks() { const q = this.stackQuery.toLowerCase(); return q ? this.stacks.filter(s => (s.name||'').toLowerCase().includes(q)) : this.stacks; },
|
||||
async stackAction(name, action) {
|
||||
const d = await this.rpc(`/api/stacks/${encodeURIComponent(name)}/${action}`, {});
|
||||
|
|
|
|||
|
|
@ -211,5 +211,14 @@
|
|||
"backup_before_update": "Automatically back up before updating a stack",
|
||||
"backup_before_remove": "Automatically back up before removing a stack",
|
||||
"check_updates": "Check for updates",
|
||||
"stack_update_check": "Check daily whether newer images are available"
|
||||
"stack_update_check": "Check daily whether newer images are available",
|
||||
"role_admin": "Administrator",
|
||||
"role_operator": "Operator",
|
||||
"role_viewer": "Read-only",
|
||||
"roles_hint": "An administrator may do everything. An operator manages stacks, containers and backups, but not settings, users, repositories or networks. Read-only cannot change anything.",
|
||||
"proxy_role": "Role for proxy users",
|
||||
"proxy_role_hint": "Anyone signing in through the reverse proxy without their own account gets this role.",
|
||||
"no_permission": "Your role does not allow this action",
|
||||
"search_apps": "Search apps…",
|
||||
"all": "All"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -211,5 +211,14 @@
|
|||
"backup_before_update": "Automatisch een backup vóór het bijwerken van een stack",
|
||||
"backup_before_remove": "Automatisch een backup vóór het verwijderen van een stack",
|
||||
"check_updates": "Updates controleren",
|
||||
"stack_update_check": "Dagelijks controleren of er nieuwere images zijn"
|
||||
"stack_update_check": "Dagelijks controleren of er nieuwere images zijn",
|
||||
"role_admin": "Beheerder",
|
||||
"role_operator": "Operator",
|
||||
"role_viewer": "Alleen lezen",
|
||||
"roles_hint": "Beheerder mag alles. Operator beheert stacks, containers en backups, maar niet de instellingen, gebruikers, repo's of netwerken. Alleen lezen kan niets wijzigen.",
|
||||
"proxy_role": "Rol voor proxy-gebruikers",
|
||||
"proxy_role_hint": "Iedereen die via de reverse proxy binnenkomt en geen eigen account heeft, krijgt deze rol.",
|
||||
"no_permission": "Je rol geeft geen toegang tot deze actie",
|
||||
"search_apps": "Zoek een app…",
|
||||
"all": "Alle"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,12 +56,24 @@ def test_ui_gebruikt_alleen_bestaande_sleutels():
|
|||
regels = TEMPLATE.read_text(encoding="utf-8").splitlines()
|
||||
# Commentaarregels overslaan: die noemen t('key') als voorbeeld.
|
||||
html = "\n".join(r for r in regels if not r.lstrip().startswith(("//", "*", "/*")))
|
||||
gebruikt = set(re.findall(r"\bt\('([a-z0-9_]+)'", html))
|
||||
# Alleen letterlijke sleutels: `t('foo')` en `t('foo', {...})`. Dynamisch
|
||||
# samengestelde sleutels zoals t('role_'+r) vallen hierbuiten en worden
|
||||
# afzonderlijk gecontroleerd in test_dynamische_sleutels_bestaan.
|
||||
gebruikt = set(re.findall(r"\bt\('([a-z0-9_]+)'\s*[,)]", html))
|
||||
bekend = set(_laad("nl"))
|
||||
onbekend = sorted(gebruikt - bekend)
|
||||
assert not onbekend, f"t() verwijst naar niet-bestaande sleutels: {onbekend}"
|
||||
|
||||
|
||||
def test_dynamische_sleutels_bestaan():
|
||||
"""Sleutels die de UI opbouwt uit een voorvoegsel plus een waarde."""
|
||||
nl = _laad("nl")
|
||||
for rol in ("admin", "operator", "viewer"):
|
||||
assert f"role_{rol}" in nl
|
||||
for plan in ("off", "daily", "weekly"):
|
||||
assert f"backup_schedule_{plan}" in nl
|
||||
|
||||
|
||||
def test_nieuwe_schermen_zijn_vertaald():
|
||||
"""Steekproef op de schermen die in v0.5.x zijn toegevoegd."""
|
||||
nl = _laad("nl")
|
||||
|
|
|
|||
173
tests/test_roles.py
Normal file
173
tests/test_roles.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
"""Rollen: admin mag alles, operator beheert stacks, viewer leest alleen."""
|
||||
import pytest
|
||||
|
||||
from conftest import login
|
||||
|
||||
|
||||
def _maak(client, csrf, naam, rol, wachtwoord="eenlangwachtwoord"):
|
||||
r = client.post("/api/auth/users",
|
||||
json={"username": naam, "password": wachtwoord, "role": rol},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
assert r.status_code == 200, r.get_json()
|
||||
|
||||
|
||||
def _login_als(client, naam, wachtwoord="eenlangwachtwoord") -> str:
|
||||
r = client.post("/api/auth/login", json={"username": naam, "password": wachtwoord})
|
||||
assert r.status_code == 200, r.get_json()
|
||||
return r.get_json()["csrf_token"]
|
||||
|
||||
|
||||
def test_eerste_account_is_beheerder(client):
|
||||
login(client)
|
||||
assert client.get("/api/auth/me").get_json()["role"] == "admin"
|
||||
|
||||
|
||||
def test_bestaand_account_zonder_rol_geldt_als_beheerder(client, env):
|
||||
"""Accounts van vóór deze versie hebben geen `role`-veld."""
|
||||
login(client)
|
||||
import core.auth as auth
|
||||
a = auth.settings()
|
||||
a["users"]["tester"].pop("role", None)
|
||||
env["core"].patch({"AUTH": a})
|
||||
assert auth.role_of("tester") == "admin"
|
||||
|
||||
|
||||
def test_viewer_mag_lezen_maar_niets_wijzigen(client):
|
||||
csrf = login(client)
|
||||
_maak(client, csrf, "kijker", "viewer")
|
||||
kijker_csrf = _login_als(client, "kijker")
|
||||
|
||||
assert client.get("/api/stacks").status_code == 200
|
||||
assert client.get("/api/auth/me").get_json()["role"] == "viewer"
|
||||
|
||||
r = client.post("/api/stacks/web/start", headers={"X-CSRF-Token": kijker_csrf})
|
||||
assert r.status_code == 403
|
||||
assert "rol" in r.get_json()["msg"]
|
||||
|
||||
|
||||
def test_operator_mag_stacks_maar_geen_instellingen(client):
|
||||
csrf = login(client)
|
||||
_maak(client, csrf, "beheerder2", "operator")
|
||||
op_csrf = _login_als(client, "beheerder2")
|
||||
|
||||
# Stackacties mogen: 404 betekent 'route bereikt, stack bestaat niet'.
|
||||
r = client.post("/api/stacks/web/start", headers={"X-CSRF-Token": op_csrf})
|
||||
assert r.status_code != 403
|
||||
|
||||
# Instellingen niet.
|
||||
for pad, methode in (("/api/settings", "put"), ("/api/networks", "post"),
|
||||
("/api/auth/users", "post"), ("/api/repos", "post"),
|
||||
("/api/docker/restart", "post")):
|
||||
r = getattr(client, methode)(pad, json={}, headers={"X-CSRF-Token": op_csrf})
|
||||
assert r.status_code == 403, f"{methode.upper()} {pad} → {r.status_code}"
|
||||
|
||||
|
||||
def test_operator_mag_backups_maken_en_terugzetten(client):
|
||||
csrf = login(client)
|
||||
_maak(client, csrf, "op", "operator")
|
||||
op_csrf = _login_als(client, "op")
|
||||
r = client.post("/api/backups/web/create", headers={"X-CSRF-Token": op_csrf})
|
||||
assert r.status_code != 403
|
||||
|
||||
|
||||
def test_iedereen_mag_eigen_wachtwoord_wijzigen(client):
|
||||
csrf = login(client)
|
||||
_maak(client, csrf, "kijker", "viewer")
|
||||
kijker_csrf = _login_als(client, "kijker")
|
||||
r = client.post("/api/auth/password",
|
||||
json={"current": "eenlangwachtwoord", "new": "nogeenlangwachtwoord"},
|
||||
headers={"X-CSRF-Token": kijker_csrf})
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_wachtwoord_wijzigen_behoudt_de_rol(client):
|
||||
"""Rechtenescalatie: het hele gebruikersrecord werd vervangen bij een
|
||||
wachtwoordwijziging, waardoor de rol wegviel. Omdat een ontbrekende rol als
|
||||
admin geldt (voor oude accounts), promoveerde een viewer zichzelf zo tot
|
||||
beheerder."""
|
||||
csrf = login(client)
|
||||
_maak(client, csrf, "kijker", "viewer")
|
||||
kijker_csrf = _login_als(client, "kijker")
|
||||
|
||||
r = client.post("/api/auth/password",
|
||||
json={"current": "eenlangwachtwoord", "new": "nogeenlangwachtwoord"},
|
||||
headers={"X-CSRF-Token": kijker_csrf})
|
||||
assert r.status_code == 200
|
||||
|
||||
import core.auth as auth
|
||||
assert auth.role_of("kijker") == "viewer"
|
||||
assert client.get("/api/auth/me").get_json()["role"] == "viewer"
|
||||
# En nog steeds geen toegang tot beheeracties.
|
||||
assert client.put("/api/settings", json={},
|
||||
headers={"X-CSRF-Token": kijker_csrf}).status_code == 403
|
||||
|
||||
|
||||
def test_viewer_mag_uitloggen(client):
|
||||
csrf = login(client)
|
||||
_maak(client, csrf, "kijker", "viewer")
|
||||
kijker_csrf = _login_als(client, "kijker")
|
||||
assert client.post("/api/auth/logout", json={},
|
||||
headers={"X-CSRF-Token": kijker_csrf}).status_code == 200
|
||||
|
||||
|
||||
def test_laatste_beheerder_kan_niet_gedegradeerd_worden(client):
|
||||
csrf = login(client)
|
||||
_maak(client, csrf, "op", "operator")
|
||||
r = client.put("/api/auth/users/tester/role", json={"role": "viewer"},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
assert r.status_code == 400
|
||||
assert "laatste beheerder" in r.get_json()["msg"]
|
||||
|
||||
|
||||
def test_laatste_beheerder_kan_niet_verwijderd_worden(client):
|
||||
csrf = login(client)
|
||||
_maak(client, csrf, "op", "operator")
|
||||
r = client.delete("/api/auth/users/tester", headers={"X-CSRF-Token": csrf})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_degraderen_mag_met_een_tweede_beheerder(client):
|
||||
csrf = login(client)
|
||||
_maak(client, csrf, "admin2", "admin")
|
||||
r = client.put("/api/auth/users/tester/role", json={"role": "operator"},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
assert r.status_code == 200
|
||||
import core.auth as auth
|
||||
assert auth.role_of("tester") == "operator"
|
||||
|
||||
|
||||
def test_onbekende_rol_wordt_geweigerd(client):
|
||||
csrf = login(client)
|
||||
r = client.post("/api/auth/users",
|
||||
json={"username": "x", "password": "eenlangwachtwoord",
|
||||
"role": "superuser"},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_proxy_gebruiker_krijgt_de_ingestelde_rol(client, env):
|
||||
csrf = login(client)
|
||||
client.put("/api/auth/mode",
|
||||
json={"mode": "both", "proxy_header": "Remote-User",
|
||||
"trusted_proxies": ["10.9.9.9"], "proxy_role": "viewer"},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
client.post("/api/auth/logout", json={}, headers={"X-CSRF-Token": csrf})
|
||||
|
||||
r = client.get("/api/auth/me", headers={"Remote-User": "collega"},
|
||||
environ_overrides={"REMOTE_ADDR": "10.9.9.9"})
|
||||
assert r.get_json()["role"] == "viewer"
|
||||
|
||||
|
||||
def test_lokaal_account_wint_van_proxy_rol(client, env):
|
||||
"""Komt de proxy-naam overeen met een bestaand account, dan geldt díé rol."""
|
||||
csrf = login(client)
|
||||
_maak(client, csrf, "collega", "operator")
|
||||
client.put("/api/auth/mode",
|
||||
json={"mode": "both", "proxy_header": "Remote-User",
|
||||
"trusted_proxies": ["10.9.9.9"], "proxy_role": "viewer"},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
client.post("/api/auth/logout", json={}, headers={"X-CSRF-Token": csrf})
|
||||
|
||||
r = client.get("/api/auth/me", headers={"Remote-User": "collega"},
|
||||
environ_overrides={"REMOTE_ADDR": "10.9.9.9"})
|
||||
assert r.get_json()["role"] == "operator"
|
||||
146
tests/test_scheduler.py
Normal file
146
tests/test_scheduler.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"""Scheduler: alleen afstempelen als een taak echt gedraaid heeft.
|
||||
|
||||
Dat onderscheid is niet cosmetisch. Een taak die zijn eigen tijdvenster bewaakt
|
||||
(zoals de geplande backup om 03:00) wordt bij elke tick even aangeroepen. Zou de
|
||||
scheduler ook een overgeslagen taak afstempelen, dan verschuift het volgende
|
||||
moment naar precies dat verkeerde tijdstip — en draait de backup nooit.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sch(env):
|
||||
from core import scheduler
|
||||
scheduler._TAKEN.clear()
|
||||
return scheduler
|
||||
|
||||
|
||||
def test_overgeslagen_taak_wordt_niet_afgestempeld(sch, env):
|
||||
aanroepen = {"n": 0}
|
||||
|
||||
def _slaat_over():
|
||||
aanroepen["n"] += 1
|
||||
return False
|
||||
|
||||
sch.register("nooit", _slaat_over, interval_uren=24)
|
||||
assert sch.run_due() == []
|
||||
assert sch.run_due() == []
|
||||
# Elke tick opnieuw geprobeerd, want er is niets afgestempeld.
|
||||
assert aanroepen["n"] == 2
|
||||
assert "nooit" not in env["core"].load().get("SCHEDULER_LAST", {})
|
||||
|
||||
|
||||
def test_uitgevoerde_taak_wordt_wel_afgestempeld(sch, env):
|
||||
aanroepen = {"n": 0}
|
||||
|
||||
def _doet_iets():
|
||||
aanroepen["n"] += 1
|
||||
return True
|
||||
|
||||
sch.register("wel", _doet_iets, interval_uren=24)
|
||||
assert sch.run_due() == ["wel"]
|
||||
assert sch.run_due() == [] # interval nog niet verstreken
|
||||
assert aanroepen["n"] == 1
|
||||
assert "wel" in env["core"].load()["SCHEDULER_LAST"]
|
||||
|
||||
|
||||
def test_taak_draait_opnieuw_na_het_interval(sch, env):
|
||||
import time
|
||||
sch.register("kort", lambda: True, interval_uren=1)
|
||||
assert sch.run_due() == ["kort"]
|
||||
assert sch.run_due() == []
|
||||
# Tijdstempel terugzetten in plaats van een uur wachten.
|
||||
env["core"].patch({"SCHEDULER_LAST": {"kort": time.time() - 3700}})
|
||||
assert sch.run_due() == ["kort"]
|
||||
|
||||
|
||||
def test_falende_taak_wordt_afgestempeld(sch, env):
|
||||
"""Anders probeert een structureel kapotte taak het elke tick opnieuw."""
|
||||
aanroepen = {"n": 0}
|
||||
|
||||
def _ontploft():
|
||||
aanroepen["n"] += 1
|
||||
raise RuntimeError("stuk")
|
||||
|
||||
sch.register("stuk", _ontploft, interval_uren=24)
|
||||
assert sch.run_due() == []
|
||||
assert sch.run_due() == []
|
||||
assert aanroepen["n"] == 1
|
||||
assert "stuk" in env["core"].load()["SCHEDULER_LAST"]
|
||||
|
||||
|
||||
def test_enabled_key_schakelt_taak_uit(sch, env):
|
||||
env["core"].patch({"MIJN_VLAG": False})
|
||||
sch.register("uit", lambda: True, interval_uren=0.001, enabled_key="MIJN_VLAG")
|
||||
assert sch.run_due() == []
|
||||
env["core"].patch({"MIJN_VLAG": True})
|
||||
assert sch.run_due() == ["uit"]
|
||||
|
||||
|
||||
def test_een_kapotte_taak_blokkeert_de_rest_niet(sch, env):
|
||||
gedaan = []
|
||||
sch.register("a", lambda: (_ for _ in ()).throw(RuntimeError("stuk")))
|
||||
sch.register("b", lambda: (gedaan.append("b"), True)[1])
|
||||
sch.run_due()
|
||||
assert gedaan == ["b"]
|
||||
|
||||
|
||||
# ── Tijdvenster ──────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("nu,uur,verwacht", [
|
||||
(datetime(2026, 7, 26, 3, 0), 3, True),
|
||||
(datetime(2026, 7, 26, 3, 29), 3, True),
|
||||
(datetime(2026, 7, 26, 3, 30), 3, False),
|
||||
(datetime(2026, 7, 26, 2, 59), 3, False),
|
||||
(datetime(2026, 7, 26, 14, 0), 3, False),
|
||||
])
|
||||
def test_binnen_venster(sch, nu, uur, verwacht):
|
||||
assert sch.binnen_venster(uur, 30, nu=nu) is verwacht
|
||||
|
||||
|
||||
# ── De backup-taak zelf ──────────────────────────────────────────────────────
|
||||
|
||||
def test_geplande_backup_slaat_over_buiten_het_venster(env, monkeypatch):
|
||||
import app as app_module
|
||||
from core import scheduler
|
||||
env["core"].patch({"BACKUP_SCHEDULE": "daily", "BACKUP_SCHEDULE_HOUR": 3})
|
||||
monkeypatch.setattr(scheduler, "binnen_venster", lambda *a, **k: False)
|
||||
assert app_module._geplande_backups() is False
|
||||
|
||||
|
||||
def test_geplande_backup_slaat_over_als_planning_uit_staat(env):
|
||||
import app as app_module
|
||||
env["core"].patch({"BACKUP_SCHEDULE": "off"})
|
||||
assert app_module._geplande_backups() is False
|
||||
|
||||
|
||||
def test_geplande_backup_draait_binnen_het_venster(env, monkeypatch):
|
||||
import app as app_module
|
||||
from core import scheduler, backups
|
||||
env["core"].patch({"BACKUP_SCHEDULE": "daily", "BACKUP_SCHEDULE_HOUR": 3,
|
||||
"BACKUP_DIR": str(env["tmp"] / "backups")})
|
||||
d = env["lib"] / "web"
|
||||
d.mkdir(parents=True)
|
||||
(d / "docker-compose.yml").write_text("services:\n a:\n image: nginx\n")
|
||||
monkeypatch.setattr(scheduler, "binnen_venster", lambda *a, **k: True)
|
||||
|
||||
assert app_module._geplande_backups() is True
|
||||
assert len(backups.list_for("web")) == 1
|
||||
assert backups.list_for("web")[0]["reason"] == "gepland-daily"
|
||||
|
||||
|
||||
def test_wekelijkse_backup_alleen_op_de_ingestelde_dag(env, monkeypatch):
|
||||
import app as app_module
|
||||
from core import scheduler
|
||||
env["core"].patch({"BACKUP_SCHEDULE": "weekly", "BACKUP_SCHEDULE_DAY": 0})
|
||||
monkeypatch.setattr(scheduler, "binnen_venster", lambda *a, **k: True)
|
||||
|
||||
class _Dinsdag(datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return datetime(2026, 7, 28) # dinsdag → weekday() == 1
|
||||
|
||||
monkeypatch.setattr(app_module, "datetime", _Dinsdag)
|
||||
assert app_module._geplande_backups() is False
|
||||
Loading…
Reference in a new issue