mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-09 21:43:05 +09:00
GET /api/v1/search?q= (JWT, 2-char minimum) fans a term out across the core entity types — services, servers, domains, databases, WordPress sites, cron jobs (admin-only), installed extensions, and vaults (names only, never secret values) — reusing each domain's existing access helpers so a member only sees what their grants allow. Per-type cap of 5; each entity block is isolated so one failing type degrades gracefully instead of 500-ing the search. Wires the frontend api.search() async provider (200ms debounce) into the palette's entity group, replacing the fetch-everything-on-open prefetch. Adds tests/test_search.py covering shape, min-length, cap, member-vs-admin filtering, cron admin-only, and the vaults names-only rule. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
166 lines
6.2 KiB
Python
166 lines
6.2 KiB
Python
"""Tests for the unified entity omnisearch endpoint (plan 41, Phase 4).
|
|
|
|
Proves the /api/v1/search contract: response shape, the 2-char minimum, the
|
|
per-type cap, authz-aware fan-out (a member can't see another user's app; cron
|
|
is admin-only), and that vault rows never leak secret values.
|
|
"""
|
|
import pytest
|
|
from werkzeug.security import generate_password_hash
|
|
from flask_jwt_extended import create_access_token
|
|
|
|
|
|
def _mk_user(db, username, role='developer'):
|
|
from app.models import User
|
|
u = User(email=f'{username}@t.local', username=username,
|
|
password_hash=generate_password_hash('x'), role=role, is_active=True)
|
|
db.session.add(u)
|
|
db.session.commit()
|
|
return u
|
|
|
|
|
|
def _headers(user_id):
|
|
return {'Authorization': f'Bearer {create_access_token(identity=user_id)}'}
|
|
|
|
|
|
@pytest.fixture
|
|
def personas(app):
|
|
"""An admin, a developer 'owner', and a foreign developer, plus one app the
|
|
owner owns."""
|
|
from types import SimpleNamespace
|
|
from app import db
|
|
from app.models.application import Application
|
|
|
|
admin = _mk_user(db, 'srch_admin', role='admin')
|
|
owner = _mk_user(db, 'srch_owner')
|
|
foreign = _mk_user(db, 'srch_foreign')
|
|
|
|
a = Application(name='SecretProjectOne', app_type='php', user_id=owner.id,
|
|
root_path='/srv/secretprojectone')
|
|
db.session.add(a)
|
|
db.session.commit()
|
|
|
|
return SimpleNamespace(
|
|
admin=_headers(admin.id),
|
|
owner=_headers(owner.id),
|
|
foreign=_headers(foreign.id),
|
|
app_id=a.id,
|
|
)
|
|
|
|
|
|
def test_shape(client, personas):
|
|
"""Response has a results list and each row carries type/label/sublabel/path."""
|
|
r = client.get('/api/v1/search?q=SecretProject', headers=personas.owner)
|
|
assert r.status_code == 200
|
|
body = r.get_json()
|
|
assert 'results' in body and isinstance(body['results'], list)
|
|
assert len(body['results']) >= 1
|
|
for row in body['results']:
|
|
assert set(['type', 'label', 'sublabel', 'path']).issubset(row.keys())
|
|
|
|
svc = [row for row in body['results'] if row['type'] == 'service']
|
|
assert any(row['label'] == 'SecretProjectOne' for row in svc)
|
|
assert svc[0]['path'] == f'/services/{personas.app_id}'
|
|
|
|
|
|
def test_min_length_returns_empty(client, personas):
|
|
"""A term shorter than 2 chars returns an empty result set (200)."""
|
|
r = client.get('/api/v1/search?q=a', headers=personas.owner)
|
|
assert r.status_code == 200
|
|
assert r.get_json() == {'results': []}
|
|
|
|
# Missing q behaves the same.
|
|
r2 = client.get('/api/v1/search', headers=personas.owner)
|
|
assert r2.status_code == 200
|
|
assert r2.get_json() == {'results': []}
|
|
|
|
|
|
def test_per_type_cap(client, personas):
|
|
"""More than 5 matching apps yields at most 5 service rows."""
|
|
from app import db
|
|
from app.models import User
|
|
from app.models.application import Application
|
|
|
|
# Reuse the admin as owner so an admin search sees all 7 matches.
|
|
admin = User.query.filter_by(username='srch_admin').first()
|
|
for i in range(7):
|
|
db.session.add(Application(name=f'CapApp{i}', app_type='php',
|
|
user_id=admin.id, root_path=f'/srv/capapp{i}'))
|
|
db.session.commit()
|
|
|
|
r = client.get('/api/v1/search?q=CapApp', headers=personas.admin)
|
|
assert r.status_code == 200
|
|
service_rows = [row for row in r.get_json()['results'] if row['type'] == 'service']
|
|
assert len(service_rows) == 5
|
|
|
|
|
|
def test_member_cannot_see_foreign_app_but_admin_can(client, personas):
|
|
"""A non-owner developer does NOT see another user's app; an admin does."""
|
|
# Foreign developer: no ownership, no grant -> no service row.
|
|
r_foreign = client.get('/api/v1/search?q=SecretProject', headers=personas.foreign)
|
|
assert r_foreign.status_code == 200
|
|
foreign_services = [row for row in r_foreign.get_json()['results']
|
|
if row['type'] == 'service']
|
|
assert foreign_services == []
|
|
|
|
# Admin bypasses ownership -> sees it.
|
|
r_admin = client.get('/api/v1/search?q=SecretProject', headers=personas.admin)
|
|
admin_services = [row for row in r_admin.get_json()['results']
|
|
if row['type'] == 'service']
|
|
assert any(row['label'] == 'SecretProjectOne' for row in admin_services)
|
|
|
|
|
|
def test_cron_admin_only(client, personas, monkeypatch):
|
|
"""Cron rows appear for admins but never for members."""
|
|
from app.services import cron_service
|
|
|
|
monkeypatch.setattr(
|
|
cron_service.CronService, 'list_jobs',
|
|
classmethod(lambda cls: {'jobs': [{
|
|
'name': 'NightlyBackupTask',
|
|
'description': 'runs a NightlyBackupTask',
|
|
'command': '/usr/bin/backup.sh',
|
|
'schedule': '0 3 * * *',
|
|
}]}),
|
|
)
|
|
|
|
r_admin = client.get('/api/v1/search?q=NightlyBackup', headers=personas.admin)
|
|
admin_cron = [row for row in r_admin.get_json()['results'] if row['type'] == 'cron']
|
|
assert len(admin_cron) == 1
|
|
assert admin_cron[0]['label'] == 'NightlyBackupTask'
|
|
assert admin_cron[0]['sublabel'] == '0 3 * * *'
|
|
|
|
r_member = client.get('/api/v1/search?q=NightlyBackup', headers=personas.owner)
|
|
member_cron = [row for row in r_member.get_json()['results'] if row['type'] == 'cron']
|
|
assert member_cron == []
|
|
|
|
|
|
def test_vault_names_only_never_leaks_secret(client, personas):
|
|
"""A vault row exposes only name/slug/description-derived fields — never the
|
|
stored secret value."""
|
|
from app import db
|
|
from app.models.secret_vault import SecretVault, Secret
|
|
from app.utils.crypto import encrypt_secret
|
|
|
|
vault = SecretVault(name='ProdVaultAlpha', slug='prod-vault-alpha',
|
|
description='production secrets')
|
|
db.session.add(vault)
|
|
db.session.commit()
|
|
|
|
leak_marker = 'SUPERSECRETVALUE-DO-NOT-LEAK-12345'
|
|
secret = Secret(vault_id=vault.id, name='API_KEY',
|
|
encrypted_value=encrypt_secret(leak_marker))
|
|
db.session.add(secret)
|
|
db.session.commit()
|
|
|
|
r = client.get('/api/v1/search?q=ProdVaultAlpha', headers=personas.admin)
|
|
assert r.status_code == 200
|
|
body = r.get_json()
|
|
|
|
vault_rows = [row for row in body['results'] if row['type'] == 'vault']
|
|
assert len(vault_rows) == 1
|
|
assert vault_rows[0]['label'] == 'ProdVaultAlpha'
|
|
|
|
# The secret value must not appear anywhere in the serialized response.
|
|
import json
|
|
assert leak_marker not in json.dumps(body)
|