Files
shopdb-flask/tests/test_smoke.py
cproudlock 2d1bb83c3b Phase 1: pytest baseline, production hardening, pinned requirements
Establishes the safety net required before any structural refactor.

Tests (tests/):
- conftest.py rewritten for Flask-SQLAlchemy 3.x (drop-recreate per
  test, StaticPool-shared in-memory SQLite, admin_user + auth_headers
  fixtures). Removes deprecated db.create_scoped_session pattern.
- test_smoke.py: 8 baseline tests (app boot, JWT login valid+invalid,
  protected routes, paginated response shape, plugin auto-discovery).
- test_security_config.py: 7 tests pinning ProductionConfig.validate
  failure modes (missing/dev SECRET_KEY, missing JWT_SECRET_KEY,
  missing DATABASE_URL, wildcard CORS, empty CORS) and one happy-path.

Production hardening (shopdb/config.py, shopdb/__init__.py):
- ProductionConfig.validate() raises ConfigError on missing or
  insecure SECRET_KEY, JWT_SECRET_KEY, DATABASE_URL, CORS_ORIGINS.
  No silent fallback to dev defaults in production.
- create_app invokes validate() when config_name == 'production'.
- CORS_ORIGINS default no longer wildcard; defaults to localhost
  Vite dev origin.
- Drop os.path.exists probe in serve_frontend (path-traversal risk
  surface). send_from_directory handles safe-join + 404 itself.
- Replace User.query.get with db.session.get (SQLAlchemy 2.0 API).

TestingConfig (shopdb/config.py):
- Add StaticPool + check_same_thread connect_args so SQLite in-memory
  is shared across the test session.

Index dedup (plugins/printers/models/printer_extension.py):
- Rename idx_printer_windowsname -> idx_printerdata_windowsname.
  Two model classes (Printer, PrinterData) declared the same index
  name; SQLite enforces global index uniqueness even across tables.
  Per CONTRIBUTING.md naming convention, indexes follow
  idx_<table>_<column>.

Dependency pinning (requirements.in, requirements.txt):
- requirements.in holds the loose source pins (the human-edited file).
- requirements.txt is now a uv-compiled lockfile (every transitive
  dep pinned to an exact version). Reproducible builds. Run
  `uv pip compile requirements.in -o requirements.txt` to refresh.

Test count: 0 -> 15 passing. All naming/style checks still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:48:19 -04:00

111 lines
3.7 KiB
Python

"""Smoke tests pinning the baseline behavior of shopdb-flask.
These eight tests are the safety net required before any structural
refactor proceeds. See `~/.claude/skills/pinning-flask-behavior.md`.
"""
import pytest
def test_app_factory_creates_app(app):
"""create_app('testing') returns a Flask app with TESTING=True."""
assert app is not None
assert app.config['TESTING'] is True
assert 'sqlite' in app.config['SQLALCHEMY_DATABASE_URI']
def test_login_with_valid_credentials_returns_tokens(client, admin_user):
"""POST /api/auth/login with valid creds returns access and refresh tokens."""
response = client.post(
'/api/auth/login',
json={'username': 'testadmin', 'password': 'testpass'},
)
assert response.status_code == 200
payload = response.get_json()
assert 'data' in payload
data = payload['data']
assert 'access_token' in data
assert 'refresh_token' in data
assert 'user' in data
assert data['user']['username'] == 'testadmin'
def test_login_with_invalid_credentials_returns_401(client, admin_user):
"""Wrong password returns 401 with the documented error envelope.
Pins the current shape: error info nested under `data.error` (not at
top level). The error_response docstring claims top-level `error` but
the implementation puts it under `data`. Pinned as-is until that
inconsistency is intentionally addressed.
"""
response = client.post(
'/api/auth/login',
json={'username': 'testadmin', 'password': 'wrongpassword'},
)
assert response.status_code == 401
payload = response.get_json()
assert payload['status'] == 'error'
assert payload['data']['error']['code'] == 'UNAUTHORIZED'
def test_login_with_missing_fields_returns_400(client):
"""Missing username or password returns 400 validation error."""
response = client.post('/api/auth/login', json={})
assert response.status_code == 400
def test_protected_route_requires_authentication(client, admin_user):
"""GET /api/users without a JWT returns 401."""
response = client.get('/api/users')
assert response.status_code == 401
def test_protected_route_works_with_jwt(client, auth_headers):
"""GET /api/users with a valid JWT returns 200."""
response = client.get('/api/users', headers=auth_headers)
assert response.status_code == 200
def test_paginated_response_shape(client, auth_headers):
"""A paginated list endpoint returns data plus pagination meta.
Uses /api/locations because it is a simple platform endpoint that
uses paginated_response. Pagination meta keys follow the naming
convention (lowercase concatenated): page, perpage, total,
totalpages, hasnext, hasprev.
"""
response = client.get('/api/locations', headers=auth_headers)
assert response.status_code == 200
payload = response.get_json()
assert 'data' in payload
assert isinstance(payload['data'], list)
assert 'meta' in payload
assert 'pagination' in payload['meta']
pagination = payload['meta']['pagination']
assert 'page' in pagination
assert 'perpage' in pagination
assert 'total' in pagination
assert 'totalpages' in pagination
def test_plugin_loader_discovers_bundled_plugins(app):
"""Plugin manager finds the six bundled plugins."""
from shopdb.plugins import plugin_manager
expected_plugins = {
'computers',
'equipment',
'network',
'notifications',
'printers',
'usb',
}
with app.app_context():
loader = plugin_manager.loader
discovered = set(loader.discover_plugins())
assert expected_plugins.issubset(discovered), (
f'Missing bundled plugins: {expected_plugins - discovered}'
)