— Add built-in authentication for human interface

Built-in user management is provided too
This commit is contained in:
Luc Didry 2024-06-11 17:53:58 +02:00
parent 29d839dc4d
commit 09a858794d
No known key found for this signature in database
GPG key ID: EA868E12D0257E3C
25 changed files with 1032 additions and 152 deletions

View file

@ -11,6 +11,7 @@
- 💥 — Change default config file path to argos-config.yaml (fix #36) - 💥 — Change default config file path to argos-config.yaml (fix #36)
- 📝 — New documentation URL: doc is now on https://argos-monitoring.framasoft.org/ - 📝 — New documentation URL: doc is now on https://argos-monitoring.framasoft.org/
- 💥 — Remove env vars and only use the configuration file - 💥 — Remove env vars and only use the configuration file
- ✨ — Add built-in authentication for human interface
## 0.1.1 ## 0.1.1

View file

@ -64,6 +64,11 @@ def server():
pass pass
@server.group()
def user():
pass
@cli.command() @cli.command()
def version(): def version():
click.echo(VERSION) click.echo(VERSION)
@ -200,7 +205,7 @@ async def cleandb(max_results, max_lock_seconds, config):
) )
@click.option( @click.option(
"--config", "--config",
default="config.yaml", default="argos-config.yaml",
help="Path of the configuration file. " help="Path of the configuration file. "
"If ARGOS_YAML_FILE environment variable is set, its value will be used instead.", "If ARGOS_YAML_FILE environment variable is set, its value will be used instead.",
envvar="ARGOS_YAML_FILE", envvar="ARGOS_YAML_FILE",
@ -281,6 +286,243 @@ async def migrate(config):
command.upgrade(alembic_cfg, "head") command.upgrade(alembic_cfg, "head")
@user.command()
@click.option(
"--config",
default="argos-config.yaml",
help="Path of the configuration file. "
"If ARGOS_YAML_FILE environment variable is set, its value will be used instead.",
envvar="ARGOS_YAML_FILE",
callback=validate_config_access,
)
@click.option("--name", prompt=True, help="Name of the user to create.")
@click.password_option()
@coroutine
async def add(config, name, password):
"""Add new user"""
# Its mandatory to do it before the imports
os.environ["ARGOS_YAML_FILE"] = config
# The imports are made here otherwise the agent will need server configuration files.
from argos.server import queries
from passlib.context import CryptContext
db = await get_db()
_user = await queries.get_user(db, name)
if _user is not None:
click.echo(f"User {name} already exists.")
sysexit(1)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
await queries.add_user(db, name, pwd_context.hash(password))
click.echo(f"User {name} added.")
@user.command()
@click.option(
"--config",
default="argos-config.yaml",
help="Path of the configuration file. "
"If ARGOS_YAML_FILE environment variable is set, its value will be used instead.",
envvar="ARGOS_YAML_FILE",
callback=validate_config_access,
)
@click.option(
"--name", prompt=True, help="Name of the user you want to change the password."
)
@click.password_option()
@coroutine
async def change_password(config, name, password):
"""Change users password"""
# Its mandatory to do it before the imports
os.environ["ARGOS_YAML_FILE"] = config
# The imports are made here otherwise the agent will need server configuration files.
from argos.server import queries
from passlib.context import CryptContext
db = await get_db()
_user = await queries.get_user(db, name)
if _user is None:
click.echo(f"User {name} does not exist.")
sysexit(1)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
_user.password = pwd_context.hash(password)
db.commit()
click.echo(f"Password of user {name} changed.")
@user.command()
@click.option(
"--config",
default="argos-config.yaml",
help="Path of the configuration file. "
"If ARGOS_YAML_FILE environment variable is set, its value will be used instead.",
envvar="ARGOS_YAML_FILE",
callback=validate_config_access,
)
@click.option(
"--name", required=True, help="Name of the user you want to test the password for."
)
@click.option("--password", prompt=True, hide_input=True)
@coroutine
async def verify_password(config, name, password):
"""Test users password"""
# Its mandatory to do it before the imports
os.environ["ARGOS_YAML_FILE"] = config
# The imports are made here otherwise the agent will need server configuration files.
from argos.server import queries
from passlib.context import CryptContext
db = await get_db()
_user = await queries.get_user(db, name)
if _user is None:
click.echo(f"User {name} does not exist.")
sysexit(1)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
if not pwd_context.verify(password, _user.password):
click.echo("Wrong password!")
sysexit(2)
click.echo("The provided password is correct.")
@user.command()
@click.option(
"--config",
default="argos-config.yaml",
help="Path of the configuration file. "
"If ARGOS_YAML_FILE environment variable is set, its value will be used instead.",
envvar="ARGOS_YAML_FILE",
callback=validate_config_access,
)
@click.option("--name", required=True, help="Name of the user to disable.")
@coroutine
async def disable(config, name):
"""Disable user"""
# Its mandatory to do it before the imports
os.environ["ARGOS_YAML_FILE"] = config
# The imports are made here otherwise the agent will need server configuration files.
from argos.server import queries
db = await get_db()
_user = await queries.get_user(db, name)
if _user is None:
click.echo(f"User {name} does not exist.")
sysexit(1)
if _user.disabled:
click.echo(f"User {name} is already disabled.")
sysexit(2)
_user.disabled = True
db.commit()
click.echo(f"User {name} disabled.")
@user.command()
@click.option(
"--config",
default="argos-config.yaml",
help="Path of the configuration file. "
"If ARGOS_YAML_FILE environment variable is set, its value will be used instead.",
envvar="ARGOS_YAML_FILE",
callback=validate_config_access,
)
@click.option("--name", required=True, help="Name of the user to reenable")
@coroutine
async def enable(config, name):
"""Enable user"""
# Its mandatory to do it before the imports
os.environ["ARGOS_YAML_FILE"] = config
# The imports are made here otherwise the agent will need server configuration files.
from argos.server import queries
db = await get_db()
_user = await queries.get_user(db, name)
if _user is None:
click.echo(f"User {name} does not exist.")
sysexit(1)
if not _user.disabled:
click.echo(f"User {name} is already enabled.")
sysexit(2)
_user.disabled = False
db.commit()
click.echo(f"User {name} enabled.")
@user.command()
@click.option(
"--config",
default="argos-config.yaml",
help="Path of the configuration file. "
"If ARGOS_YAML_FILE environment variable is set, its value will be used instead.",
envvar="ARGOS_YAML_FILE",
callback=validate_config_access,
)
@click.option("--name", required=True, help="Name of the user to delete.")
@coroutine
async def delete(config, name):
"""Delete user"""
# Its mandatory to do it before the imports
os.environ["ARGOS_YAML_FILE"] = config
# The imports are made here otherwise the agent will need server configuration files.
from argos.server import queries
db = await get_db()
_user = await queries.get_user(db, name)
if _user is None:
click.echo(f"User {name} does not exist.")
sysexit(1)
db.delete(_user)
db.commit()
click.echo(f"User {name} deleted.")
@user.command()
@click.option(
"--config",
default="argos-config.yaml",
help="Path of the configuration file. "
"If ARGOS_YAML_FILE environment variable is set, its value will be used instead.",
envvar="ARGOS_YAML_FILE",
callback=validate_config_access,
)
@coroutine
async def show(config):
"""List all users"""
# Its mandatory to do it before the imports
os.environ["ARGOS_YAML_FILE"] = config
# The imports are made here otherwise the agent will need server configuration files.
from argos.server import queries
db = await get_db()
users = await queries.list_users(db)
if users.count() == 0:
click.echo("There is no users in database.")
sysexit(1)
click.echo("✅ means that the user is enabled.")
click.echo("❌ means that the user is disabled.")
for _user in users.all():
status = ""
if _user.disabled:
status = ""
click.echo(f"{status} {_user.username}, last login: {_user.last_login_at}")
@server.command(short_help="Generate a token for agents") @server.command(short_help="Generate a token for agents")
@coroutine @coroutine
async def generate_token(): async def generate_token():

View file

@ -10,6 +10,9 @@ general:
# Can be "production", "dev", "test". # Can be "production", "dev", "test".
# If not present, default value is "production" # If not present, default value is "production"
env: "production" env: "production"
# to get a good string for cookie_secret, run:
# openssl rand -hex 32
cookie_secret: "foo_bar_baz"
frequency: "1m" # Run checks every minute. frequency: "1m" # Run checks every minute.
# Which way do you want to be warned when a check goes to that severity? # Which way do you want to be warned when a check goes to that severity?
# "local" emits a message in the server log # "local" emits a message in the server log

View file

@ -1,5 +1,9 @@
import logging import logging
logging.getLogger("passlib").setLevel(logging.ERROR)
LOG_LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] LOG_LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
# Print level before message # Print level before message

View file

@ -153,6 +153,7 @@ class DbSettings(BaseModel):
class General(BaseModel): class General(BaseModel):
"""Frequency for the checks and alerts""" """Frequency for the checks and alerts"""
cookie_secret: str
frequency: int frequency: int
db: DbSettings db: DbSettings
env: Environment = "production" env: Environment = "production"

View file

@ -0,0 +1,13 @@
from fastapi import Request
from fastapi.responses import RedirectResponse
class NotAuthenticatedException(Exception):
pass
def auth_exception_handler(request: Request, exc: NotAuthenticatedException):
"""
Redirect the user to the login page if not logged in
"""
return RedirectResponse(url=request.url_for("login_view"))

View file

@ -1,36 +1,42 @@
import os import os
import sys import sys
from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi_login import LoginManager
from pydantic import ValidationError from pydantic import ValidationError
from sqlalchemy import create_engine, event from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from argos.logging import logger from argos.logging import logger
from argos.server import models, routes, queries from argos.server import models, routes, queries
from argos.server.exceptions import NotAuthenticatedException, auth_exception_handler
from argos.server.settings import read_yaml_config from argos.server.settings import read_yaml_config
def get_application() -> FastAPI: def get_application() -> FastAPI:
"""Spawn Argos FastAPI server""" """Spawn Argos FastAPI server"""
appli = FastAPI() appli = FastAPI(lifespan=lifespan)
config_file = os.environ["ARGOS_YAML_FILE"] config_file = os.environ["ARGOS_YAML_FILE"]
config = read_config(config_file) config = read_config(config_file)
# Config is the argos config object (built from yaml) # Config is the argos config object (built from yaml)
appli.state.config = config appli.state.config = config
appli.add_exception_handler(NotAuthenticatedException, auth_exception_handler)
appli.state.manager = create_manager(config.general.cookie_secret)
@appli.state.manager.user_loader()
async def query_user(user: str) -> None | models.User:
"""
Get a user from the db
:param user: name of the user
:return: None or the user object
"""
return await queries.get_user(appli.state.db, user)
appli.add_event_handler(
"startup",
create_start_app_handler(appli),
)
appli.add_event_handler(
"shutdown",
create_stop_app_handler(appli),
)
appli.include_router(routes.api, prefix="/api") appli.include_router(routes.api, prefix="/api")
appli.include_router(routes.views) appli.include_router(routes.views)
@ -40,44 +46,11 @@ def get_application() -> FastAPI:
return appli return appli
def create_start_app_handler(appli):
"""Warmup the server:
setup database connection
"""
async def _get_db():
setup_database(appli)
db = await connect_to_db(appli)
tasks_count = await queries.count_tasks(db)
if tasks_count == 0:
logger.warning(
"There is no tasks in the database. "
'Please launch the command "argos server reload-config"'
)
return db
return _get_db
async def connect_to_db(appli): async def connect_to_db(appli):
appli.state.db = appli.state.SessionLocal() appli.state.db = appli.state.SessionLocal()
return appli.state.db return appli.state.db
def create_stop_app_handler(appli):
"""Gracefully shutdown the server:
close database connection.
"""
async def stop_app():
appli.state.db.close()
return stop_app
def read_config(yaml_file): def read_config(yaml_file):
try: try:
config = read_yaml_config(yaml_file) config = read_yaml_config(yaml_file)
@ -119,4 +92,36 @@ def setup_database(appli):
models.Base.metadata.create_all(bind=engine) models.Base.metadata.create_all(bind=engine)
def create_manager(cookie_secret):
return LoginManager(
cookie_secret,
"/login",
use_cookie=True,
use_header=False,
not_authenticated_exception=NotAuthenticatedException,
)
@asynccontextmanager
async def lifespan(appli):
"""Server start and stop actions
Setup database connection then close it at shutdown.
"""
setup_database(appli)
db = await connect_to_db(appli)
tasks_count = await queries.count_tasks(db)
if tasks_count == 0:
logger.warning(
"There is no tasks in the database. "
'Please launch the command "argos server reload-config"'
)
yield
appli.state.db.close()
app = get_application() app = get_application()

View file

@ -0,0 +1,35 @@
"""Add users table
Revision ID: c780864dc407
Revises: defda3f2952d
Create Date: 2024-06-10 16:31:17.296983
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "c780864dc407"
down_revision: Union[str, None] = "defda3f2952d"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("username", sa.String(), nullable=False),
sa.Column("password", sa.String(), nullable=False),
sa.Column("disabled", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.Column("last_login_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("username"),
)
def downgrade() -> None:
op.drop_table("users")

View file

@ -138,3 +138,18 @@ class ConfigCache(Base):
name: Mapped[str] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(primary_key=True)
val: Mapped[str] = mapped_column() val: Mapped[str] = mapped_column()
updated_at: Mapped[datetime] = mapped_column() updated_at: Mapped[datetime] = mapped_column()
class User(Base):
"""Database model for user authentication"""
__tablename__ = "users"
username: Mapped[str] = mapped_column(primary_key=True)
password: Mapped[str] = mapped_column()
disabled: Mapped[bool] = mapped_column()
created_at: Mapped[datetime] = mapped_column(default=datetime.now())
updated_at: Mapped[datetime] = mapped_column(nullable=True)
last_login_at: Mapped[datetime] = mapped_column(nullable=True)
def update_last_login_at(self):
self.last_login_at = datetime.now()

View file

@ -1,15 +1,15 @@
"""Functions to ease SQL queries management""" """Functions to ease SQL queries management"""
from datetime import datetime, timedelta from datetime import datetime, timedelta
from hashlib import sha256 from hashlib import sha256
from typing import List, Union from typing import List
from urllib.parse import urljoin from urllib.parse import urljoin
from sqlalchemy import desc, func from sqlalchemy import asc, desc, func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from argos import schemas from argos import schemas
from argos.logging import logger from argos.logging import logger
from argos.server.models import Result, Task, ConfigCache from argos.server.models import Result, Task, ConfigCache, User
async def list_tasks(db: Session, agent_id: str, limit: int = 100): async def list_tasks(db: Session, agent_id: str, limit: int = 100):
@ -32,6 +32,25 @@ async def list_tasks(db: Session, agent_id: str, limit: int = 100):
return tasks return tasks
async def add_user(db: Session, name: str, password: str) -> User:
user = User(
username=name,
password=password,
disabled=False,
)
db.add(user)
db.commit()
return user
async def get_user(db: Session, username: str) -> None | User:
return db.get(User, username)
async def list_users(db: Session):
return db.query(User).order_by(asc(User.username))
async def get_task(db: Session, task_id: int) -> Task: async def get_task(db: Session, task_id: int) -> Task:
return db.get(Task, task_id) return db.get(Task, task_id)
@ -48,7 +67,7 @@ async def create_result(db: Session, agent_result: schemas.AgentResult, agent_id
return result return result
async def count_tasks(db: Session, selected: Union[None, bool] = None): async def count_tasks(db: Session, selected: None | bool = None):
query = db.query(Task) query = db.query(Task)
if selected is not None: if selected is not None:
if selected: if selected:

View file

@ -16,6 +16,10 @@ def get_config(request: Request):
return request.app.state.config return request.app.state.config
async def get_manager(request: Request):
return await request.app.state.manager(request)
async def verify_token( async def verify_token(
request: Request, token: HTTPAuthorizationCredentials = Depends(auth_scheme) request: Request, token: HTTPAuthorizationCredentials = Depends(auth_scheme)
): ):

View file

@ -1,5 +1,6 @@
"""Web interface for humans""" """Web interface for humans"""
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timedelta
from functools import cmp_to_key from functools import cmp_to_key
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated
@ -7,14 +8,16 @@ from urllib.parse import urlparse
from fastapi import APIRouter, Cookie, Depends, Form, Request, status from fastapi import APIRouter, Cookie, Depends, Form, Request, status
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi.security import OAuth2PasswordRequestForm
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from passlib.context import CryptContext
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from argos.schemas import Config from argos.schemas import Config
from argos.server import queries from argos.server import queries
from argos.server.models import Result, Task from argos.server.models import Result, Task, User
from argos.server.routes.dependencies import get_config, get_db from argos.server.routes.dependencies import get_config, get_db, get_manager
route = APIRouter() route = APIRouter()
@ -23,9 +26,74 @@ templates = Jinja2Templates(directory=current_dir / ".." / "templates")
SEVERITY_LEVELS = {"ok": 1, "warning": 2, "critical": 3, "unknown": 4} SEVERITY_LEVELS = {"ok": 1, "warning": 2, "critical": 3, "unknown": 4}
@route.get("/login")
async def login_view(request: Request, msg: str | None = None):
token = request.cookies.get("access-token")
if token is not None:
manager = get_manager(request)
user = await manager.get_current_user(token)
if user is not None:
return RedirectResponse(
request.url_for("get_severity_counts_view"),
status_code=status.HTTP_303_SEE_OTHER,
)
if msg == "logout":
msg = "You have been successfully disconnected."
else:
msg = None
return templates.TemplateResponse("login.html", {"request": request, "msg": msg})
@route.post("/login")
async def post_login(
request: Request,
db: Session = Depends(get_db),
data: OAuth2PasswordRequestForm = Depends(),
):
username = data.username
user = await queries.get_user(db, username)
invalid_credentials = templates.TemplateResponse(
"login.html",
{"request": request, "msg": "Sorry, invalid username or bad password."},
)
if user is None:
return invalid_credentials
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
if not pwd_context.verify(data.password, user.password):
return invalid_credentials
user.last_login_at = datetime.now()
db.commit()
manager = request.app.state.manager
token = manager.create_access_token(
data={"sub": username}, expires=timedelta(days=7)
)
response = RedirectResponse(
request.url_for("get_severity_counts_view"),
status_code=status.HTTP_303_SEE_OTHER,
)
manager.set_cookie(response, token)
return response
@route.get("/logout")
async def logout_view(request: Request, user: User | None = Depends(get_manager)):
response = RedirectResponse(
request.url_for("login_view").include_query_params(msg="logout"),
status_code=status.HTTP_303_SEE_OTHER,
)
response.delete_cookie(key="access-token")
return response
@route.get("/") @route.get("/")
async def get_severity_counts_view( async def get_severity_counts_view(
request: Request, request: Request,
user: User | None = Depends(get_manager),
db: Session = Depends(get_db), db: Session = Depends(get_db),
auto_refresh_enabled: Annotated[bool, Cookie()] = False, auto_refresh_enabled: Annotated[bool, Cookie()] = False,
auto_refresh_seconds: Annotated[int, Cookie()] = 15, auto_refresh_seconds: Annotated[int, Cookie()] = 15,
@ -48,7 +116,11 @@ async def get_severity_counts_view(
@route.get("/domains") @route.get("/domains")
async def get_domains_view(request: Request, db: Session = Depends(get_db)): async def get_domains_view(
request: Request,
user: User | None = Depends(get_manager),
db: Session = Depends(get_db),
):
"""Show all tasks and their current state""" """Show all tasks and their current state"""
tasks = db.query(Task).all() tasks = db.query(Task).all()
@ -96,7 +168,10 @@ async def get_domains_view(request: Request, db: Session = Depends(get_db)):
@route.get("/domain/{domain}") @route.get("/domain/{domain}")
async def get_domain_tasks_view( async def get_domain_tasks_view(
request: Request, domain: str, db: Session = Depends(get_db) request: Request,
domain: str,
user: User | None = Depends(get_manager),
db: Session = Depends(get_db),
): ):
"""Show all tasks attached to a domain""" """Show all tasks attached to a domain"""
tasks = db.query(Task).filter(Task.domain.contains(f"//{domain}")).all() tasks = db.query(Task).filter(Task.domain.contains(f"//{domain}")).all()
@ -107,7 +182,10 @@ async def get_domain_tasks_view(
@route.get("/result/{result_id}") @route.get("/result/{result_id}")
async def get_result_view( async def get_result_view(
request: Request, result_id: int, db: Session = Depends(get_db) request: Request,
result_id: int,
user: User | None = Depends(get_manager),
db: Session = Depends(get_db),
): ):
"""Show the details of a result""" """Show the details of a result"""
result = db.query(Result).get(result_id) result = db.query(Result).get(result_id)
@ -120,6 +198,7 @@ async def get_result_view(
async def get_task_results_view( async def get_task_results_view(
request: Request, request: Request,
task_id: int, task_id: int,
user: User | None = Depends(get_manager),
db: Session = Depends(get_db), db: Session = Depends(get_db),
config: Config = Depends(get_config), config: Config = Depends(get_config),
): ):
@ -144,7 +223,11 @@ async def get_task_results_view(
@route.get("/agents") @route.get("/agents")
async def get_agents_view(request: Request, db: Session = Depends(get_db)): async def get_agents_view(
request: Request,
user: User | None = Depends(get_manager),
db: Session = Depends(get_db),
):
"""Show argos agents and the last time the server saw them""" """Show argos agents and the last time the server saw them"""
last_seen = ( last_seen = (
db.query(Result.agent_id, func.max(Result.submitted_at).label("submitted_at")) db.query(Result.agent_id, func.max(Result.submitted_at).label("submitted_at"))
@ -160,6 +243,7 @@ async def get_agents_view(request: Request, db: Session = Depends(get_db)):
@route.post("/refresh") @route.post("/refresh")
async def set_refresh_cookies_view( async def set_refresh_cookies_view(
request: Request, request: Request,
user: User | None = Depends(get_manager),
auto_refresh_enabled: Annotated[bool, Form()] = False, auto_refresh_enabled: Annotated[bool, Form()] = False,
auto_refresh_seconds: Annotated[int, Form()] = 15, auto_refresh_seconds: Annotated[int, Form()] = 15,
): ):

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -13,7 +13,7 @@ body > main {
} }
h2 { h2 {
margin-bottom: calc(var(--typography-spacing-vertical) * 0.5); margin-bottom: calc(var(--pico-typography-spacing-vertical) * 0.5);
} }
.grid-index { .grid-index {
@ -26,10 +26,12 @@ h2 {
.grid-index article { .grid-index article {
margin-top: 0; margin-top: 0;
margin-bottom: 1rem; margin-bottom: 1rem;
padding-bottom: calc(var(--block-spacing-vertical) * 0.7); padding-bottom: calc(var(--pico-block-spacing-vertical) * 2.4);
} }
.grid-index article > header { .grid-index article > header {
margin-bottom: calc(var(--block-spacing-vertical) * 0.7); padding-top: calc(var(--pico-block-spacing-vertical) * 2.4);
padding-bottom: calc(var(--pico-block-spacing-vertical) * 2.4);
margin-bottom: calc(var(--pico-block-spacing-vertical) * 2.4);
} }
label[for="select-status"] { label[for="select-status"] {

View file

@ -32,6 +32,10 @@
</li> </li>
</ul> </ul>
</a> </a>
{% if request.url.remove_query_params('msg') != url_for('login_view') %}
<ul>
<details class="dropdown">
<summary autofocus>Menu</summary>
<ul> <ul>
<li> <li>
<a href="{{ url_for('get_severity_counts_view') }}" <a href="{{ url_for('get_severity_counts_view') }}"
@ -39,24 +43,41 @@
role="button"> role="button">
Dashboard Dashboard
</a> </a>
</li>
<li>
<a href="{{ url_for('get_domains_view') }}" <a href="{{ url_for('get_domains_view') }}"
class="outline {{ 'contrast' if request.url == url_for('get_domains_view') }}" class="outline {{ 'contrast' if request.url == url_for('get_domains_view') }}"
role="button"> role="button">
Domains Domains
</a> </a>
</li>
<li>
<a href="{{ url_for('get_agents_view') }}" <a href="{{ url_for('get_agents_view') }}"
class="outline {{ 'contrast' if request.url == url_for('get_agents_view') }}" class="outline {{ 'contrast' if request.url == url_for('get_agents_view') }}"
role="button"> role="button">
Agents Agents
</a> </a>
</li>
<li>
<a href="#" <a href="#"
id="reschedule-all" id="reschedule-all"
class="outline" class="outline"
title="Reschedule non-ok checks as soon as possible"> title="Reschedule non-ok checks as soon as possible"
🕐 role="button">
Reschedule non-ok checks
</a>
</li>
<li>
<a href="{{ url_for('logout_view') }}"
class="outline {{ 'contrast' if request.url == url_for('get_agents_view') }}"
role="button">
Logout
</a> </a>
</li> </li>
</ul> </ul>
</details>
</ul>
{% endif %}
</nav> </nav>
</header> </header>
<main class="container"> <main class="container">
@ -74,7 +95,7 @@
<a href="https://argos-monitoring.framasoft.org/">Argos Panoptès</a>, <a href="https://argos-monitoring.framasoft.org/">Argos Panoptès</a>,
<a href="https://framagit.org/framasoft/framaspace/argos/-/blob/main/LICENSE">AGPLv3</a> <a href="https://framagit.org/framasoft/framaspace/argos/-/blob/main/LICENSE">AGPLv3</a>
(<a href="https://framagit.org/framasoft/framaspace/argos">sources</a>) (<a href="https://framagit.org/framasoft/framaspace/argos">sources</a>)
<br/> <br>
API documentation: API documentation:
<a href="{{ url_for('get_severity_counts_view') }}docs">Swagger</a> <a href="{{ url_for('get_severity_counts_view') }}docs">Swagger</a>
or or

View file

@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}<h2>Login</h2>{% endblock title %}
{% block content %}
{% if msg is not none %}
<article>{{ msg }}</article>
{% endif %}
<div class="frame">
<label for="username">Username</label>
<input id="username"
name="username"
type="text"
form="login"
autofocus>
<label for="password">Password</label>
<input id="password"
name="password"
type="password"
form="login">
<form id="login"
method="post"
action="{{ url_for('post_login') }}">
<input type="Submit">
</form>
</div>
{% endblock content %}

View file

@ -22,5 +22,4 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% endblock content %} {% endblock content %}

View file

@ -16,12 +16,6 @@ server {
rewrite ^ https://$http_host$request_uri? permanent; rewrite ^ https://$http_host$request_uri? permanent;
} }
location ~ ^/($|domains?/|result/|task/|refresh/) {
auth_basic "Closed site";
auth_basic_user_file argos.passwd;
include proxy_params;
proxy_pass http://127.0.0.1:8000;
}
location / { location / {
include proxy_params; include proxy_params;
proxy_pass http://127.0.0.1:8000; proxy_pass http://127.0.0.1:8000;

View file

@ -83,6 +83,7 @@ Commands:
migrate Run database migrations migrate Run database migrations
reload-config Load or reload tasks configuration reload-config Load or reload tasks configuration
start Starts the server (use only for testing or development!) start Starts the server (use only for testing or development!)
user
watch-agents Watch agents (to run routinely) watch-agents Watch agents (to run routinely)
``` ```
@ -241,3 +242,200 @@ Options:
<!--[[[end]]] <!--[[[end]]]
--> -->
### Server user management
To access Argos web interface, you need to create at least one user.
You can manage users only through CLI.
<!--
.. [[[cog
help(["server", "user", "--help"])
.. ]]] -->
```man
Usage: argos server user [OPTIONS] COMMAND [ARGS]...
Options:
--help Show this message and exit.
Commands:
add Add new user
change-password Change users password
delete Delete user
disable Disable user
enable Enable user
show List all users
verify-password Test users password
```
<!--[[[end]]]
-->
### Add user
<!--
.. [[[cog
help(["server", "user", "add", "--help"])
.. ]]] -->
```man
Usage: argos server user add [OPTIONS]
Add new user
Options:
--config TEXT Path of the configuration file. If ARGOS_YAML_FILE
environment variable is set, its value will be used instead.
--name TEXT Name of the user to create.
--password TEXT
--help Show this message and exit.
```
<!--[[[end]]]
-->
### Change the password of a user
<!--
.. [[[cog
help(["server", "user", "change-password", "--help"])
.. ]]] -->
```man
Usage: argos server user change-password [OPTIONS]
Change users password
Options:
--config TEXT Path of the configuration file. If ARGOS_YAML_FILE
environment variable is set, its value will be used instead.
--name TEXT Name of the user you want to change the password.
--password TEXT
--help Show this message and exit.
```
<!--[[[end]]]
-->
### Delete a user
<!--
.. [[[cog
help(["server", "user", "delete", "--help"])
.. ]]] -->
```man
Usage: argos server user delete [OPTIONS]
Delete user
Options:
--config TEXT Path of the configuration file. If ARGOS_YAML_FILE environment
variable is set, its value will be used instead.
--name TEXT Name of the user to delete. [required]
--help Show this message and exit.
```
<!--[[[end]]]
-->
### Disable a user
Disabling a user prevents the user to login and access Argos web interface but its credentials are still stored in Argos database.
<!--
.. [[[cog
help(["server", "user", "disable", "--help"])
.. ]]] -->
```man
Usage: argos server user disable [OPTIONS]
Disable user
Options:
--config TEXT Path of the configuration file. If ARGOS_YAML_FILE environment
variable is set, its value will be used instead.
--name TEXT Name of the user to disable. [required]
--help Show this message and exit.
```
<!--[[[end]]]
-->
### Enable a user
Enabling a user prevents the user to login and access Argos web interface.
Obviously, the user needs to exists and to be disabled before using the command.
<!--
.. [[[cog
help(["server", "user", "enable", "--help"])
.. ]]] -->
```man
Usage: argos server user enable [OPTIONS]
Enable user
Options:
--config TEXT Path of the configuration file. If ARGOS_YAML_FILE environment
variable is set, its value will be used instead.
--name TEXT Name of the user to reenable [required]
--help Show this message and exit.
```
<!--[[[end]]]
-->
### List all users
Show all accounts, with their status (enabled or disabled).
<!--
.. [[[cog
help(["server", "user", "show", "--help"])
.. ]]] -->
```man
Usage: argos server user show [OPTIONS]
List all users
Options:
--config TEXT Path of the configuration file. If ARGOS_YAML_FILE environment
variable is set, its value will be used instead.
--help Show this message and exit.
```
<!--[[[end]]]
-->
### Test the password of a user
You can verify that you have the right password for a user with the following command:
<!--
.. [[[cog
help(["server", "user", "verify-password", "--help"])
.. ]]] -->
```man
Usage: argos server user verify-password [OPTIONS]
Test users password
Options:
--config TEXT Path of the configuration file. If ARGOS_YAML_FILE
environment variable is set, its value will be used instead.
--name TEXT Name of the user you want to test the password for.
[required]
--password TEXT
--help Show this message and exit.
```
<!--[[[end]]]
-->

View file

@ -1,13 +1,6 @@
# Using Nginx as reverse proxy # Using Nginx as reverse proxy
As Argos has no authentication mechanism for the front-end, you need to protect some routes with HTTP authentication. Here is a example for Nginx configuration:
To do so on Debian, install `apache2-utils` then create a file containing the wanted credentials:
```bash
htpasswd -c /etc/nginx/argos.passwd argos_admin
```
You can then use this file to protect the front-ends routes:
```{literalinclude} ../../conf/nginx.conf ```{literalinclude} ../../conf/nginx.conf
--- ---
caption: /etc/nginx/sites-available/argos.example.org caption: /etc/nginx/sites-available/argos.example.org

View file

@ -22,10 +22,13 @@ classifiers = [
dependencies = [ dependencies = [
"alembic>=1.13.0,<1.14", "alembic>=1.13.0,<1.14",
"bcrypt>=4.1.3,<5",
"click>=8.1,<9", "click>=8.1,<9",
"fastapi>=0.103,<0.104", "fastapi>=0.103,<0.104",
"fastapi-login>=1.10.0,<2",
"httpx>=0.25,<1", "httpx>=0.25,<1",
"Jinja2>=3.0,<4", "Jinja2>=3.0,<4",
"passlib>=1.7.4,<2",
"psycopg2-binary>=2.9,<3", "psycopg2-binary>=2.9,<3",
"pydantic[email]>=2.4,<3", "pydantic[email]>=2.4,<3",
"pydantic-settings>=2.0,<3", "pydantic-settings>=2.0,<3",
@ -96,3 +99,7 @@ testpaths = [
"argos" "argos"
] ]
pythonpath = "." pythonpath = "."
filterwarnings = [
"ignore:'crypt' is deprecated and slated for removal in Python 3.13:DeprecationWarning",
"ignore:The 'app' shortcut is now deprecated:DeprecationWarning",
]

View file

@ -3,6 +3,7 @@ general:
# The database URL, as defined in SQLAlchemy docs : https://docs.sqlalchemy.org/en/20/core/engines.html#database-urls # The database URL, as defined in SQLAlchemy docs : https://docs.sqlalchemy.org/en/20/core/engines.html#database-urls
url: "sqlite:////tmp/test-argos.db" url: "sqlite:////tmp/test-argos.db"
env: test env: test
cookie_secret: "foo-bar-baz"
frequency: "1m" frequency: "1m"
alerts: alerts:
ok: ok:

150
tests/test_cli.py Normal file
View file

@ -0,0 +1,150 @@
import os
from click.testing import CliRunner
from argos.commands import (
add,
verify_password,
change_password,
show,
disable,
enable,
delete,
)
os.environ["ARGOS_APP_ENV"] = "test"
os.environ["ARGOS_YAML_FILE"] = "tests/config.yaml"
def test_add_user():
runner = CliRunner()
result = runner.invoke(add, ["--name", "foo"], input="bar\nbar\n")
assert result.exit_code == 0
assert result.output == "Password: \nRepeat for confirmation: \nUser foo added.\n"
result = runner.invoke(add, ["--name", "foo"], input="bar\nbar\n")
assert result.exit_code == 1
assert (
result.output
== "Password: \nRepeat for confirmation: \nUser foo already exists.\n"
)
result = runner.invoke(add, ["--name", "baz", "--password", "qux"])
assert result.exit_code == 0
assert result.output == "User baz added.\n"
def test_user_password():
runner = CliRunner()
result = runner.invoke(verify_password, ["--name", "foo"], input="bar\n")
assert result.exit_code == 0
assert result.output == "Password: \nThe provided password is correct.\n"
result = runner.invoke(verify_password, ["--name", "foo", "--password", "bar"])
assert result.exit_code == 0
assert result.output == "The provided password is correct.\n"
result = runner.invoke(verify_password, ["--name", "quux", "--password", "corge"])
assert result.exit_code == 1
assert result.output == "User quux does not exist.\n"
result = runner.invoke(verify_password, ["--name", "foo", "--password", "grault"])
assert result.exit_code == 2
assert result.output == "Wrong password!\n"
def test_change_password():
runner = CliRunner()
result = runner.invoke(verify_password, ["--name", "foo", "--password", "grault"])
assert result.exit_code == 2
assert result.output == "Wrong password!\n"
result = runner.invoke(change_password, ["--name", "foo"], input="grault\ngrault\n")
assert result.exit_code == 0
assert (
result.output
== "Password: \nRepeat for confirmation: \nPassword of user foo changed.\n"
)
result = runner.invoke(verify_password, ["--name", "foo", "--password", "grault"])
assert result.exit_code == 0
assert result.output == "The provided password is correct.\n"
result = runner.invoke(change_password, ["--name", "foo", "--password", "bar"])
assert result.exit_code == 0
assert result.output == "Password of user foo changed.\n"
result = runner.invoke(verify_password, ["--name", "foo", "--password", "bar"])
assert result.exit_code == 0
assert result.output == "The provided password is correct.\n"
result = runner.invoke(verify_password, ["--name", "quux", "--password", "bar"])
assert result.exit_code == 1
assert result.output == "User quux does not exist.\n"
def test_show():
runner = CliRunner()
result = runner.invoke(show)
assert result.exit_code == 0
assert (
result.output
== "✅ means that the user is enabled.\n❌ means that the user is disabled.\n"
"✅ baz, last login: None\n✅ foo, last login: None\n"
)
def test_disable():
runner = CliRunner()
result = runner.invoke(disable, ["--name", "quux"])
assert result.exit_code == 1
assert result.output == "User quux does not exist.\n"
result = runner.invoke(disable, ["--name", "foo"])
assert result.exit_code == 0
assert result.output == "User foo disabled.\n"
result = runner.invoke(disable, ["--name", "foo"])
assert result.exit_code == 2
assert result.output == "User foo is already disabled.\n"
result = runner.invoke(show)
assert result.exit_code == 0
assert (
result.output
== "✅ means that the user is enabled.\n❌ means that the user is disabled.\n"
"✅ baz, last login: None\n❌ foo, last login: None\n"
)
def test_enable():
runner = CliRunner()
result = runner.invoke(enable, ["--name", "quux"])
assert result.exit_code == 1
assert result.output == "User quux does not exist.\n"
result = runner.invoke(enable, ["--name", "foo"])
assert result.exit_code == 0
assert result.output == "User foo enabled.\n"
result = runner.invoke(enable, ["--name", "foo"])
assert result.exit_code == 2
assert result.output == "User foo is already enabled.\n"
result = runner.invoke(show)
assert result.exit_code == 0
assert (
result.output
== "✅ means that the user is enabled.\n❌ means that the user is disabled.\n"
"✅ baz, last login: None\n✅ foo, last login: None\n"
)
def test_delete():
runner = CliRunner()
result = runner.invoke(delete, ["--name", "quux"])
assert result.exit_code == 1
assert result.output == "User quux does not exist.\n"
result = runner.invoke(delete, ["--name", "foo"])
assert result.exit_code == 0
assert result.output == "User foo deleted.\n"
result = runner.invoke(delete, ["--name", "foo"])
assert result.exit_code == 1
assert result.output == "User foo does not exist.\n"
result = runner.invoke(show)
assert result.exit_code == 0
assert (
result.output
== "✅ means that the user is enabled.\n❌ means that the user is disabled.\n"
"✅ baz, last login: None\n"
)
result = runner.invoke(delete, ["--name", "baz"])
assert result.exit_code == 0
assert result.output == "User baz deleted.\n"
result = runner.invoke(show)
assert result.exit_code == 1
assert result.output == "There is no users in database.\n"

View file

@ -4,18 +4,18 @@ import pytest
from argos import schemas from argos import schemas
from argos.server import queries from argos.server import queries
from argos.server.models import Result, Task from argos.server.models import Result, Task, User
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_remove_old_results(db, ten_tasks): async def test_remove_old_results(db, ten_tasks): # pylint: disable-msg=redefined-outer-name
for task in ten_tasks: for _task in ten_tasks:
for i in range(5): for _ in range(5):
result = Result( result = Result(
submitted_at=datetime.now(), submitted_at=datetime.now(),
status="success", status="success",
context={"foo": "bar"}, context={"foo": "bar"},
task=task, task=_task,
agent_id="test", agent_id="test",
severity="ok", severity="ok",
) )
@ -28,8 +28,8 @@ async def test_remove_old_results(db, ten_tasks):
deleted = await queries.remove_old_results(db, 2) deleted = await queries.remove_old_results(db, 2)
assert deleted == 30 assert deleted == 30
assert db.query(Result).count() == 20 assert db.query(Result).count() == 20
for task in ten_tasks: for _task in ten_tasks:
assert db.query(Result).filter(Result.task == task).count() == 2 assert db.query(Result).filter(Result.task == _task).count() == 2
@pytest.mark.asyncio @pytest.mark.asyncio
@ -40,7 +40,7 @@ async def test_remove_old_results_with_empty_db(db):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_release_old_locks(db, ten_locked_tasks, ten_tasks): async def test_release_old_locks(db, ten_locked_tasks, ten_tasks): # pylint: disable-msg=redefined-outer-name
assert db.query(Task).count() == 20 assert db.query(Task).count() == 20
released = await queries.release_old_locks(db, 10) released = await queries.release_old_locks(db, 10)
assert released == 10 assert released == 10
@ -54,9 +54,9 @@ async def test_release_old_locks_with_empty_db(db):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_from_config_with_duplicate_tasks(db, empty_config): async def test_update_from_config_with_duplicate_tasks(db, empty_config): # pylint: disable-msg=redefined-outer-name
# We pass the same path twice # We pass the same path twice
fake_path = dict(path="/", checks=[{"body-contains": "foo"}]) fake_path = {"path": "/", "checks": [{"body-contains": "foo"}]}
website = schemas.config.Website( website = schemas.config.Website(
domain="https://example.org", domain="https://example.org",
paths=[ paths=[
@ -79,7 +79,9 @@ async def test_update_from_config_with_duplicate_tasks(db, empty_config):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_from_config_db_can_remove_duplicates_and_old_tasks( async def test_update_from_config_db_can_remove_duplicates_and_old_tasks(
db, empty_config, task db,
empty_config,
task, # pylint: disable-msg=redefined-outer-name
): ):
# Add a duplicate in the db # Add a duplicate in the db
same_task = Task( same_task = Task(
@ -96,10 +98,11 @@ async def test_update_from_config_db_can_remove_duplicates_and_old_tasks(
website = schemas.config.Website( website = schemas.config.Website(
domain=task.domain, domain=task.domain,
paths=[ paths=[
dict( {
path="https://another-example.com", checks=[{task.check: task.expected}] "path": "https://another-example.com",
), "checks": [{task.check: task.expected}],
dict(path=task.url, checks=[{task.check: task.expected}]), },
{"path": task.url, "checks": [{task.check: task.expected}]},
], ],
) )
empty_config.websites = [website] empty_config.websites = [website]
@ -110,9 +113,10 @@ async def test_update_from_config_db_can_remove_duplicates_and_old_tasks(
website = schemas.config.Website( website = schemas.config.Website(
domain=task.domain, domain=task.domain,
paths=[ paths=[
dict( {
path="https://another-example.com", checks=[{task.check: task.expected}] "path": "https://another-example.com",
), "checks": [{task.check: task.expected}],
}
], ],
) )
empty_config.websites = [website] empty_config.websites = [website]
@ -122,14 +126,12 @@ async def test_update_from_config_db_can_remove_duplicates_and_old_tasks(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_from_config_db_updates_existing_tasks(db, empty_config, task): async def test_update_from_config_db_updates_existing_tasks(db, empty_config, task): # pylint: disable-msg=redefined-outer-name
assert db.query(Task).count() == 1 assert db.query(Task).count() == 1
website = schemas.config.Website( website = schemas.config.Website(
domain=task.domain, domain=task.domain,
paths=[ paths=[{"path": task.url, "checks": [{task.check: task.expected}]}],
dict(path=task.url, checks=[{task.check: task.expected}]),
],
) )
empty_config.websites = [website] empty_config.websites = [website]
@ -139,7 +141,11 @@ async def test_update_from_config_db_updates_existing_tasks(db, empty_config, ta
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_reschedule_all( async def test_reschedule_all(
db, ten_tasks, ten_warning_tasks, ten_critical_tasks, ten_ok_tasks db,
ten_tasks,
ten_warning_tasks,
ten_critical_tasks,
ten_ok_tasks, # pylint: disable-msg=redefined-outer-name
): ):
assert db.query(Task).count() == 40 assert db.query(Task).count() == 40
assert db.query(Task).filter(Task.severity == "unknown").count() == 10 assert db.query(Task).filter(Task.severity == "unknown").count() == 10
@ -154,18 +160,65 @@ async def test_reschedule_all(
assert db.query(Task).filter(Task.next_run <= one_hour_ago).count() == 30 assert db.query(Task).filter(Task.next_run <= one_hour_ago).count() == 30
@pytest.mark.asyncio
async def test_add_user(db):
users = await queries.list_users(db)
assert users.count() == 0
_user = await queries.add_user(db, "john", "doe")
assert _user.username == "john"
assert _user.password == "doe"
assert _user.disabled == False
assert _user.created_at is not None
assert _user.updated_at is None
assert _user.last_login_at is None
_user = await queries.get_user(db, "morgan")
assert _user is None
_user = await queries.get_user(db, "john")
assert _user.username == "john"
assert _user.password == "doe"
assert _user.disabled == False
assert _user.created_at is not None
assert _user.updated_at is None
assert _user.last_login_at is None
users = await queries.list_users(db)
assert users.count() == 1
@pytest.mark.asyncio
async def test_remove_user(db, user): # pylint: disable-msg=redefined-outer-name
users = await queries.list_users(db)
assert users.count() == 1
assert user.username == "jane"
assert user.password == "doe"
assert user.disabled == False
assert user.created_at is not None
assert user.updated_at is None
assert user.last_login_at is None
db.delete(user)
db.commit()
users = await queries.list_users(db)
assert users.count() == 0
@pytest.fixture @pytest.fixture
def task(db): def task(db):
task = Task( _task = Task(
url="https://www.example.com", url="https://www.example.com",
domain="https://www.example.com", domain="https://www.example.com",
check="body-contains", check="body-contains",
expected="foo", expected="foo",
frequency=1, frequency=1,
) )
db.add(task) db.add(_task)
db.commit() db.commit()
return task return _task
@pytest.fixture @pytest.fixture
@ -173,6 +226,7 @@ def empty_config():
return schemas.config.Config( return schemas.config.Config(
general=schemas.config.General( general=schemas.config.General(
db=schemas.config.DbSettings(url="sqlite:////tmp/test-argos.db"), db=schemas.config.DbSettings(url="sqlite:////tmp/test-argos.db"),
cookie_secret="foo-bar-baz",
frequency="1m", frequency="1m",
alerts=schemas.config.Alert( alerts=schemas.config.Alert(
ok=["", ""], ok=["", ""],
@ -192,9 +246,9 @@ def empty_config():
@pytest.fixture @pytest.fixture
def ten_results(db, task): def ten_results(db, task): # pylint: disable-msg=redefined-outer-name
results = [] results = []
for i in range(10): for _ in range(10):
result = Result( result = Result(
submitted_at=datetime.now(), submitted_at=datetime.now(),
status="success", status="success",
@ -213,8 +267,8 @@ def ten_results(db, task):
def ten_locked_tasks(db): def ten_locked_tasks(db):
a_minute_ago = datetime.now() - timedelta(minutes=1) a_minute_ago = datetime.now() - timedelta(minutes=1)
tasks = [] tasks = []
for i in range(10): for _ in range(10):
task = Task( _task = Task(
url="https://www.example.com", url="https://www.example.com",
domain="example.com", domain="example.com",
check="body-contains", check="body-contains",
@ -223,8 +277,8 @@ def ten_locked_tasks(db):
selected_by="test", selected_by="test",
selected_at=a_minute_ago, selected_at=a_minute_ago,
) )
db.add(task) db.add(_task)
tasks.append(task) tasks.append(_task)
db.commit() db.commit()
return tasks return tasks
@ -233,8 +287,8 @@ def ten_locked_tasks(db):
def ten_tasks(db): def ten_tasks(db):
now = datetime.now() now = datetime.now()
tasks = [] tasks = []
for i in range(10): for _ in range(10):
task = Task( _task = Task(
url="https://www.example.com", url="https://www.example.com",
domain="example.com", domain="example.com",
check="body-contains", check="body-contains",
@ -243,8 +297,8 @@ def ten_tasks(db):
selected_by="test", selected_by="test",
selected_at=now, selected_at=now,
) )
db.add(task) db.add(_task)
tasks.append(task) tasks.append(_task)
db.commit() db.commit()
return tasks return tasks
@ -253,8 +307,8 @@ def ten_tasks(db):
def ten_warning_tasks(db): def ten_warning_tasks(db):
now = datetime.now() now = datetime.now()
tasks = [] tasks = []
for i in range(10): for _ in range(10):
task = Task( _task = Task(
url="https://www.example.com", url="https://www.example.com",
domain="example.com", domain="example.com",
check="body-contains", check="body-contains",
@ -263,8 +317,8 @@ def ten_warning_tasks(db):
next_run=now, next_run=now,
severity="warning", severity="warning",
) )
db.add(task) db.add(_task)
tasks.append(task) tasks.append(_task)
db.commit() db.commit()
return tasks return tasks
@ -273,8 +327,8 @@ def ten_warning_tasks(db):
def ten_critical_tasks(db): def ten_critical_tasks(db):
now = datetime.now() now = datetime.now()
tasks = [] tasks = []
for i in range(10): for _ in range(10):
task = Task( _task = Task(
url="https://www.example.com", url="https://www.example.com",
domain="example.com", domain="example.com",
check="body-contains", check="body-contains",
@ -283,8 +337,8 @@ def ten_critical_tasks(db):
next_run=now, next_run=now,
severity="critical", severity="critical",
) )
db.add(task) db.add(_task)
tasks.append(task) tasks.append(_task)
db.commit() db.commit()
return tasks return tasks
@ -293,8 +347,8 @@ def ten_critical_tasks(db):
def ten_ok_tasks(db): def ten_ok_tasks(db):
now = datetime.now() now = datetime.now()
tasks = [] tasks = []
for i in range(10): for _ in range(10):
task = Task( _task = Task(
url="https://www.example.com", url="https://www.example.com",
domain="example.com", domain="example.com",
check="body-contains", check="body-contains",
@ -303,7 +357,19 @@ def ten_ok_tasks(db):
next_run=now, next_run=now,
severity="ok", severity="ok",
) )
db.add(task) db.add(_task)
tasks.append(task) tasks.append(_task)
db.commit() db.commit()
return tasks return tasks
@pytest.fixture
def user(db):
_user = User(
username="jane",
password="doe",
disabled=False,
)
db.add(_user)
db.commit()
return _user