diff --git a/.github/workflows/equipment-checkout.yml b/.github/workflows/equipment-checkout.yml new file mode 100644 index 00000000..5efa1606 --- /dev/null +++ b/.github/workflows/equipment-checkout.yml @@ -0,0 +1,29 @@ +name: Equipment checkout checks +on: + pull_request: + paths: [applications/equipment-checkout/**, .github/workflows/equipment-checkout.yml] + push: + paths: [applications/equipment-checkout/**, .github/workflows/equipment-checkout.yml] +permissions: + contents: read +jobs: + django-check: + runs-on: ubuntu-latest + defaults: + run: + working-directory: applications/equipment-checkout + env: + DJANGO_SECRET_KEY: ci-configuration-check-only + PGHOST: unused.example.invalid + PGUSER: equipment_app + PGPASSWORD: unused + PGSSLROOTCERT: /etc/ssl/certs/ca-certificates.crt + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python -m pip install -r requirements.txt + - run: python manage.py check + - run: python -m compileall -q config board tests +# Cloud integration tests are intentionally manual against a dedicated service. diff --git a/applications/equipment-checkout/.env.example b/applications/equipment-checkout/.env.example new file mode 100644 index 00000000..2f66e0c6 --- /dev/null +++ b/applications/equipment-checkout/.env.example @@ -0,0 +1,12 @@ +# Load explicitly: set -a; source .deployment/app.env; set +a +DJANGO_SECRET_KEY=REPLACE_WITH_A_RANDOM_SECRET +DJANGO_DEBUG=1 +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 +# Local HTTP only; use 1 behind HTTPS in hosted operation. +DJANGO_COOKIE_SECURE=0 +PGHOST=YOUR_SERVICE_DIRECT_HOSTNAME +PGPORT=5432 +PGDATABASE=postgres +PGUSER=equipment_app +PGPASSWORD=YOUR_RUNTIME_PASSWORD +PGSSLROOTCERT=/absolute/path/to/.deployment/postgres-ca.pem diff --git a/applications/equipment-checkout/.gitignore b/applications/equipment-checkout/.gitignore new file mode 100644 index 00000000..8ff4edf4 --- /dev/null +++ b/applications/equipment-checkout/.gitignore @@ -0,0 +1,6 @@ +.venv/ +.deployment/ +.env +__pycache__/ +*.pyc +staticfiles/ diff --git a/applications/equipment-checkout/README.md b/applications/equipment-checkout/README.md new file mode 100644 index 00000000..800513ad --- /dev/null +++ b/applications/equipment-checkout/README.md @@ -0,0 +1,205 @@ +# Equipment Checkout Board + +Borrow shared equipment, see your current loans, and return items with **Django**, **Django ORM**, **psycopg**, and **htmx** on **ClickHouse Managed Postgres**. Native Django authentication identifies borrowers. Staff use Django admin to add or disable inventory and can return any current loan. + +Two people can see the same available camera. Only one can check it out: a transaction locks the item, and a Postgres partial unique constraint permits one active loan per item. Returns preserve history and are idempotent. An ordinary member can return only their own loans; staff can return anyone's. Available inventory contains no borrower names. + +[ClickHouse Cloud](https://clickhouse.com/cloud) is the managed data platform. [ClickHouse Managed Postgres](https://clickhouse.com/docs/products/managed-postgres) provides the transactional PostgreSQL service used here. This example needs only Postgres. + +## Structure + +| File | Purpose | +| --- | --- | +| `board/models.py`, `board/migrations/` | Inventory, loan history, database constraints | +| `board/services.py` | Item-first locking, borrower identity, repeatable returns | +| `board/views.py`, `board/templates/` | Authenticated HTML and htmx fragments | +| `board/admin.py` | Staff inventory management; read-only loan history | +| `sql/bootstrap.sql`, `sql/grants.sql` | Separate migration and restricted runtime roles | +| `tests/integration.py`, `tests/browser.py` | Real Cloud service and browser acceptance | + +A loan belongs to an item and a Django user. `returned_at IS NULL` marks an active loan. `one_active_loan_per_item` is a partial unique index; `return_after_checkout` rejects invalid timestamps. Foreign keys protect referenced equipment and users. Admin disables equipment instead of deleting it, and does not edit loan history. + +## Set up in Linux + +Use Python 3.12, Git, OpenSSL, `jq`, `psql`, and a ClickHouse Cloud account with Managed Postgres access. Run application dependencies in a Linux environment. An optional dedicated OrbStack VM keeps installations away from your macOS host: + +```sh +orb create --memory 3G --cpus 2 ubuntu:24.04 equipment-checkout-dev +orb -m equipment-checkout-dev +``` + +Inside Linux, clone onto the VM's native filesystem: + +```sh +sudo apt-get update +sudo apt-get install -y python3-venv git curl jq openssl postgresql-client +git clone https://github.com/ClickHouse/examples.git ~/examples +cd ~/examples/applications/equipment-checkout +python3 -m venv .venv +.venv/bin/python -m pip install -r requirements.txt +umask 077 +mkdir -p .deployment +``` + +`requirements.txt` pins runtime dependencies; `requirements-dev.txt` pins the complete validation environment. htmx 2.0.11 loads from jsDelivr with an integrity hash. CSS uses optional Google Fonts and falls back to local sans-serif fonts. There's no frontend build step. + +### 1. Create a dedicated Cloud service + +Run Cloud provisioning where you manage resources. Install [clickhousectl](https://clickhouse.com/docs/interfaces/cli), and authenticate with an Admin API key. The interactive prompt keeps secrets out of shell history. + +```sh +curl -fsSL https://clickhouse.com/cli | sh +export PATH="$HOME/.local/bin:$PATH" +clickhousectl --version +clickhousectl cloud auth login --interactive +clickhousectl cloud org list +umask 077 +mkdir -p .deployment +``` + +Set your organization ID, supported AWS region, and supported Postgres size in a private `.deployment/resources.env` file. The validation used `c6gd.large` in `us-east-1` with Postgres 18 and no HA; choose from sizes available to your organization. Review [Managed Postgres pricing](https://clickhouse.com/docs/products/managed-postgres/pricing) before provisioning. Compute, storage, backups, and network usage can incur charges. Stopping the app or VM does not delete the Cloud service. + +```dotenv +CH_ORG_ID=YOUR_ORGANIZATION_UUID +DEPLOYMENT_NAME=equipment-checkout-example +CLOUD_REGION=us-east-1 +PG_SIZE=c6gd.large +``` + +Create once and preserve the response, which includes the initial administrator password: + +```sh +source .deployment/resources.env +clickhousectl cloud postgres create \ + --org-id "$CH_ORG_ID" --name "$DEPLOYMENT_NAME" \ + --provider aws --region "$CLOUD_REGION" --size "$PG_SIZE" \ + --pg-version 18 --ha-type none --tag project=equipment-checkout --json \ + > .deployment/postgres-create.json +PG_SERVICE_ID="$(jq -er '.id' .deployment/postgres-create.json)" +printf 'PG_SERVICE_ID=%s\n' "$PG_SERVICE_ID" >> .deployment/resources.env +``` + +Repeat `get` until `state` is `running`. Do not repeat creation while waiting: + +```sh +clickhousectl cloud postgres get "$PG_SERVICE_ID" \ + --org-id "$CH_ORG_ID" --json > .deployment/postgres-status.json +jq '{id, state, size, postgresVersion}' .deployment/postgres-status.json +``` + +If creation was interrupted, reconcile the recorded service name against `clickhousectl cloud postgres list --org-id "$CH_ORG_ID" --json` before creating anything else. Fetch the CA only after the service is running: + +```sh +clickhousectl cloud postgres certs get "$PG_SERVICE_ID" \ + --org-id "$CH_ORG_ID" --output .deployment/postgres-ca.pem +``` + +Use `--output` for a PEM file. Redirecting JSON certificate metadata to `.pem` won't produce a usable CA. If provisioning outside Linux, transfer the private create receipt and CA to the Linux app's `.deployment` directory for setup, without copying Cloud API keys. + +### 2. Bootstrap roles and apply migrations + +The remaining commands run inside Linux from the app directory. Generate these role passwords **once** and preserve the file on retries: + +```sh +umask 077 +cat > .deployment/passwords.env <p:last-child,.login>p { + color:var(--muted); + line-height:1.6} +.intro { + margin-bottom:46px} +.section-heading { + display:flex; + align-items:center; + justify-content:space-between; + margin-bottom:14px} +.section-heading span { + font-size:13px; + color:var(--muted)} +.inventory { + display:grid; + grid-template-columns:repeat(2,1fr); + gap:18px} +.card { + padding:27px; + background:#fff; + border:1px solid var(--line); + border-radius:15px} +.card-top { + display:flex; + align-items:center; + justify-content:space-between} +.asset { + font-size:12px; + letter-spacing:1px; + color:var(--muted)} +.badge { + font-size:11px; + font-weight:700; + padding:6px 10px; + border-radius:20px; + background:#edf4e5; + color:#376138} +.badge.muted { + background:#f0f0ec; + color:#747a70} +.card>p { + font-size:14px; + min-height:28px; + color:var(--muted)} +button { + border:0; + background:var(--green); + color:#fff; + border-radius:8px; + padding:12px 17px; + font:600 14px 'DM Sans',sans-serif; + cursor:pointer} +button span { + margin-left:30px} +button:disabled { + background:#eff0eb; + color:#8e9589; + cursor:default} +.secondary { + background:#e8ede5; + color:var(--green)} +.quiet { + background:none; + color:var(--muted); + padding:0} +.loans { + margin-top:42px} +.loan { + display:flex; + align-items:center; + justify-content:space-between; + border-top:1px solid var(--line); + padding:23px 0} +.loan p { + font-size:13px; + color:var(--muted); + margin:8px 0 0} +.empty { + border:1px dashed #c9d0c3; + border-radius:12px; + padding:28px; + color:var(--muted); + font-size:14px} +.notice { + border-left:3px solid var(--green); + background:#e8ede2; + padding:15px 18px; + border-radius:6px} +.login { + max-width:480px; + margin:30px auto} +.login form { + margin-top:30px} +.login label { + display:block; + font-size:13px; + font-weight:600; + margin-bottom:8px} +.login input { + width:100%; + padding:12px; + border:1px solid #c8cec4; + border-radius:8px; + font:inherit} +.errorlist { + color:#a32d26; + font-size:14px} +footer { + border-top:1px solid var(--line); + padding:26px 6vw; + font-size:12px; + color:var(--muted)} +@media(min-width:1100px) { + .inventory { + grid-template-columns:repeat(4,1fr)} +} +@media(max-width:650px) { + header { + padding:0 22px; + height:auto; + min-height:80px; + flex-wrap:wrap; + gap:16px} +nav { + gap:14px; + padding-bottom:15px} +.inventory { + grid-template-columns:1fr} +main { + padding:35px 22px} +h1 { + font-size:34px} +.loan { + gap:14px} +} diff --git a/applications/equipment-checkout/board/templates/board/_board.html b/applications/equipment-checkout/board/templates/board/_board.html new file mode 100644 index 00000000..0e079fb6 --- /dev/null +++ b/applications/equipment-checkout/board/templates/board/_board.html @@ -0,0 +1,8 @@ +

THE SHARED KIT

Good tools, ready to go.

Borrow what you need. Return it when you’re done.

+{% if notice %}

{{ notice }}

{% endif %}{% for message in messages %}

{{ message }}

{% endfor %} +

Inventory

{{ items|length }} items
+{% for item in items %}
{{ item.asset_tag }}{% if not item.enabled %}Out of service{% elif item.on_loan %}On loan{% else %}Available{% endif %}
+

{{ item.name }}

{{ item.description }}

+{% if item.enabled and not item.on_loan %}
{% csrf_token %}
{% else %}{% endif %}
{% empty %}

No equipment yet. Staff can add items in the admin.

{% endfor %}
+

{% if user.is_staff %}Current loans{% else %}Your current loans{% endif %}

{{ loans|length }} active
+{% for loan in loans %}
{{ loan.item.name }}

{{ loan.item.asset_tag }} · borrowed {{ loan.checked_out_at|date:'j M, H:i' }}{% if user.is_staff %} by {{ loan.borrower.username }}{% endif %}

{% csrf_token %}
{% empty %}
Nothing checked out. Choose an available item above.
{% endfor %}
diff --git a/applications/equipment-checkout/board/templates/board/base.html b/applications/equipment-checkout/board/templates/board/base.html new file mode 100644 index 00000000..918ffc1b --- /dev/null +++ b/applications/equipment-checkout/board/templates/board/base.html @@ -0,0 +1,9 @@ +{% load static %} + +Equipment Checkout Board + + +
↗ Equipment room +{% if user.is_authenticated %}{% endif %}
+
{% block content %}{% endblock %}
diff --git a/applications/equipment-checkout/board/templates/board/index.html b/applications/equipment-checkout/board/templates/board/index.html new file mode 100644 index 00000000..21853d72 --- /dev/null +++ b/applications/equipment-checkout/board/templates/board/index.html @@ -0,0 +1 @@ +{% extends 'board/base.html' %}{% block content %}{% include 'board/_board.html' %}{% endblock %} diff --git a/applications/equipment-checkout/board/templates/registration/login.html b/applications/equipment-checkout/board/templates/registration/login.html new file mode 100644 index 00000000..312127e0 --- /dev/null +++ b/applications/equipment-checkout/board/templates/registration/login.html @@ -0,0 +1 @@ +{% extends 'board/base.html' %}{% block content %}

WELCOME TO THE EQUIPMENT ROOM

Your next project starts here.

Sign in to borrow shared equipment and track your returns.

{% csrf_token %}{{ form.as_p }}{% if next %}{% endif %}
{% endblock %} diff --git a/applications/equipment-checkout/board/views.py b/applications/equipment-checkout/board/views.py new file mode 100644 index 00000000..79c8cf3a --- /dev/null +++ b/applications/equipment-checkout/board/views.py @@ -0,0 +1,57 @@ +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.db import DatabaseError +from django.db.models import Exists, OuterRef +from django.shortcuts import redirect, render +from django.http import HttpResponse +from django.views.decorators.http import require_GET, require_POST +from .models import Item, Loan +from .services import borrow_item, complete_return, Unavailable + + +def render_board(request, notice="", status=200): + active = Loan.objects.filter(item=OuterRef("pk"), returned_at__isnull=True) + items = Item.objects.annotate(on_loan=Exists(active)) + loans = Loan.objects.filter(returned_at__isnull=True).select_related("item", "borrower") + if not request.user.is_staff: + loans = loans.filter(borrower=request.user) + template = "board/_board.html" if request.headers.get("HX-Request") == "true" else "board/index.html" + response = render(request, template, {"items": items, "loans": loans, "notice": notice}, status=status) + response["Cache-Control"] = "no-store" + response["Vary"] = "HX-Request, Cookie" + return response + + +@login_required +@require_GET +def board(request): + return render_board(request) + + +def mutation_response(request, notice, status=200): + if request.headers.get("HX-Request") == "true" or status != 200: + return render_board(request, notice, status) + messages.success(request, notice) + return redirect("board") + + +@login_required +@require_POST +def borrow(request, item_id): + try: + loan, created = borrow_item(item_id, request.user) + except Unavailable as error: + return mutation_response(request, str(error), 409) + except DatabaseError: + return HttpResponse('

The database is busy. Refresh and try again.

', status=503) + return mutation_response(request, f"Borrowed {loan.item.name}." if created else "You already have this item.") + + +@login_required +@require_POST +def return_loan(request, loan_id): + try: + loan = complete_return(loan_id, request.user) + except DatabaseError: + return HttpResponse('

The database is busy. Refresh and try again.

', status=503) + return mutation_response(request, f"Returned {loan.item.name}.") diff --git a/applications/equipment-checkout/config/__init__.py b/applications/equipment-checkout/config/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/applications/equipment-checkout/config/settings.py b/applications/equipment-checkout/config/settings.py new file mode 100644 index 00000000..56651310 --- /dev/null +++ b/applications/equipment-checkout/config/settings.py @@ -0,0 +1,50 @@ +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent +SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] +DEBUG = os.environ.get("DJANGO_DEBUG", "0") == "1" +ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") +INSTALLED_APPS = ["django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", + "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "board"] +MIDDLEWARE = ["django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware"] +ROOT_URLCONF = "config.urls" +TEMPLATES = [{"BACKEND": "django.template.backends.django.DjangoTemplates", "APP_DIRS": True, + "OPTIONS": {"context_processors": ["django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages"]}}] +WSGI_APPLICATION = "config.wsgi.application" +DATABASES = {"default": { + "ENGINE": "django.db.backends.postgresql", "NAME": os.environ.get("PGDATABASE", "postgres"), + "USER": os.environ["PGUSER"], "PASSWORD": os.environ["PGPASSWORD"], + "HOST": os.environ["PGHOST"], "PORT": os.environ.get("PGPORT", "5432"), + "CONN_MAX_AGE": 0, + "OPTIONS": {"sslmode": "verify-full", "sslrootcert": os.environ["PGSSLROOTCERT"], + "connect_timeout": 10, "options": "-c search_path=equipment,public -c statement_timeout=10000 -c lock_timeout=5000"}, +}} +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] +LANGUAGE_CODE = "en-gb" +TIME_ZONE = "UTC" +USE_TZ = True +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +LOGIN_URL = "login" +LOGIN_REDIRECT_URL = "board" +LOGOUT_REDIRECT_URL = "login" +# Set secure cookies behind HTTPS; local HTTP development must opt out explicitly. +SESSION_COOKIE_SECURE = os.environ.get("DJANGO_COOKIE_SECURE", "1") == "1" +CSRF_COOKIE_SECURE = SESSION_COOKIE_SECURE +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SAMESITE = "Lax" +CSRF_COOKIE_SAMESITE = "Lax" +SECURE_CONTENT_TYPE_NOSNIFF = True +X_FRAME_OPTIONS = "DENY" +DATA_UPLOAD_MAX_MEMORY_SIZE = 16384 diff --git a/applications/equipment-checkout/config/urls.py b/applications/equipment-checkout/config/urls.py new file mode 100644 index 00000000..608eeb8e --- /dev/null +++ b/applications/equipment-checkout/config/urls.py @@ -0,0 +1,13 @@ +from django.contrib import admin +from django.contrib.auth import views as auth_views +from django.urls import path +from board import views + +urlpatterns = [ + path("admin/", admin.site.urls), + path("accounts/login/", auth_views.LoginView.as_view(), name="login"), + path("accounts/logout/", auth_views.LogoutView.as_view(), name="logout"), + path("", views.board, name="board"), + path("items//borrow/", views.borrow, name="borrow"), + path("loans//return/", views.return_loan, name="return_loan"), +] diff --git a/applications/equipment-checkout/config/wsgi.py b/applications/equipment-checkout/config/wsgi.py new file mode 100644 index 00000000..72126a55 --- /dev/null +++ b/applications/equipment-checkout/config/wsgi.py @@ -0,0 +1,4 @@ +import os +from django.core.wsgi import get_wsgi_application +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") +application = get_wsgi_application() diff --git a/applications/equipment-checkout/manage.py b/applications/equipment-checkout/manage.py new file mode 100644 index 00000000..60bebe87 --- /dev/null +++ b/applications/equipment-checkout/manage.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +import os +import sys +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") +from django.core.management import execute_from_command_line +execute_from_command_line(sys.argv) diff --git a/applications/equipment-checkout/requirements-dev.in b/applications/equipment-checkout/requirements-dev.in new file mode 100644 index 00000000..af479544 --- /dev/null +++ b/applications/equipment-checkout/requirements-dev.in @@ -0,0 +1,3 @@ +-r requirements.in +requests==2.32.5 +playwright==1.56.0 diff --git a/applications/equipment-checkout/requirements-dev.txt b/applications/equipment-checkout/requirements-dev.txt new file mode 100644 index 00000000..92b9c849 --- /dev/null +++ b/applications/equipment-checkout/requirements-dev.txt @@ -0,0 +1,16 @@ +asgiref==3.12.1 +certifi==2026.7.22 +charset-normalizer==3.5.2 +Django==5.2.17 +greenlet==3.5.6 +gunicorn==23.0.0 +idna==3.20 +packaging==26.3 +playwright==1.56.0 +psycopg==3.3.6 +psycopg-binary==3.3.6 +pyee==13.0.1 +requests==2.32.5 +sqlparse==0.6.0 +typing_extensions==4.16.0 +urllib3==2.8.0 diff --git a/applications/equipment-checkout/requirements.in b/applications/equipment-checkout/requirements.in new file mode 100644 index 00000000..c41be2e1 --- /dev/null +++ b/applications/equipment-checkout/requirements.in @@ -0,0 +1,3 @@ +Django==5.2.17 +psycopg[binary]==3.3.6 +gunicorn==23.0.0 diff --git a/applications/equipment-checkout/requirements.txt b/applications/equipment-checkout/requirements.txt new file mode 100644 index 00000000..32efd96c --- /dev/null +++ b/applications/equipment-checkout/requirements.txt @@ -0,0 +1,8 @@ +asgiref==3.12.1 +Django==5.2.17 +gunicorn==23.0.0 +packaging==26.3 +psycopg==3.3.6 +psycopg-binary==3.3.6 +sqlparse==0.6.0 +typing_extensions==4.16.0 diff --git a/applications/equipment-checkout/sql/bootstrap.sql b/applications/equipment-checkout/sql/bootstrap.sql new file mode 100644 index 00000000..a8601242 --- /dev/null +++ b/applications/equipment-checkout/sql/bootstrap.sql @@ -0,0 +1,10 @@ +\set ON_ERROR_STOP on +\getenv migrator_password EQUIPMENT_MIGRATOR_PASSWORD +\getenv app_password EQUIPMENT_APP_PASSWORD +CREATE ROLE equipment_migrator LOGIN PASSWORD :'migrator_password' NOSUPERUSER NOCREATEDB NOCREATEROLE; +CREATE ROLE equipment_app LOGIN PASSWORD :'app_password' NOSUPERUSER NOCREATEDB NOCREATEROLE; +CREATE SCHEMA equipment AUTHORIZATION equipment_migrator; +REVOKE ALL ON SCHEMA equipment FROM PUBLIC; +GRANT USAGE ON SCHEMA equipment TO equipment_app; +ALTER ROLE equipment_migrator SET search_path = equipment, public; +ALTER ROLE equipment_app SET search_path = equipment, public; diff --git a/applications/equipment-checkout/sql/cleanup.sql b/applications/equipment-checkout/sql/cleanup.sql new file mode 100644 index 00000000..3c3a4fd6 --- /dev/null +++ b/applications/equipment-checkout/sql/cleanup.sql @@ -0,0 +1,5 @@ +\set ON_ERROR_STOP on +-- Destructive: run explicitly as service administrator after stopping the app. +DROP SCHEMA equipment CASCADE; +DROP ROLE equipment_app; +DROP ROLE equipment_migrator; diff --git a/applications/equipment-checkout/sql/grants.sql b/applications/equipment-checkout/sql/grants.sql new file mode 100644 index 00000000..1c04ff1d --- /dev/null +++ b/applications/equipment-checkout/sql/grants.sql @@ -0,0 +1,6 @@ +\set ON_ERROR_STOP on +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA equipment TO equipment_app; +REVOKE ALL ON equipment.django_migrations FROM equipment_app; +GRANT SELECT ON equipment.django_migrations TO equipment_app; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA equipment TO equipment_app; +-- Re-run after every migration that creates tables or sequences. diff --git a/applications/equipment-checkout/tests/browser.py b/applications/equipment-checkout/tests/browser.py new file mode 100644 index 00000000..d1fbfadb --- /dev/null +++ b/applications/equipment-checkout/tests/browser.py @@ -0,0 +1,31 @@ +"""Browser smoke test against the seeded local HTTP app. Run inside Linux.""" +import os +from pathlib import Path +from playwright.sync_api import sync_playwright, expect + +with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page(viewport={"width": 1440, "height": 1100}) + errors = [] + page.on("pageerror", lambda error: errors.append(str(error))) + page.goto("http://127.0.0.1:8000/accounts/login/") + page.get_by_label("Username").fill("alex") + page.get_by_label("Password").fill(os.environ["DEMO_PASSWORD"]) + page.get_by_role("button", name="Sign in").click() + expect(page.get_by_role("heading", name="Good tools, ready to go.")).to_be_visible() + page.wait_for_function("typeof htmx !== 'undefined'") + camera = page.locator("article").filter(has=page.get_by_role("heading", name="Mirrorless camera")) + camera.get_by_role("button", name="Borrow item").click() + expect(page.get_by_role("status")).to_contain_text("Borrowed Mirrorless camera") + expect(page.locator(".loan").filter(has_text="Mirrorless camera")).to_be_visible() + page.locator(".loan").filter(has_text="Mirrorless camera").get_by_role("button", name="Return item").click() + expect(page.get_by_role("status")).to_contain_text("Returned Mirrorless camera") + expect(camera.get_by_role("button", name="Borrow item")).to_be_visible() + Path(".deployment").mkdir(exist_ok=True) + page.screenshot(path=".deployment/board-desktop.png", full_page=True) + page.set_viewport_size({"width": 390, "height": 844}) + page.screenshot(path=".deployment/board-mobile.png", full_page=True) + assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth"), "mobile overflow" + assert not errors, errors + browser.close() + print("Browser login, htmx borrow/return, desktop/mobile checks passed.") diff --git a/applications/equipment-checkout/tests/integration.py b/applications/equipment-checkout/tests/integration.py new file mode 100644 index 00000000..18c77e5b --- /dev/null +++ b/applications/equipment-checkout/tests/integration.py @@ -0,0 +1,184 @@ +"""Destructive fixture tests: use only a dedicated, migrated example service.""" +import concurrent.futures +import os +import re +import threading +import time +import unittest +import uuid +from datetime import timedelta + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") +import django +django.setup() +import psycopg +import requests +from django.contrib.auth import get_user_model +from django.db import IntegrityError, connection, transaction +from django.utils import timezone +from board.models import Item, Loan + +BASE = os.environ.get("TEST_BASE_URL", "http://127.0.0.1:8000") +PASSWORD = "FixturePass-" + uuid.uuid4().hex + + +def login(username): + session = requests.Session() + page = session.get(BASE + "/accounts/login/", timeout=15) + token = re.search(r'name="csrfmiddlewaretoken" value="([^"]+)"', page.text).group(1) + result = session.post(BASE + "/accounts/login/", data={"username": username, "password": PASSWORD, + "csrfmiddlewaretoken": token}, timeout=15, allow_redirects=False) + assert result.status_code == 302, result.status_code + return session + + +def post(session, path, **extra): + return session.post(BASE + path, data={"csrfmiddlewaretoken": session.cookies["csrftoken"], **extra}, + headers={"HX-Request": "true"}, timeout=20, allow_redirects=False) + + +class Acceptance(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.prefix = "test-" + uuid.uuid4().hex[:10] + cls.users = [get_user_model().objects.create_user(cls.prefix + str(i), password=PASSWORD, + is_staff=(i == 2), is_superuser=(i == 2)) for i in range(3)] + cls.sessions = [login(user.username) for user in cls.users] + + @classmethod + def tearDownClass(cls): + Loan.objects.filter(item__asset_tag__startswith=cls.prefix).delete() + Item.objects.filter(asset_tag__startswith=cls.prefix).delete() + for user in cls.users: + user.delete() + connection.close() + + def setUp(self): + self.item = Item.objects.create(asset_tag=self.prefix + uuid.uuid4().hex[:8], name="Fixture camera") + + def borrow(self, who=0): + return post(self.sessions[who], f"/items/{self.item.pk}/borrow/") + + def test_authentication_and_methods(self): + self.assertEqual(requests.get(BASE + "/", allow_redirects=False, timeout=15).status_code, 302) + self.assertEqual(self.sessions[0].get(BASE + f"/items/{self.item.pk}/borrow/", timeout=15).status_code, 405) + + def test_csrf_rejection(self): + result = self.sessions[0].post(BASE + f"/items/{self.item.pk}/borrow/", timeout=15) + self.assertEqual(result.status_code, 403) + self.assertFalse(Loan.objects.filter(item=self.item).exists()) + + def test_identity_is_server_owned(self): + result = post(self.sessions[0], f"/items/{self.item.pk}/borrow/", borrower=self.users[1].pk) + self.assertEqual(result.status_code, 200) + self.assertEqual(Loan.objects.get(item=self.item).borrower_id, self.users[0].pk) + + def test_concurrent_checkout(self): + barrier = threading.Barrier(2) + def checkout(who): + barrier.wait() + return self.borrow(who).status_code + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(checkout, [0, 1])) + self.assertEqual(sorted(results), [200, 409]) + self.assertEqual(Loan.objects.filter(item=self.item, returned_at__isnull=True).count(), 1) + + def test_inventory_disable_serializes_with_checkout(self): + # ItemAdmin uses this same lock inside its atomic save transaction. + started = threading.Event() + def checkout(): + started.set() + return self.borrow().status_code + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + with transaction.atomic(): + item = Item.objects.select_for_update().get(pk=self.item.pk) + future = pool.submit(checkout) + self.assertTrue(started.wait(timeout=5)) + time.sleep(0.5) + self.assertFalse(future.done(), "checkout should wait for inventory update") + item.enabled = False + item.save(update_fields=["enabled"]) + self.assertEqual(future.result(timeout=20), 409) + self.assertFalse(Loan.objects.filter(item=self.item).exists()) + + def test_database_constraint_without_application_lock(self): + Loan.objects.create(item=self.item, borrower=self.users[0]) + with self.assertRaises(IntegrityError), transaction.atomic(): + Loan.objects.create(item=self.item, borrower=self.users[1]) + + def test_return_constraint(self): + with self.assertRaises(IntegrityError), transaction.atomic(): + Loan.objects.create(item=self.item, borrower=self.users[0], returned_at=timezone.now() - timedelta(days=1)) + + def test_owner_return_and_idempotence(self): + self.assertEqual(self.borrow().status_code, 200) + self.assertEqual(self.borrow().status_code, 200) + loan = Loan.objects.get(item=self.item) + path = f"/loans/{loan.pk}/return/" + self.assertEqual(post(self.sessions[1], path).status_code, 404) + loan.refresh_from_db() + self.assertIsNone(loan.returned_at) + self.assertEqual(post(self.sessions[0], path).status_code, 200) + loan.refresh_from_db() + returned = loan.returned_at + self.assertEqual(post(self.sessions[0], path).status_code, 200) + loan.refresh_from_db() + self.assertEqual(loan.returned_at, returned) + self.assertEqual(self.borrow(1).status_code, 200) + self.assertEqual(Loan.objects.filter(item=self.item).count(), 2) + + def test_staff_return_and_private_listing(self): + self.borrow() + loan = Loan.objects.get(item=self.item) + self.assertNotIn(f'data-loan="{loan.pk}"', self.sessions[1].get(BASE + "/", timeout=15).text) + self.assertIn(f'data-loan="{loan.pk}"', self.sessions[2].get(BASE + "/", timeout=15).text) + self.assertEqual(post(self.sessions[2], f"/loans/{loan.pk}/return/").status_code, 200) + + def test_disabled_and_missing_items(self): + self.item.enabled = False + self.item.save() + self.assertEqual(self.borrow().status_code, 409) + self.assertEqual(post(self.sessions[0], "/items/999999999/borrow/").status_code, 404) + self.assertEqual(post(self.sessions[0], "/items/nope/borrow/").status_code, 404) + + def test_admin_authorization_and_validation(self): + self.assertEqual(self.sessions[0].get(BASE + "/admin/board/item/add/", allow_redirects=False, timeout=15).status_code, 302) + page = self.sessions[2].get(BASE + "/admin/board/item/add/", timeout=15) + token = re.search(r'name="csrfmiddlewaretoken" value="([^"]+)"', page.text).group(1) + result = self.sessions[2].post(BASE + "/admin/board/item/add/", data={"csrfmiddlewaretoken": token, + "asset_tag": "", "name": "", "_save": "Save"}, timeout=15) + self.assertEqual(result.status_code, 200) + self.assertIn("This field is required", result.text) + result = self.sessions[2].post(BASE + "/admin/board/item/add/", data={"csrfmiddlewaretoken": token, + "asset_tag": self.prefix + "admin", "name": "Staff-added kit", "enabled": "on", "_save": "Save"}, timeout=15, allow_redirects=False) + self.assertEqual(result.status_code, 302) + self.assertTrue(Item.objects.filter(asset_tag=self.prefix + "admin").exists()) + + def test_runtime_role_cannot_change_schema(self): + with self.assertRaises(django.db.ProgrammingError), transaction.atomic(): + with connection.cursor() as cur: + cur.execute("CREATE TABLE equipment.must_not_exist (id integer)") + with self.assertRaises(django.db.ProgrammingError), transaction.atomic(): + with connection.cursor() as cur: + cur.execute("UPDATE django_migrations SET name = name") + + def test_tls_chain_and_hostname_verification(self): + with connection.cursor() as cur: + cur.execute("SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid()") + self.assertTrue(cur.fetchone()[0]) + params = dict(host=os.environ["PGHOST"], port=os.environ.get("PGPORT", "5432"), + dbname=os.environ.get("PGDATABASE", "postgres"), user=os.environ["PGUSER"], + password=os.environ["PGPASSWORD"], sslmode="verify-full", connect_timeout=10) + with self.assertRaises(psycopg.OperationalError) as wrong_ca: + psycopg.connect(**params, sslrootcert="/etc/ssl/certs/ca-certificates.crt") + self.assertIn("certificate verify failed", str(wrong_ca.exception).lower()) + import socket + params["hostaddr"] = socket.gethostbyname(params["host"]) + params["host"] = "wrong-hostname.example.invalid" + with self.assertRaises(psycopg.OperationalError) as wrong_host: + psycopg.connect(**params, sslrootcert=os.environ["PGSSLROOTCERT"]) + self.assertIn("does not match host name", str(wrong_host.exception).lower()) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/applications/equipment-checkout/tests/persistence.py b/applications/equipment-checkout/tests/persistence.py new file mode 100644 index 00000000..a0835c52 --- /dev/null +++ b/applications/equipment-checkout/tests/persistence.py @@ -0,0 +1,34 @@ +import os,json,re,sys +from pathlib import Path +import requests +os.environ.setdefault('DJANGO_SETTINGS_MODULE','config.settings') +import django +django.setup() +from board.models import Item,Loan +from django.contrib.auth import get_user_model +p=Path('.deployment/persistence.json') +s=requests.Session();base='http://127.0.0.1:8000' +if sys.argv[1]=='before': + page=s.get(base+'/accounts/login/',timeout=20) + token=re.search(r'name="csrfmiddlewaretoken" value="([^"]+)"',page.text).group(1) + response=s.post(base+'/accounts/login/',data={'username':'alex','password':os.environ['DEMO_PASSWORD'],'csrfmiddlewaretoken':token},timeout=20,allow_redirects=False) + assert response.status_code==302 + item=Item.objects.get(asset_tag='MIC-01') + response=s.post(base+f'/items/{item.pk}/borrow/',data={'csrfmiddlewaretoken':s.cookies['csrftoken']},headers={'HX-Request':'true'},timeout=20) + assert response.status_code==200 + loan=Loan.objects.get(item=item,returned_at__isnull=True) + p.write_text(json.dumps({'cookies':s.cookies.get_dict(),'loan':loan.pk,'checked_out_at':loan.checked_out_at.isoformat()}));p.chmod(0o600) + print('Before restart: authenticated session and active microphone loan recorded.') +else: + state=json.loads(p.read_text()) + for name,value in state['cookies'].items(): + s.cookies.set(name,value,domain='127.0.0.1',path='/') + response=s.get(base+'/',timeout=20,allow_redirects=False) + assert response.status_code==200 + assert f'data-loan="{state["loan"]}"' in response.text + loan=Loan.objects.get(pk=state['loan']) + assert loan.checked_out_at.isoformat()==state['checked_out_at'] and loan.returned_at is None + response=s.post(base+f'/loans/{loan.pk}/return/',data={'csrfmiddlewaretoken':s.cookies['csrftoken']},headers={'HX-Request':'true'},timeout=20) + assert response.status_code==200 + p.unlink() + print('After process restart: same session authorized, same loan/timestamp persisted; return succeeded.')