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>
This commit is contained in:
110
tests/test_smoke.py
Normal file
110
tests/test_smoke.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""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}'
|
||||
)
|
||||
Reference in New Issue
Block a user