mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-24 20:18:06 +09:00
`GET /databases/engines/<app_id>/extensions` carried only `@jwt_required()`, so any signed-in user could enumerate any instance on the panel — the image it runs, its databases, and what it can load. All facts about someone else's application. It gates on `can_access_app` now, mirroring the per-app docker DB read next to it: owner, admin, or an explicit grant. Read access rather than admin-only is deliberate — a developer who can already see an app should be able to see what that app can load. The POST was already admin-gated and is unchanged. The file also declares its `# Bucket: PER-APP` header (plan 29 #9), which is what the structural sweeps read to know a per-app route file has been thought about at all. Found by the repo's own authz sweeps in a full-suite run — four failures, one cause. Worth noting they fired on a route that has no feature test capable of catching this: every functional test authenticates as someone entitled to the data. That is precisely what a structural sweep is for. Four tests pin the behaviour: denied without access, still visible to the owner, 404 rather than 403 for an app that does not exist (conflating them would make the endpoint useless to its own caller), and 401 anonymous. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
813 lines
39 KiB
Python
813 lines
39 KiB
Python
"""Database engine extensions — installed INTO a running engine, not beside it.
|
|
|
|
Sibling of ``test_database_engines.py``: engines are app templates, extensions
|
|
are not (they get no container), so they get their own file rather than a
|
|
section of that one.
|
|
|
|
Two tests carry the design:
|
|
|
|
* ``test_dropping_a_new_extension_yaml_lists_it`` — the same zero-code-change
|
|
property the engine catalog has, for extensions.
|
|
* ``TestTheImageProblem`` — ``CREATE EXTENSION vector`` is a *correct*
|
|
statement that fails on stock ``postgres:16``. Nothing here may reach the
|
|
engine without first establishing that the image can load the extension.
|
|
|
|
No Docker: every executor is stubbed, and the stubs record exactly which
|
|
statements would have been run so "it never executed the statement" is a real
|
|
assertion rather than a hope.
|
|
"""
|
|
import json
|
|
import os
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from app import db
|
|
from app.models import Application, User
|
|
from app.services import database_engine_extension_service as ext
|
|
from app.services.template_service import TemplateService
|
|
|
|
EXTENSIONS_DIR = os.path.join(TemplateService.LOCAL_TEMPLATES_DIR,
|
|
TemplateService.EXTENSIONS_SUBDIR)
|
|
TEMPLATES_DIR = TemplateService.LOCAL_TEMPLATES_DIR
|
|
|
|
# A complete extension, written into backend/templates/extensions/ by a fixture.
|
|
NEW_EXTENSION_YAML = """\
|
|
id: probe_ext
|
|
name: ProbeExt
|
|
version: "3.1"
|
|
description: An extension that exists only for this test
|
|
extension:
|
|
protocol: postgresql
|
|
statement: CREATE EXTENSION IF NOT EXISTS probe_ext;
|
|
available_query: SELECT 1 FROM pg_available_extensions WHERE name = 'probe_ext';
|
|
installed_query: SELECT extversion FROM pg_extension WHERE extname = 'probe_ext';
|
|
images:
|
|
- probe/postgres-probe
|
|
image_hint: probe/postgres-probe:pg{version}
|
|
size: 1 MB
|
|
unit: probes
|
|
versions:
|
|
- "3.1"
|
|
- "3.0"
|
|
"""
|
|
|
|
# An engine template whose image ships the extension above — proving the
|
|
# "install this instead" remedy is discovered, not hardcoded.
|
|
CAPABLE_ENGINE_YAML = """\
|
|
id: probe-capable-pg
|
|
name: Probe-capable PostgreSQL
|
|
version: "16"
|
|
description: PostgreSQL on an image that ships ProbeExt
|
|
icon: https://serverkit.ai/imgs/template-icons/postgresql.svg
|
|
categories:
|
|
- database
|
|
engine:
|
|
family: Relational
|
|
protocol: postgresql
|
|
default_port: 55432
|
|
admin_user: postgres
|
|
admin_password_var: DB_PASSWORD
|
|
port_var: PORT
|
|
bind_var: BIND_ADDRESS
|
|
unit: tables
|
|
client: psql
|
|
data_path: /var/lib/postgresql/data
|
|
extensions: true
|
|
version_var: IMAGE_TAG
|
|
versions:
|
|
- "16"
|
|
variables:
|
|
- name: IMAGE_TAG
|
|
type: string
|
|
default: "16"
|
|
hidden: true
|
|
- name: PORT
|
|
type: port
|
|
default: "55432"
|
|
- name: BIND_ADDRESS
|
|
type: string
|
|
default: 127.0.0.1
|
|
hidden: true
|
|
- name: DB_PASSWORD
|
|
type: password
|
|
length: 28
|
|
compose:
|
|
services:
|
|
app:
|
|
image: probe/postgres-probe:pg${IMAGE_TAG}
|
|
container_name: ${APP_NAME}
|
|
restart: unless-stopped
|
|
ports:
|
|
- "${BIND_ADDRESS}:${PORT}:5432"
|
|
environment:
|
|
POSTGRES_PASSWORD: "${DB_PASSWORD}"
|
|
POSTGRES_USER: postgres
|
|
volumes:
|
|
- probe-capable-data:/var/lib/postgresql/data
|
|
volumes:
|
|
probe-capable-data:
|
|
"""
|
|
|
|
|
|
# ── fixtures ─────────────────────────────────────────────────────────────────
|
|
@pytest.fixture(autouse=True)
|
|
def offline_templates(monkeypatch):
|
|
"""Hermetic: no remote repos, no `docker ps`, no `docker inspect`."""
|
|
monkeypatch.setattr(TemplateService, 'get_config',
|
|
classmethod(lambda cls: {'repos': [], 'installed': {}}))
|
|
monkeypatch.setattr(TemplateService, '_get_docker_used_ports',
|
|
classmethod(lambda cls: set()))
|
|
monkeypatch.setattr(TemplateService, '_managed_app_base_port',
|
|
classmethod(lambda cls: 0))
|
|
|
|
|
|
@pytest.fixture
|
|
def new_extension_yaml():
|
|
"""Drop a brand-new extension into the extensions directory, then remove it.
|
|
|
|
No registration, no import, no code change — the whole design.
|
|
"""
|
|
path = os.path.join(EXTENSIONS_DIR, 'probe_ext.yaml')
|
|
with open(path, 'w', encoding='utf-8') as fh:
|
|
fh.write(NEW_EXTENSION_YAML)
|
|
try:
|
|
yield 'probe_ext'
|
|
finally:
|
|
if os.path.exists(path):
|
|
os.remove(path)
|
|
|
|
|
|
@pytest.fixture
|
|
def capable_engine_yaml():
|
|
"""An engine template whose image ships ProbeExt."""
|
|
path = os.path.join(TEMPLATES_DIR, 'probe-capable-pg.yaml')
|
|
with open(path, 'w', encoding='utf-8') as fh:
|
|
fh.write(CAPABLE_ENGINE_YAML)
|
|
try:
|
|
yield 'probe-capable-pg'
|
|
finally:
|
|
if os.path.exists(path):
|
|
os.remove(path)
|
|
|
|
|
|
@pytest.fixture
|
|
def viewer_headers(app):
|
|
"""A logged-in NON-admin, for the admin-only checks."""
|
|
from flask_jwt_extended import create_access_token
|
|
from werkzeug.security import generate_password_hash
|
|
|
|
user = User(email='extviewer@test.local', username='extviewer',
|
|
password_hash=generate_password_hash('x'),
|
|
role=User.ROLE_DEVELOPER, is_active=True)
|
|
db.session.add(user)
|
|
db.session.commit()
|
|
return {'Authorization': f'Bearer {create_access_token(identity=user.id)}'}
|
|
|
|
|
|
def _install_engine(tmp_path, monkeypatch, *, template_id='postgresql',
|
|
name='pg-main', image='postgres:16-alpine', variables=None):
|
|
"""An Application that looks exactly like a finished engine install.
|
|
|
|
The rendered compose is written for real, because that file is where the
|
|
image an instance runs is read from.
|
|
"""
|
|
root = tmp_path / name
|
|
root.mkdir()
|
|
(root / '.serverkit-template.json').write_text(json.dumps({
|
|
'template_id': template_id,
|
|
'template_version': '16',
|
|
'variables': variables or {'PORT': '5433', 'BIND_ADDRESS': '127.0.0.1',
|
|
'IMAGE_TAG': '16', 'DB_NAME': 'shop',
|
|
'DB_PASSWORD': 'sup3rs3cret-do-not-leak'},
|
|
}), encoding='utf-8')
|
|
if image:
|
|
(root / 'docker-compose.yml').write_text(yaml.safe_dump({
|
|
'services': {'app': {'image': image, 'container_name': name}},
|
|
}), encoding='utf-8')
|
|
|
|
application = Application(name=name, app_type='docker', status='running',
|
|
user_id=1, port=5433, root_path=str(root))
|
|
db.session.add(application)
|
|
db.session.commit()
|
|
monkeypatch.setattr(TemplateService, 'get_config', classmethod(
|
|
lambda cls: {'repos': [], 'installed': {
|
|
str(application.id): {'template_id': template_id}}}))
|
|
return application
|
|
|
|
|
|
class FakeEngine:
|
|
"""Records every statement, answers the ones a test declares.
|
|
|
|
Defaults to "the extension is not available", which is what a stock
|
|
postgres image really says.
|
|
"""
|
|
|
|
def __init__(self, *, available=False, installed_version='', fail=None):
|
|
self.available = available
|
|
self.installed_version = installed_version
|
|
self.fail = fail
|
|
self.statements = []
|
|
|
|
def __call__(self, context, sql, database):
|
|
self.statements.append((sql, database))
|
|
if self.fail is not None:
|
|
return {'success': False, 'error': self.fail}
|
|
if 'pg_available_extensions' in sql:
|
|
return {'success': True, 'output': '1\n' if self.available else '\n'}
|
|
if 'pg_extension' in sql:
|
|
return {'success': True, 'output': self.installed_version}
|
|
return {'success': True, 'output': ''}
|
|
|
|
@property
|
|
def ran_create(self):
|
|
return any(s.upper().startswith('CREATE EXTENSION') for s, _ in self.statements)
|
|
|
|
|
|
@pytest.fixture
|
|
def engine_client(monkeypatch):
|
|
"""Install a FakeEngine as the postgresql executor and hand it back."""
|
|
def _install(**kwargs):
|
|
fake = FakeEngine(**kwargs)
|
|
monkeypatch.setitem(ext.EXECUTORS, 'postgresql', fake)
|
|
return fake
|
|
return _install
|
|
|
|
|
|
def _post(client, headers, app_id, **body):
|
|
return client.post(f'/api/v1/databases/engines/{app_id}/extensions',
|
|
json=body, headers=headers)
|
|
|
|
|
|
# ── the design proof ─────────────────────────────────────────────────────────
|
|
class TestZeroCodeChangeCatalog:
|
|
|
|
def test_dropping_a_new_extension_yaml_lists_it(self, client, auth_headers,
|
|
new_extension_yaml):
|
|
"""A new extension YAML appears in the catalog with NO code change."""
|
|
response = client.get('/api/v1/databases/engines/extensions',
|
|
headers=auth_headers)
|
|
assert response.status_code == 200
|
|
|
|
entry = next((e for e in response.get_json()['extensions']
|
|
if e['id'] == 'probe_ext'), None)
|
|
assert entry is not None, 'a new extension file must appear with no code change'
|
|
assert entry['name'] == 'ProbeExt'
|
|
assert entry['statement'] == 'CREATE EXTENSION IF NOT EXISTS probe_ext;'
|
|
assert entry['protocol'] == 'postgresql'
|
|
assert entry['images'] == ['probe/postgres-probe']
|
|
assert entry['versions'] == ['3.1', '3.0']
|
|
assert entry['family'] == 'Extension'
|
|
|
|
def test_extension_removed_when_yaml_is_removed(self, client, auth_headers):
|
|
"""The catalog is derived, not cached: no YAML, no entry."""
|
|
data = client.get('/api/v1/databases/engines/extensions',
|
|
headers=auth_headers).get_json()
|
|
assert 'probe_ext' not in {e['id'] for e in data['extensions']}
|
|
|
|
def test_a_new_extension_is_immediately_installable(
|
|
self, client, auth_headers, tmp_path, monkeypatch,
|
|
new_extension_yaml, engine_client):
|
|
"""...and installing it needs no code either."""
|
|
fake = engine_client(available=True, installed_version='3.1')
|
|
application = _install_engine(tmp_path, monkeypatch,
|
|
image='probe/postgres-probe:pg16')
|
|
response = _post(client, auth_headers, application.id,
|
|
extension_id='probe_ext', database='shop')
|
|
assert response.status_code == 201, response.get_json()
|
|
assert fake.ran_create
|
|
assert response.get_json()['extension']['version'] == '3.1'
|
|
|
|
def test_a_new_engine_yaml_becomes_a_host_with_no_code_change(
|
|
self, client, auth_headers, new_extension_yaml, capable_engine_yaml):
|
|
"""Compatibility is matched on the engine's OWN protocol.
|
|
|
|
Neither file names the other, so an engine template dropped in later is
|
|
offered as a host — and as the remedy, because its image ships it.
|
|
"""
|
|
data = client.get('/api/v1/databases/engines/extensions',
|
|
headers=auth_headers).get_json()
|
|
entry = next(e for e in data['extensions'] if e['id'] == 'probe_ext')
|
|
hosts = {h['template_id'] for h in entry['hosts']}
|
|
assert 'postgresql' in hosts # protocol match, image does not ship it
|
|
assert 'probe-capable-pg' in hosts
|
|
assert entry['provided_by'] == ['probe-capable-pg']
|
|
|
|
def test_bundled_extensions_are_the_ones_shipped(self, client, auth_headers):
|
|
data = client.get('/api/v1/databases/engines/extensions',
|
|
headers=auth_headers).get_json()
|
|
assert {'pgvector', 'timescaledb'} <= {e['id'] for e in data['extensions']}
|
|
|
|
def test_extensions_are_not_app_templates(self, app):
|
|
"""They have no container, so they must never reach the app catalog."""
|
|
ids = {t['id'] for t in TemplateService.list_all_templates()}
|
|
assert 'pgvector' not in ids
|
|
assert 'timescaledb' not in ids
|
|
|
|
|
|
# ── the extension block: loader + validator ──────────────────────────────────
|
|
class TestExtensionBlockPlumbing:
|
|
|
|
def test_shipped_extensions_all_validate(self, app):
|
|
for filename in os.listdir(EXTENSIONS_DIR):
|
|
if not filename.endswith(('.yaml', '.yml')):
|
|
continue
|
|
with open(os.path.join(EXTENSIONS_DIR, filename), encoding='utf-8') as fh:
|
|
document = yaml.safe_load(fh)
|
|
result = TemplateService.validate_extension(document)
|
|
assert result['valid'], (filename, result['errors'])
|
|
assert document['id'] == filename.rsplit('.', 1)[0], filename
|
|
|
|
def test_statement_is_required(self, app):
|
|
result = TemplateService.validate_extension({
|
|
'name': 'x', 'version': '1', 'description': 'd',
|
|
'extension': {'protocol': 'postgresql'}})
|
|
assert not result['valid']
|
|
assert any('statement' in e for e in result['errors'])
|
|
|
|
def test_protocol_must_be_a_known_one(self, app):
|
|
result = TemplateService.validate_extension({
|
|
'name': 'x', 'version': '1', 'description': 'd',
|
|
'extension': {'protocol': 'telepathy', 'statement': 'SELECT 1;'}})
|
|
assert not result['valid']
|
|
assert any('telepathy' in e for e in result['errors'])
|
|
|
|
def test_protocol_none_cannot_host_an_extension(self, app):
|
|
"""`protocol: none` is legitimate for an ENGINE and meaningless here."""
|
|
result = TemplateService.validate_extension({
|
|
'name': 'x', 'version': '1', 'description': 'd',
|
|
'extension': {'protocol': 'none', 'statement': 'SELECT 1;'}})
|
|
assert not result['valid']
|
|
|
|
def test_an_extension_may_not_declare_a_container(self, app):
|
|
"""The distinction from an engine, enforced rather than documented."""
|
|
result = TemplateService.validate_extension({
|
|
'name': 'x', 'version': '1', 'description': 'd',
|
|
'extension': {'protocol': 'postgresql', 'statement': 'SELECT 1;'},
|
|
'compose': {'services': {'app': {'image': 'x'}}}})
|
|
assert not result['valid']
|
|
assert any('compose' in e for e in result['errors'])
|
|
|
|
def test_a_broken_file_is_skipped_not_fatal(self, app):
|
|
"""One malformed extension must not take the catalog down with it."""
|
|
path = os.path.join(EXTENSIONS_DIR, 'broken_ext.yaml')
|
|
with open(path, 'w', encoding='utf-8') as fh:
|
|
fh.write('name: Broken\nextension: not-a-mapping\n')
|
|
try:
|
|
ids = {e['id'] for e in TemplateService.list_extension_templates()}
|
|
assert 'broken_ext' not in ids
|
|
assert 'pgvector' in ids
|
|
finally:
|
|
os.remove(path)
|
|
|
|
def test_catalog_schema_documents_the_extension_block(self, client, auth_headers):
|
|
schema = client.get('/api/v1/templates/catalog/schema',
|
|
headers=auth_headers).get_json()
|
|
block = schema['extension_block']
|
|
assert block['family'] == 'Extension'
|
|
assert 'none' not in block['protocols']
|
|
fields = {f['field'] for f in block['fields']}
|
|
assert {'protocol', 'statement', 'images', 'available_query'} <= fields
|
|
|
|
|
|
# ── compatibility ────────────────────────────────────────────────────────────
|
|
class TestCompatibility:
|
|
|
|
def test_extension_is_only_offered_for_engines_that_can_host_it(
|
|
self, client, auth_headers):
|
|
"""pgvector is a PostgreSQL extension; MongoDB may not be offered it."""
|
|
data = client.get('/api/v1/databases/engines/extensions',
|
|
headers=auth_headers).get_json()
|
|
entry = next(e for e in data['extensions'] if e['id'] == 'pgvector')
|
|
hosts = {h['template_id'] for h in entry['hosts']}
|
|
assert 'postgresql' in hosts
|
|
assert 'postgresql-pgvector' in hosts
|
|
assert 'mongodb' not in hosts
|
|
assert 'mysql' not in hosts
|
|
assert 'redis' not in hosts
|
|
|
|
def test_speaking_the_protocol_is_not_enough(self, client, auth_headers):
|
|
"""CockroachDB answers on the PostgreSQL wire and has no extensions.
|
|
|
|
Offering pgvector for it would send an operator to an image that cannot
|
|
exist, so an engine has to declare `engine.extensions: true` -- which
|
|
keeps CockroachDB out without anything in Python naming it.
|
|
"""
|
|
data = client.get('/api/v1/databases/engines', headers=auth_headers).get_json()
|
|
cockroach = next(e for e in data['catalog'] if e['id'] == 'cockroachdb')
|
|
assert cockroach['engine']['protocol'] == 'postgresql'
|
|
assert cockroach['engine']['supports_extensions'] is False
|
|
|
|
entry = next(e for e in data['extensions'] if e['id'] == 'pgvector')
|
|
assert 'cockroachdb' not in {h['template_id'] for h in entry['hosts']}
|
|
|
|
def test_installing_into_an_engine_without_extension_support_is_refused(
|
|
self, client, auth_headers, tmp_path, monkeypatch, engine_client):
|
|
fake = engine_client(available=True)
|
|
application = _install_engine(tmp_path, monkeypatch, template_id='cockroachdb',
|
|
name='crdb', image='cockroachdb/cockroach:latest-v24.1')
|
|
response = _post(client, auth_headers, application.id, extension_id='pgvector')
|
|
assert response.status_code == 400
|
|
assert 'does not support database extensions' in response.get_json()['error']
|
|
assert not fake.statements
|
|
|
|
def test_installing_into_the_wrong_engine_is_refused(
|
|
self, client, auth_headers, tmp_path, monkeypatch, engine_client):
|
|
fake = engine_client(available=True)
|
|
application = _install_engine(tmp_path, monkeypatch, template_id='mongodb',
|
|
name='mongo-main', image='mongo:7.0')
|
|
response = _post(client, auth_headers, application.id, extension_id='pgvector')
|
|
assert response.status_code == 400
|
|
body = response.get_json()
|
|
assert 'postgresql' in body['error']
|
|
assert 'postgresql' in body['hosts']
|
|
assert not fake.statements, 'a wrong-engine install must not reach any engine'
|
|
|
|
def test_a_compatible_instance_is_listed_as_such(
|
|
self, client, auth_headers, tmp_path, monkeypatch):
|
|
_install_engine(tmp_path, monkeypatch, template_id='postgresql-pgvector',
|
|
name='pgv', image='pgvector/pgvector:pg16')
|
|
data = client.get('/api/v1/databases/engines/extensions',
|
|
headers=auth_headers).get_json()
|
|
entry = next(e for e in data['extensions'] if e['id'] == 'pgvector')
|
|
assert entry['compatible_instances']
|
|
assert not entry['incompatible_instances']
|
|
|
|
def test_instance_listing_judges_every_extension(
|
|
self, client, auth_headers, tmp_path, monkeypatch):
|
|
application = _install_engine(tmp_path, monkeypatch)
|
|
data = client.get(f'/api/v1/databases/engines/{application.id}/extensions',
|
|
headers=auth_headers).get_json()
|
|
by_id = {e['id']: e for e in data['extensions']}
|
|
assert by_id['pgvector']['status'] == ext.STATUS_IMAGE_UNSUPPORTED
|
|
assert data['instance']['protocol'] == 'postgresql'
|
|
assert data['probed'] is False
|
|
# The secret never leaves the install record.
|
|
assert 'sup3rs3cret-do-not-leak' not in json.dumps(data)
|
|
|
|
def test_instance_listing_404s_for_a_non_engine_app(
|
|
self, client, auth_headers, tmp_path, monkeypatch):
|
|
application = _install_engine(tmp_path, monkeypatch, template_id='gitea',
|
|
name='git', image='gitea/gitea:1')
|
|
response = client.get(f'/api/v1/databases/engines/{application.id}/extensions',
|
|
headers=auth_headers)
|
|
assert response.status_code == 404
|
|
|
|
def test_unknown_instance_is_404(self, client, auth_headers):
|
|
assert client.get('/api/v1/databases/engines/9999/extensions',
|
|
headers=auth_headers).status_code == 404
|
|
|
|
|
|
# ── THE image problem ────────────────────────────────────────────────────────
|
|
class TestTheImageProblem:
|
|
"""`CREATE EXTENSION vector` is correct SQL that fails on stock postgres.
|
|
|
|
Every test here exists so that failure can never be silent:
|
|
|
|
ERROR: could not open extension control file
|
|
"/usr/share/postgresql/16/extension/vector.control"
|
|
"""
|
|
|
|
def test_stock_postgres_is_marked_incompatible_before_anything_is_clicked(
|
|
self, client, auth_headers, tmp_path, monkeypatch):
|
|
application = _install_engine(tmp_path, monkeypatch, image='postgres:16-alpine')
|
|
data = client.get(f'/api/v1/databases/engines/{application.id}/extensions',
|
|
headers=auth_headers).get_json()
|
|
entry = next(e for e in data['extensions'] if e['id'] == 'pgvector')
|
|
assert entry['status'] == ext.STATUS_IMAGE_UNSUPPORTED
|
|
assert 'postgres:16-alpine' in entry['reason']
|
|
assert entry['remedy']['suggested_image'] == 'pgvector/pgvector:pg16'
|
|
assert entry['remedy']['required_images'] == ['pgvector/pgvector']
|
|
|
|
def test_the_remedy_names_an_engine_template_that_can_host_it(
|
|
self, client, auth_headers, tmp_path, monkeypatch):
|
|
"""Discovered by matching images, not by naming a template in Python."""
|
|
application = _install_engine(tmp_path, monkeypatch, image='postgres:16-alpine')
|
|
data = client.get(f'/api/v1/databases/engines/{application.id}/extensions',
|
|
headers=auth_headers).get_json()
|
|
remedy = next(e for e in data['extensions'] if e['id'] == 'pgvector')['remedy']
|
|
assert 'postgresql-pgvector' in {h['template_id'] for h in remedy['install_instead']}
|
|
|
|
def test_install_refuses_and_never_runs_the_statement(
|
|
self, client, auth_headers, tmp_path, monkeypatch, engine_client):
|
|
"""The engine says it cannot load it, so CREATE EXTENSION is not sent."""
|
|
fake = engine_client(available=False)
|
|
application = _install_engine(tmp_path, monkeypatch, image='postgres:16-alpine')
|
|
response = _post(client, auth_headers, application.id,
|
|
extension_id='pgvector', database='shop')
|
|
assert response.status_code == 409
|
|
body = response.get_json()
|
|
assert 'does not ship the extension' in body['error']
|
|
assert body['remedy']['suggested_image'] == 'pgvector/pgvector:pg16'
|
|
assert body['remedy']['install_instead'][0]['image'] == 'pgvector/pgvector'
|
|
# The transport code is the status, not part of the body.
|
|
assert 'status_code' not in body
|
|
assert not fake.ran_create, 'refused installs must not execute the statement'
|
|
|
|
def test_a_capable_image_installs(self, client, auth_headers, tmp_path,
|
|
monkeypatch, engine_client):
|
|
fake = engine_client(available=True, installed_version='0.8.0')
|
|
application = _install_engine(tmp_path, monkeypatch,
|
|
template_id='postgresql-pgvector', name='pgv',
|
|
image='pgvector/pgvector:pg16')
|
|
response = _post(client, auth_headers, application.id,
|
|
extension_id='pgvector', database='shop')
|
|
assert response.status_code == 201, response.get_json()
|
|
body = response.get_json()
|
|
assert body['status'] == ext.STATUS_INSTALLED
|
|
assert body['statement'] == 'CREATE EXTENSION IF NOT EXISTS vector;'
|
|
assert body['database'] == 'shop'
|
|
assert body['extension']['version'] == '0.8.0'
|
|
assert fake.ran_create
|
|
|
|
def test_the_engine_overrules_the_image_name_when_it_says_yes(
|
|
self, client, auth_headers, tmp_path, monkeypatch, engine_client):
|
|
"""An operator's own build is not punished for an unfamiliar name.
|
|
|
|
The probe is authoritative in both directions; this is the direction
|
|
that keeps the image list from becoming a false blocker.
|
|
"""
|
|
fake = engine_client(available=True, installed_version='0.8.0')
|
|
application = _install_engine(tmp_path, monkeypatch,
|
|
image='registry.internal/ops/pg-with-vector:16')
|
|
response = _post(client, auth_headers, application.id, extension_id='pgvector')
|
|
assert response.status_code == 201, response.get_json()
|
|
assert fake.ran_create
|
|
|
|
def test_the_engine_overrules_the_image_name_when_it_says_no(
|
|
self, client, auth_headers, tmp_path, monkeypatch, engine_client):
|
|
"""...and a matching name is not a licence to skip the check.
|
|
|
|
A ``pgvector/pgvector`` tag that predates the extension, or a mangled
|
|
image, must still be refused rather than trusted by its name.
|
|
"""
|
|
fake = engine_client(available=False)
|
|
application = _install_engine(tmp_path, monkeypatch,
|
|
template_id='postgresql-pgvector', name='pgv',
|
|
image='pgvector/pgvector:pg16')
|
|
response = _post(client, auth_headers, application.id, extension_id='pgvector')
|
|
assert response.status_code == 409
|
|
assert not fake.ran_create
|
|
|
|
def test_an_unreachable_engine_does_not_downgrade_a_known_no(
|
|
self, client, auth_headers, tmp_path, monkeypatch, engine_client):
|
|
"""A stopped container must not turn "cannot" into "maybe"."""
|
|
engine_client(fail='Error: No such container: pg-main')
|
|
application = _install_engine(tmp_path, monkeypatch, image='postgres:16-alpine')
|
|
response = _post(client, auth_headers, application.id, extension_id='pgvector')
|
|
assert response.status_code == 409
|
|
assert response.get_json()['status'] == ext.STATUS_IMAGE_UNSUPPORTED
|
|
|
|
def test_an_unreachable_capable_engine_is_reported_as_unreachable(
|
|
self, client, auth_headers, tmp_path, monkeypatch, engine_client):
|
|
"""Not a 409 with a remedy: the image is fine, the engine is down."""
|
|
fake = engine_client(fail='Error: No such container: pgv')
|
|
application = _install_engine(tmp_path, monkeypatch,
|
|
template_id='postgresql-pgvector', name='pgv',
|
|
image='pgvector/pgvector:pg16')
|
|
response = _post(client, auth_headers, application.id, extension_id='pgvector')
|
|
assert response.status_code == 502
|
|
assert 'Could not reach' in response.get_json()['error']
|
|
assert not fake.ran_create
|
|
|
|
def test_a_rejected_statement_surfaces_the_engines_own_error(
|
|
self, client, auth_headers, tmp_path, monkeypatch, monkeypatch_free=None):
|
|
"""If it fails anyway, the operator sees why — not a generic failure."""
|
|
control_file_error = ('ERROR: could not open extension control file '
|
|
'"/usr/share/postgresql/16/extension/vector.control"')
|
|
|
|
def executor(context, sql, database):
|
|
if 'pg_available_extensions' in sql:
|
|
return {'success': True, 'output': '1\n'}
|
|
return {'success': False, 'error': control_file_error}
|
|
|
|
monkeypatch.setitem(ext.EXECUTORS, 'postgresql', executor)
|
|
application = _install_engine(tmp_path, monkeypatch,
|
|
template_id='postgresql-pgvector', name='pgv',
|
|
image='pgvector/pgvector:pg16')
|
|
response = _post(client, auth_headers, application.id, extension_id='pgvector')
|
|
assert response.status_code == 400
|
|
assert 'control file' in response.get_json()['error']
|
|
|
|
def test_timescaledb_has_no_bundled_host_and_says_so(self, client, auth_headers,
|
|
tmp_path, monkeypatch):
|
|
"""We ship no timescale image, and the catalog is honest about it."""
|
|
_install_engine(tmp_path, monkeypatch, image='postgres:16-alpine')
|
|
data = client.get('/api/v1/databases/engines/extensions',
|
|
headers=auth_headers).get_json()
|
|
entry = next(e for e in data['extensions'] if e['id'] == 'timescaledb')
|
|
assert entry['hosts'], 'postgres engines can still host it in principle'
|
|
assert entry['provided_by'] == [], 'no bundled engine ships timescaledb'
|
|
assert entry['compatible_instances'] == []
|
|
|
|
|
|
class TestImageReferenceMatching:
|
|
"""The image comparison itself — five spellings of the same image."""
|
|
|
|
@pytest.mark.parametrize('image', [
|
|
'pgvector/pgvector:pg16',
|
|
'pgvector/pgvector',
|
|
'docker.io/pgvector/pgvector:pg16',
|
|
'pgvector/pgvector@sha256:' + 'a' * 64,
|
|
'registry.example.com:5000/pgvector/pgvector:pg16',
|
|
])
|
|
def test_equivalent_references_match(self, image):
|
|
assert ext._image_provides(image, ['pgvector/pgvector']) is True
|
|
|
|
@pytest.mark.parametrize('image', ['postgres:16-alpine', 'postgres', 'mysql:8'])
|
|
def test_unrelated_images_do_not_match(self, image):
|
|
assert ext._image_provides(image, ['pgvector/pgvector']) is False
|
|
|
|
def test_an_unresolvable_image_is_not_a_no(self):
|
|
"""Unknown is a third answer, so we never claim an image cannot host it."""
|
|
assert ext._image_provides(None, ['pgvector/pgvector']) is None
|
|
|
|
def test_no_declared_images_means_no_image_claim(self):
|
|
assert ext._image_provides('anything:1', []) is True
|
|
|
|
def test_image_is_read_from_the_rendered_compose(self, app, tmp_path, monkeypatch):
|
|
application = _install_engine(tmp_path, monkeypatch, image='postgres:15-alpine')
|
|
assert ext.instance_image(application) == 'postgres:15-alpine'
|
|
|
|
|
|
# ── rejection paths ──────────────────────────────────────────────────────────
|
|
class TestRejections:
|
|
|
|
def test_unknown_extension_is_404(self, client, auth_headers, tmp_path,
|
|
monkeypatch, engine_client):
|
|
fake = engine_client(available=True)
|
|
application = _install_engine(tmp_path, monkeypatch)
|
|
response = _post(client, auth_headers, application.id,
|
|
extension_id='definitely-not-an-extension')
|
|
assert response.status_code == 404
|
|
body = response.get_json()
|
|
assert 'Unknown extension' in body['error']
|
|
assert 'pgvector' in body['available']
|
|
assert not fake.statements
|
|
|
|
def test_extension_id_is_required(self, client, auth_headers, tmp_path, monkeypatch):
|
|
application = _install_engine(tmp_path, monkeypatch)
|
|
assert _post(client, auth_headers, application.id).status_code == 404
|
|
|
|
def test_unknown_instance_is_404(self, client, auth_headers):
|
|
assert _post(client, auth_headers, 4242, extension_id='pgvector').status_code == 404
|
|
|
|
def test_a_bad_database_name_is_refused(self, client, auth_headers, tmp_path,
|
|
monkeypatch, engine_client):
|
|
fake = engine_client(available=True)
|
|
application = _install_engine(tmp_path, monkeypatch,
|
|
template_id='postgresql-pgvector', name='pgv',
|
|
image='pgvector/pgvector:pg16')
|
|
response = _post(client, auth_headers, application.id,
|
|
extension_id='pgvector', database='shop; DROP DATABASE x')
|
|
assert response.status_code == 400
|
|
assert not fake.statements
|
|
|
|
def test_the_statement_comes_from_the_yaml_not_the_request(
|
|
self, client, auth_headers, tmp_path, monkeypatch, engine_client):
|
|
"""A caller cannot smuggle SQL through this route."""
|
|
fake = engine_client(available=True, installed_version='0.8.0')
|
|
application = _install_engine(tmp_path, monkeypatch,
|
|
template_id='postgresql-pgvector', name='pgv',
|
|
image='pgvector/pgvector:pg16')
|
|
response = client.post(
|
|
f'/api/v1/databases/engines/{application.id}/extensions',
|
|
json={'extension_id': 'pgvector', 'statement': 'DROP DATABASE shop;'},
|
|
headers=auth_headers)
|
|
assert response.status_code == 201
|
|
assert not any('DROP DATABASE' in sql for sql, _ in fake.statements)
|
|
|
|
|
|
class TestAdminOnly:
|
|
"""Mutations are admin-only, matching the engine install route."""
|
|
|
|
def test_install_rejects_a_non_admin(self, client, viewer_headers, tmp_path,
|
|
monkeypatch, engine_client):
|
|
fake = engine_client(available=True)
|
|
application = _install_engine(tmp_path, monkeypatch,
|
|
template_id='postgresql-pgvector', name='pgv',
|
|
image='pgvector/pgvector:pg16')
|
|
response = _post(client, viewer_headers, application.id, extension_id='pgvector')
|
|
assert response.status_code == 403
|
|
assert not fake.statements
|
|
|
|
def test_install_rejects_anonymous(self, client, tmp_path, monkeypatch):
|
|
application = _install_engine(tmp_path, monkeypatch)
|
|
assert _post(client, {}, application.id, extension_id='pgvector').status_code == 401
|
|
|
|
def test_reading_the_catalog_requires_auth(self, client):
|
|
assert client.get('/api/v1/databases/engines/extensions').status_code == 401
|
|
|
|
def test_reading_the_catalog_does_not_require_admin(self, client, viewer_headers):
|
|
assert client.get('/api/v1/databases/engines/extensions',
|
|
headers=viewer_headers).status_code == 200
|
|
|
|
|
|
# ── the engine listing carries them too ──────────────────────────────────────
|
|
class TestEngineListingIncludesExtensions:
|
|
|
|
def test_the_engine_catalog_response_carries_extensions(self, client, auth_headers):
|
|
"""One round trip for the drawer, which renders both."""
|
|
data = client.get('/api/v1/databases/engines', headers=auth_headers).get_json()
|
|
assert {'pgvector', 'timescaledb'} <= {e['id'] for e in data['extensions']}
|
|
assert data['extension_family'] == 'Extension'
|
|
|
|
def test_extensions_do_not_pollute_the_engine_family_filter(self, client, auth_headers):
|
|
"""`families` stays what it was: the families engines actually have."""
|
|
data = client.get('/api/v1/databases/engines', headers=auth_headers).get_json()
|
|
assert 'Extension' not in data['families']
|
|
|
|
def test_extensions_are_not_in_the_engine_catalog(self, client, auth_headers):
|
|
data = client.get('/api/v1/databases/engines', headers=auth_headers).get_json()
|
|
assert 'pgvector' not in {e['id'] for e in data['catalog']}
|
|
|
|
|
|
# ── probing ──────────────────────────────────────────────────────────────────
|
|
class TestProbing:
|
|
|
|
def test_probe_reports_what_is_already_installed(
|
|
self, client, auth_headers, tmp_path, monkeypatch, engine_client):
|
|
engine_client(available=True, installed_version='0.8.0')
|
|
application = _install_engine(tmp_path, monkeypatch,
|
|
template_id='postgresql-pgvector', name='pgv',
|
|
image='pgvector/pgvector:pg16')
|
|
data = client.get(
|
|
f'/api/v1/databases/engines/{application.id}/extensions?probe=true',
|
|
headers=auth_headers).get_json()
|
|
entry = next(e for e in data['extensions'] if e['id'] == 'pgvector')
|
|
assert data['probed'] is True
|
|
assert entry['status'] == ext.STATUS_INSTALLED
|
|
assert entry['installed_version'] == '0.8.0'
|
|
|
|
def test_probe_reports_ready_when_available_but_absent(
|
|
self, client, auth_headers, tmp_path, monkeypatch, engine_client):
|
|
engine_client(available=True, installed_version='')
|
|
application = _install_engine(tmp_path, monkeypatch,
|
|
template_id='postgresql-pgvector', name='pgv',
|
|
image='pgvector/pgvector:pg16')
|
|
data = client.get(
|
|
f'/api/v1/databases/engines/{application.id}/extensions?probe=true',
|
|
headers=auth_headers).get_json()
|
|
entry = next(e for e in data['extensions'] if e['id'] == 'pgvector')
|
|
assert entry['status'] == ext.STATUS_READY
|
|
assert entry['installed_version'] is None
|
|
|
|
def test_not_probing_costs_no_engine_calls(
|
|
self, client, auth_headers, tmp_path, monkeypatch, engine_client):
|
|
fake = engine_client(available=True)
|
|
application = _install_engine(tmp_path, monkeypatch,
|
|
template_id='postgresql-pgvector', name='pgv',
|
|
image='pgvector/pgvector:pg16')
|
|
client.get(f'/api/v1/databases/engines/{application.id}/extensions',
|
|
headers=auth_headers)
|
|
assert not fake.statements
|
|
|
|
|
|
# ── per-app authorization on the read (plan 29 #9) ──────────────────────────
|
|
class TestInstanceExtensionsAreScopedToTheApp:
|
|
"""`GET /<app_id>/extensions` describes someone's application: the image it
|
|
runs, the databases on it, what it can load. A bare `@jwt_required()` let
|
|
any signed-in user enumerate that for every instance on the panel — caught
|
|
by the structural authz sweep, not by a feature test, which is exactly what
|
|
that sweep is for."""
|
|
|
|
def test_a_user_without_access_is_denied(self, client, viewer_headers,
|
|
tmp_path, monkeypatch):
|
|
application = _install_engine(tmp_path, monkeypatch)
|
|
# `_install_engine` hardcodes user_id=1, and with no admin created first
|
|
# the viewer IS user 1 — so reassign the owner explicitly rather than
|
|
# letting the assertion depend on fixture ordering.
|
|
owner = User(email='extowner@test.local', username='extowner',
|
|
password_hash='x', role=User.ROLE_ADMIN, is_active=True)
|
|
db.session.add(owner)
|
|
db.session.commit()
|
|
application.user_id = owner.id
|
|
db.session.commit()
|
|
|
|
response = client.get(
|
|
f'/api/v1/databases/engines/{application.id}/extensions',
|
|
headers=viewer_headers)
|
|
assert response.status_code == 403, response.get_json()
|
|
assert 'error' in response.get_json()
|
|
|
|
def test_the_owner_still_sees_it(self, client, auth_headers, tmp_path, monkeypatch):
|
|
application = _install_engine(tmp_path, monkeypatch)
|
|
response = client.get(
|
|
f'/api/v1/databases/engines/{application.id}/extensions',
|
|
headers=auth_headers)
|
|
assert response.status_code == 200
|
|
assert 'extensions' in response.get_json()
|
|
|
|
def test_unknown_app_is_404_not_403(self, client, auth_headers):
|
|
"""A missing app and a forbidden one are different answers; conflating
|
|
them would make the endpoint useless for its own caller."""
|
|
response = client.get('/api/v1/databases/engines/999999/extensions',
|
|
headers=auth_headers)
|
|
assert response.status_code == 404
|
|
|
|
def test_anonymous_is_rejected(self, client, tmp_path, monkeypatch):
|
|
application = _install_engine(tmp_path, monkeypatch)
|
|
response = client.get(
|
|
f'/api/v1/databases/engines/{application.id}/extensions')
|
|
assert response.status_code == 401
|