phase 6
This commit is contained in:
parent
543f135ca0
commit
17d42c52a7
18 changed files with 420 additions and 11 deletions
|
|
@ -1,3 +1,4 @@
|
|||
STEADY_ENV=development
|
||||
SECRET_KEY=replace-with-a-long-random-value
|
||||
# DATABASE_URL may override the default absolute instance/steady.db location.
|
||||
FEATURE_ADMIN=false
|
||||
|
|
|
|||
11
README.md
11
README.md
|
|
@ -62,3 +62,14 @@ color mode; enable a dyslexia-friendly local reading style; turn completion
|
|||
sound on or off; and save a future reminder-frequency preference. The browser
|
||||
reminder control checks capability only. It does not request notification
|
||||
permission, register a service worker, or schedule reminders.
|
||||
|
||||
## Roles and admin placeholder
|
||||
|
||||
Steady recognizes `user`, `admin`, `viewer`, and `coach` roles. Viewers can read
|
||||
and export their own tasks but cannot mutate task data; the other three roles
|
||||
retain writes to their own tasks. Ownership checks apply independently of role.
|
||||
|
||||
The admin feature is absent by default. Setting `FEATURE_ADMIN=true` before the
|
||||
application starts registers `/admin/`, which remains restricted to the `admin`
|
||||
role. It is a data-free placeholder only: user management, role assignment, and
|
||||
analytics are intentionally not implemented.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import os
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask
|
||||
|
||||
|
|
@ -8,7 +10,10 @@ from config import CONFIGS
|
|||
from .extensions import csrf, db, login_manager, migrate
|
||||
|
||||
|
||||
def create_app(config_name: str | None = None) -> Flask:
|
||||
def create_app(
|
||||
config_name: str | None = None,
|
||||
config_overrides: Mapping[str, Any] | None = None,
|
||||
) -> Flask:
|
||||
"""Create and configure an isolated Steady application instance."""
|
||||
|
||||
app = Flask(__name__, instance_relative_config=True)
|
||||
|
|
@ -22,6 +27,9 @@ def create_app(config_name: str | None = None) -> Flask:
|
|||
f"Unknown configuration {selected_config!r}; choose {valid_names}."
|
||||
) from exc
|
||||
|
||||
if config_overrides:
|
||||
app.config.from_mapping(config_overrides)
|
||||
|
||||
Path(app.instance_path).mkdir(parents=True, exist_ok=True)
|
||||
_validate_config(app)
|
||||
_initialize_extensions(app)
|
||||
|
|
@ -56,3 +64,8 @@ def _register_blueprints(app: Flask) -> None:
|
|||
app.register_blueprint(auth_blueprint, url_prefix="/auth")
|
||||
app.register_blueprint(tasks_blueprint, url_prefix="/tasks")
|
||||
app.register_blueprint(settings_blueprint, url_prefix="/settings")
|
||||
|
||||
if app.config["FEATURE_ADMIN"]:
|
||||
from .admin import bp as admin_blueprint
|
||||
|
||||
app.register_blueprint(admin_blueprint, url_prefix="/admin")
|
||||
|
|
|
|||
|
|
@ -1,2 +1,6 @@
|
|||
"""Reserved package for the optional admin feature."""
|
||||
from flask import Blueprint
|
||||
|
||||
|
||||
bp = Blueprint("admin", __name__)
|
||||
|
||||
from . import routes # noqa: E402, F401
|
||||
|
|
|
|||
14
app/admin/routes.py
Normal file
14
app/admin/routes.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from flask import render_template
|
||||
from flask_login import login_required
|
||||
|
||||
from app.core.authorization import roles_required
|
||||
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@login_required
|
||||
@roles_required("admin")
|
||||
def index():
|
||||
return render_template("admin/index.html")
|
||||
|
||||
|
|
@ -1,14 +1,24 @@
|
|||
from datetime import datetime, timezone
|
||||
|
||||
from flask_login import UserMixin
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy import CheckConstraint, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
from app.extensions import db, login_manager
|
||||
|
||||
|
||||
USER_ROLES = ("user", "admin", "viewer", "coach")
|
||||
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"role IN ('user', 'admin', 'viewer', 'coach')",
|
||||
name="ck_user_role",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(80), unique=True, index=True)
|
||||
email: Mapped[str] = mapped_column(String(254), unique=True, index=True)
|
||||
|
|
@ -36,4 +46,3 @@ def load_user(user_id: str) -> User | None:
|
|||
if not user_id.isdigit():
|
||||
return None
|
||||
return db.session.get(User, int(user_id))
|
||||
|
||||
|
|
|
|||
24
app/core/authorization.py
Normal file
24
app/core/authorization.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import Any
|
||||
|
||||
from flask import abort
|
||||
from flask_login import current_user
|
||||
|
||||
|
||||
TASK_EDITOR_ROLES = ("user", "admin", "coach")
|
||||
|
||||
|
||||
def roles_required(*allowed_roles: str):
|
||||
"""Require an authenticated user's role to match an explicit allow-list."""
|
||||
|
||||
def decorator(view: Callable[..., Any]) -> Callable[..., Any]:
|
||||
@wraps(view)
|
||||
def wrapped(*args: Any, **kwargs: Any):
|
||||
if current_user.role not in allowed_roles:
|
||||
abort(403)
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
return decorator
|
||||
|
|
@ -12,3 +12,7 @@ def index():
|
|||
def health():
|
||||
return jsonify(status="ok")
|
||||
|
||||
|
||||
@bp.app_errorhandler(403)
|
||||
def forbidden(_error):
|
||||
return render_template("core/403.html"), 403
|
||||
|
|
|
|||
|
|
@ -663,6 +663,21 @@ input:focus-visible {
|
|||
background: color-mix(in srgb, var(--lavender) 18%, var(--surface));
|
||||
}
|
||||
|
||||
.admin-placeholder-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.admin-boundary {
|
||||
max-width: 48rem;
|
||||
margin-top: 1.5rem;
|
||||
padding: var(--space);
|
||||
border-left: 0.35rem solid var(--lavender);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
@keyframes gentle-arrival {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
|
@ -759,6 +774,10 @@ input {
|
|||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-placeholder-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.undo-banner {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ from flask import (
|
|||
)
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from app.core.authorization import TASK_EDITOR_ROLES, roles_required
|
||||
|
||||
from . import bp
|
||||
from .forms import (
|
||||
ActionForm,
|
||||
|
|
@ -122,6 +124,7 @@ def focus_task(task_id: int):
|
|||
|
||||
@bp.route("/new", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@roles_required(*TASK_EDITOR_ROLES)
|
||||
def create():
|
||||
form = TaskForm()
|
||||
if form.validate_on_submit():
|
||||
|
|
@ -152,6 +155,7 @@ def detail(task_id: int):
|
|||
|
||||
@bp.route("/<int:task_id>/edit", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@roles_required(*TASK_EDITOR_ROLES)
|
||||
def edit(task_id: int):
|
||||
task = _owned_task_or_404(task_id)
|
||||
form = TaskForm(obj=task)
|
||||
|
|
@ -171,6 +175,7 @@ def edit(task_id: int):
|
|||
|
||||
@bp.post("/<int:task_id>/delete")
|
||||
@login_required
|
||||
@roles_required(*TASK_EDITOR_ROLES)
|
||||
def delete(task_id: int):
|
||||
task = _owned_task_or_404(task_id)
|
||||
form = ActionForm()
|
||||
|
|
@ -183,6 +188,7 @@ def delete(task_id: int):
|
|||
|
||||
@bp.post("/<int:task_id>/subtasks")
|
||||
@login_required
|
||||
@roles_required(*TASK_EDITOR_ROLES)
|
||||
def create_subtask(task_id: int):
|
||||
task = _owned_task_or_404(task_id)
|
||||
form = SubtaskForm()
|
||||
|
|
@ -198,6 +204,7 @@ def create_subtask(task_id: int):
|
|||
|
||||
@bp.post("/<int:task_id>/subtasks/<int:subtask_id>/toggle")
|
||||
@login_required
|
||||
@roles_required(*TASK_EDITOR_ROLES)
|
||||
def toggle_subtask_status(task_id: int, subtask_id: int):
|
||||
task = _owned_task_or_404(task_id)
|
||||
form = ActionForm()
|
||||
|
|
@ -212,6 +219,7 @@ def toggle_subtask_status(task_id: int, subtask_id: int):
|
|||
|
||||
@bp.post("/<int:task_id>/focus-settings")
|
||||
@login_required
|
||||
@roles_required(*TASK_EDITOR_ROLES)
|
||||
def update_focus_settings(task_id: int):
|
||||
task = _owned_task_or_404(task_id)
|
||||
form = FocusSettingsForm()
|
||||
|
|
@ -232,6 +240,7 @@ def update_focus_settings(task_id: int):
|
|||
|
||||
@bp.post("/<int:task_id>/chunks/<int:chunk_index>/toggle")
|
||||
@login_required
|
||||
@roles_required(*TASK_EDITOR_ROLES)
|
||||
def toggle_focus_chunk(task_id: int, chunk_index: int):
|
||||
task = _owned_task_or_404(task_id)
|
||||
form = ActionForm()
|
||||
|
|
@ -246,6 +255,7 @@ def toggle_focus_chunk(task_id: int, chunk_index: int):
|
|||
|
||||
@bp.post("/<int:task_id>/complete")
|
||||
@login_required
|
||||
@roles_required(*TASK_EDITOR_ROLES)
|
||||
def complete(task_id: int):
|
||||
task = _owned_task_or_404(task_id)
|
||||
form = ActionForm()
|
||||
|
|
@ -278,6 +288,7 @@ def transfer():
|
|||
|
||||
@bp.post("/import/markdown")
|
||||
@login_required
|
||||
@roles_required(*TASK_EDITOR_ROLES)
|
||||
def import_markdown_file():
|
||||
form = MarkdownImportForm()
|
||||
if not form.validate_on_submit():
|
||||
|
|
@ -307,6 +318,7 @@ def export_json():
|
|||
|
||||
@bp.post("/import/json")
|
||||
@login_required
|
||||
@roles_required(*TASK_EDITOR_ROLES)
|
||||
def import_json():
|
||||
form = BackupImportForm()
|
||||
if not form.validate_on_submit():
|
||||
|
|
@ -328,6 +340,7 @@ def import_json():
|
|||
|
||||
@bp.post("/completed/clear")
|
||||
@login_required
|
||||
@roles_required(*TASK_EDITOR_ROLES)
|
||||
def clear_completed():
|
||||
form = ActionForm()
|
||||
if not form.validate_on_submit():
|
||||
|
|
@ -342,6 +355,7 @@ def clear_completed():
|
|||
|
||||
@bp.post("/completed/undo/<uuid:batch_id>")
|
||||
@login_required
|
||||
@roles_required(*TASK_EDITOR_ROLES)
|
||||
def undo_completed_clear(batch_id):
|
||||
form = ActionForm()
|
||||
if not form.validate_on_submit():
|
||||
|
|
|
|||
36
app/templates/admin/index.html
Normal file
36
app/templates/admin/index.html
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Administration · Steady{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Restricted placeholder</p>
|
||||
<h1>Administration</h1>
|
||||
<p>The admin boundary is active. Operational tools remain intentionally unavailable.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-placeholder-grid">
|
||||
<section class="transfer-card">
|
||||
<h2>User management</h2>
|
||||
<p>Future scope: account review, role assignment, suspension, and audited administrative actions.</p>
|
||||
<span class="status">Not implemented</span>
|
||||
</section>
|
||||
<section class="transfer-card">
|
||||
<h2>Analytics</h2>
|
||||
<p>Future scope: privacy-preserving aggregate usage and operational health metrics.</p>
|
||||
<span class="status">Not implemented</span>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="admin-boundary">
|
||||
<h2>Current security boundary</h2>
|
||||
<ul>
|
||||
<li>The feature is absent unless <code>FEATURE_ADMIN</code> is enabled.</li>
|
||||
<li>Only authenticated users with the <code>admin</code> role receive access.</li>
|
||||
<li>No user records or analytics are queried by this placeholder.</li>
|
||||
</ul>
|
||||
</aside>
|
||||
{% endblock %}
|
||||
|
||||
|
|
@ -22,6 +22,9 @@
|
|||
<a href="{{ url_for('tasks.index') }}">Tasks</a>
|
||||
<a href="{{ url_for('tasks.transfer') }}">Data</a>
|
||||
<a href="{{ url_for('settings.index') }}">Settings</a>
|
||||
{% if config.FEATURE_ADMIN and current_user.role == 'admin' %}
|
||||
<a href="{{ url_for('admin.index') }}">Admin</a>
|
||||
{% endif %}
|
||||
<span>Hi, {{ current_user.username }}</span>
|
||||
<form action="{{ url_for('auth.logout') }}" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
|
|
|||
13
app/templates/core/403.html
Normal file
13
app/templates/core/403.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Access unavailable · Steady{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="empty-state">
|
||||
<p class="eyebrow">Access unavailable</p>
|
||||
<h1>This area is not available for your role</h1>
|
||||
<p>Your account and tasks are unchanged.</p>
|
||||
<a class="button" href="{{ url_for('core.index') }}">Return home</a>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
|
|
@ -5,6 +5,13 @@ from pathlib import Path
|
|||
BASE_DIR = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def environment_flag(name: str, *, default: bool = False) -> bool:
|
||||
value = os.environ.get(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
class Config:
|
||||
"""Shared settings with secure production-oriented defaults."""
|
||||
|
||||
|
|
@ -23,7 +30,7 @@ class Config:
|
|||
WTF_CSRF_TIME_LIMIT = 3600
|
||||
MAX_CONTENT_LENGTH = 2 * 1024 * 1024
|
||||
|
||||
FEATURE_ADMIN = False
|
||||
FEATURE_ADMIN = environment_flag("FEATURE_ADMIN")
|
||||
FEATURE_API_V1 = False
|
||||
FEATURE_BROWSER_REMINDERS = False
|
||||
|
||||
|
|
|
|||
|
|
@ -252,10 +252,10 @@ a,
|
|||
- [x] Browser Reminders (Web Push API Placeholder) [2]
|
||||
|
||||
### Phase 6: Admin Dashboard (Placeholder - Future) [3]
|
||||
- [ ] Admin Route Placeholder [3]
|
||||
- [x] Admin Route Placeholder [3]
|
||||
- [ ] User Management UI (Future) [3]
|
||||
- [ ] Analytics Dashboard (Future) [3]
|
||||
- [ ] Role-based Access Control (Admin, User, Viewer, Coach) [3]
|
||||
- [x] Role-based Access Control (Admin, User, Viewer, Coach) [3]
|
||||
|
||||
### Phase 7: Polish & Testing (Week 9-10)
|
||||
- [ ] Responsive Design Testing [2]
|
||||
|
|
|
|||
35
migrations/versions/c79217aea3e8_constrain_user_roles.py
Normal file
35
migrations/versions/c79217aea3e8_constrain_user_roles.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""Constrain user roles
|
||||
|
||||
Revision ID: c79217aea3e8
|
||||
Revises: 5ad737bbca5e
|
||||
Create Date: 2026-08-08 04:08:30.449389
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'c79217aea3e8'
|
||||
down_revision = '5ad737bbca5e'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.create_check_constraint(
|
||||
'ck_user_role',
|
||||
"role IN ('user', 'admin', 'viewer', 'coach')",
|
||||
)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.drop_constraint('ck_user_role', type_='check')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
61
progress.md
61
progress.md
|
|
@ -4,10 +4,10 @@ This is the living implementation log for Steady. Update it after every meaningf
|
|||
|
||||
## Current Status
|
||||
|
||||
- Current phase: Phase 5 — Settings & Personalization
|
||||
- Status: Complete
|
||||
- Current phase: Phase 6 — Admin Dashboard Placeholder
|
||||
- Status: Complete — placeholder scope
|
||||
- Last updated: 2026-08-08
|
||||
- Next phase: Phase 6 — Admin Dashboard Placeholder
|
||||
- Next phase: Phase 7 — Polish & Testing
|
||||
|
||||
## Phase 1 — Core Infrastructure
|
||||
|
||||
|
|
@ -295,6 +295,59 @@ No broken Python requirements were found.
|
|||
Line-length, whitespace, and git diff checks completed successfully.
|
||||
```
|
||||
|
||||
## Phase 6 — Admin Dashboard Placeholder
|
||||
|
||||
### Phase 6.1 — Role and feature boundary
|
||||
|
||||
Added a database role constraint for `user`, `admin`, `viewer`, and `coach`; a reusable allow-list authorization decorator; a disabled-by-default `FEATURE_ADMIN` environment flag; and safe application-factory overrides for isolated feature testing. The admin Blueprint is imported and registered only when enabled.
|
||||
|
||||
Added an admin-only placeholder dashboard and a calm `403` page. The dashboard explicitly labels user management and analytics as unimplemented and queries no user or analytics data. Admin navigation appears only when both the feature flag and role allow it.
|
||||
|
||||
Why: a disabled route is safer than a visible placeholder protected only by convention. Role checks use an explicit allow-list, the database rejects unknown roles, and conditional imports keep the future feature absent from the route map when disabled. Factory overrides allow testing without changing deployment defaults.
|
||||
|
||||
Next: enforce the viewer role as read-only across every task mutation, migrate the role constraint, and test all role boundaries.
|
||||
|
||||
### Phase 6.2 — Viewer read-only enforcement
|
||||
|
||||
Applied the shared task-editor allow-list (`user`, `admin`, `coach`) to every task-changing route: create, edit, delete, completion, subtasks, focus configuration, chunk toggles, both imports, clear, and undo. Viewer accounts retain authenticated access to lists, details, Focus Mode, export, and personal settings but receive `403` before mutation logic runs.
|
||||
|
||||
Why: defining a viewer role without enforcing read-only behavior would create a misleading security promise. Centralizing the allow-list prevents individual routes from inventing different role semantics, while ownership checks continue to restrict editors to their own tasks.
|
||||
|
||||
Next: generate the role-constraint migration and test disabled, anonymous, non-admin, admin, viewer, and coach behavior.
|
||||
|
||||
The `Constrain user roles` migration was generated, reviewed, applied, and followed by a green 45-test regression run.
|
||||
|
||||
### Phase 6.3 — Authorization coverage
|
||||
|
||||
Added tests for default route absence, anonymous login redirection, `403` responses for user/viewer/coach roles, admin placeholder access, database rejection of unknown roles, viewer read access, all twelve viewer mutation denials, and retained coach write access to owned tasks.
|
||||
|
||||
Why: checking only the admin landing page would leave lateral privilege gaps in task operations. Enumerating every mutation proves the viewer contract across uploads, recovery, focus actions, subtasks, and CRUD. The database test verifies defense in depth beneath route authorization.
|
||||
|
||||
Next: run the focused role suite, then the complete regression and architecture checks.
|
||||
|
||||
### Phase 6.4 — Final verification and scope boundary
|
||||
|
||||
Completed the focused authorization suite and full regression, Alembic model comparison, default and enabled route discovery, Python compilation, JavaScript syntax validation, dependency consistency, line-length and whitespace scans, and patch-format validation.
|
||||
|
||||
User-management and analytics operations remain deliberately unimplemented. The design labels both as future work, and adding role assignment, account suspension, or analytics collection would require explicit product requirements, audit logging, privacy rules, and recovery policy. Phase 6 therefore completes the safe placeholder and authorization boundary without creating speculative administrative power.
|
||||
|
||||
Final results:
|
||||
|
||||
```text
|
||||
54 tests passed in 5.49s
|
||||
All 9 focused authorization tests passed.
|
||||
The default route map contains no admin endpoint.
|
||||
The enabled route map contains the guarded /admin/ placeholder.
|
||||
Alembic detected no pending model operations.
|
||||
Python and JavaScript syntax validation completed successfully.
|
||||
No broken Python requirements were found.
|
||||
Line-length, whitespace, and git diff checks completed successfully.
|
||||
```
|
||||
|
||||
## Next Work
|
||||
|
||||
Phase 6 is intentionally a future-facing admin placeholder. The next decision should be whether to implement only a safely disabled admin Blueprint and role guard as designed, or defer all admin surface area until concrete user-management requirements exist.
|
||||
Phase 7 will focus on evidence-driven polish: responsive behavior, reduced-motion verification, accessibility auditing, performance checks, and deployment/documentation hardening. Future admin operations should remain separate until their requirements are approved.
|
||||
|
||||
## Local Operational Setup
|
||||
|
||||
- 2026-08-08: Promoted the sole local development account from `user` to `admin` and enabled `FEATURE_ADMIN=true` in the ignored local `.env` file. Verified through the Flask CLI route map that `/admin/` is registered. No account identifier or credential is recorded in this log.
|
||||
|
|
|
|||
149
tests/admin/test_admin.py
Normal file
149
tests/admin/test_admin.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app import create_app
|
||||
from app.auth.models import User
|
||||
from app.auth.services import create_user
|
||||
from app.extensions import db as database
|
||||
from app.tasks.services import add_subtask, configure_focus, create_task
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def admin_app():
|
||||
application = create_app("testing", {"FEATURE_ADMIN": True})
|
||||
with application.app_context():
|
||||
database.create_all()
|
||||
yield application
|
||||
database.session.remove()
|
||||
database.drop_all()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def admin_client(admin_app):
|
||||
return admin_app.test_client()
|
||||
|
||||
|
||||
def login(client, email: str) -> None:
|
||||
client.post(
|
||||
"/auth/login",
|
||||
data={"email": email, "password": "secure password"},
|
||||
)
|
||||
|
||||
|
||||
def user_with_role(username: str, role: str) -> User:
|
||||
user = create_user(
|
||||
username,
|
||||
f"{username}@example.com",
|
||||
"secure password",
|
||||
)
|
||||
user.role = role
|
||||
database.session.commit()
|
||||
return user
|
||||
|
||||
|
||||
def test_admin_feature_is_absent_by_default(app, client):
|
||||
endpoints = {rule.endpoint for rule in app.url_map.iter_rules()}
|
||||
|
||||
assert "admin.index" not in endpoints
|
||||
assert client.get("/admin/").status_code == 404
|
||||
|
||||
|
||||
def test_enabled_admin_redirects_anonymous_users(admin_client):
|
||||
response = admin_client.get("/admin/")
|
||||
|
||||
assert response.status_code == 302
|
||||
assert "/auth/login" in response.headers["Location"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["user", "viewer", "coach"])
|
||||
def test_non_admin_roles_receive_calm_forbidden_page(admin_client, role):
|
||||
user = user_with_role(f"non_admin_{role}", role)
|
||||
login(admin_client, user.email)
|
||||
|
||||
response = admin_client.get("/admin/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert b"not available for your role" in response.data
|
||||
|
||||
|
||||
def test_admin_can_open_data_free_placeholder(admin_client):
|
||||
user = user_with_role("admin_user", "admin")
|
||||
login(admin_client, user.email)
|
||||
|
||||
response = admin_client.get("/admin/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b"User management" in response.data
|
||||
assert b"Analytics" in response.data
|
||||
assert response.data.count(b"Not implemented") == 2
|
||||
assert b'href="/admin/"' in response.data
|
||||
|
||||
|
||||
def test_database_rejects_unknown_roles(admin_app):
|
||||
user = User(
|
||||
username="invalid_role",
|
||||
email="invalid-role@example.com",
|
||||
role="superuser",
|
||||
)
|
||||
user.set_password("secure password")
|
||||
database.session.add(user)
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
database.session.commit()
|
||||
database.session.rollback()
|
||||
|
||||
|
||||
def test_viewer_can_read_but_every_task_mutation_is_forbidden(client, db):
|
||||
viewer = user_with_role("readonly_user", "viewer")
|
||||
task = create_task(
|
||||
viewer.id,
|
||||
title="Readable task",
|
||||
description=None,
|
||||
status="not_started",
|
||||
priority="normal",
|
||||
due_date=None,
|
||||
)
|
||||
subtask = add_subtask(task, "Readable step")
|
||||
configure_focus(task, timer_minutes=5, context=None, chunk_minutes=[5])
|
||||
login(client, viewer.email)
|
||||
|
||||
assert client.get("/tasks/").status_code == 200
|
||||
assert client.get(f"/tasks/{task.id}").status_code == 200
|
||||
assert client.get("/tasks/export/json").status_code == 200
|
||||
|
||||
operations = [
|
||||
("get", "/tasks/new"),
|
||||
("get", f"/tasks/{task.id}/edit"),
|
||||
("post", f"/tasks/{task.id}/delete"),
|
||||
("post", f"/tasks/{task.id}/subtasks"),
|
||||
("post", f"/tasks/{task.id}/subtasks/{subtask.id}/toggle"),
|
||||
("post", f"/tasks/{task.id}/focus-settings"),
|
||||
("post", f"/tasks/{task.id}/chunks/0/toggle"),
|
||||
("post", f"/tasks/{task.id}/complete"),
|
||||
("post", "/tasks/import/markdown"),
|
||||
("post", "/tasks/import/json"),
|
||||
("post", "/tasks/completed/clear"),
|
||||
("post", f"/tasks/completed/undo/{uuid4()}"),
|
||||
]
|
||||
for method, path in operations:
|
||||
response = getattr(client, method)(path)
|
||||
assert response.status_code == 403, path
|
||||
|
||||
|
||||
def test_coach_retains_owned_task_write_access(client, db):
|
||||
coach = user_with_role("coach_user", "coach")
|
||||
login(client, coach.email)
|
||||
|
||||
response = client.post(
|
||||
"/tasks/new",
|
||||
data={
|
||||
"title": "Coach-owned task",
|
||||
"status": "not_started",
|
||||
"priority": "normal",
|
||||
"due_date": "",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
Loading…
Add table
Reference in a new issue