Drops the AbstractWizard config-volume bootstrap entirely. All deploy-time
config now comes from docker env vars (.env). First-deploy admin user + OIDC
provider config are operator-driven via `docker exec hf_backend hf-cli ...`.
Backend changes:
- entrypoint.sh: drop config-wait loop, just exec uvicorn
- app/core/config.py: drop _resolve_db_url + OIDC_* env vars (DB only now);
keep HARBORFORGE_OIDC_ONLY (deploy-time policy)
- app/init_wizard.py → app/init_bootstrap.py: drop load_config / admin / OIDC /
default-project bootstrap; keep idempotent startup seed (permissions,
default roles, acc-mgr + deleted-user builtins)
- app/main.py: /config/status now returns {initialized: <admin exists>};
startup() imports init_bootstrap.run_bootstrap
- app/api/routers/oidc.py: get_effective_oidc reads DB only (no env fallback)
- app/services/harborforge_config.py: removed (replaced by direct env reads)
- app/services/discord_wakeup.py: HF_DISCORD_GUILD_ID / HF_DISCORD_BOT_TOKEN env
- app/api/routers/users.py + tests/conftest.py: rename init_wizard refs
New hf-cli surface (app/cli/, invoked via /usr/local/bin/hf-cli shim):
hf-cli admin create-user --email <e> [--username <u>] [--password <p>]
[--oidc-issuer <url> --oidc-subject <sub>]
hf-cli admin list
hf-cli admin set-role --username <u> --role <admin|mgr|dev|guest|account-manager>
hf-cli admin reset-password --username <u> --password <p>
hf-cli admin bind-oidc --username <u> --oidc-issuer <url> --oidc-subject <sub>
hf-cli config oidc [--issuer/...] [--client-id/...] [--client-secret/...]
[--redirect-uri/...] [--enabled true|false] [--show-secret]
Bootstrap migration on existing deployments: existing admin / OIDC settings
in the DB are preserved across the cutover; only the wizard config-volume
+ wizard sidecar services need to be removed from compose. Restart picks
up the new entrypoint + skips the config wait.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
192 lines
6.3 KiB
Python
192 lines
6.3 KiB
Python
"""Shared test fixtures — SQLite in-memory DB + FastAPI TestClient.
|
|
|
|
Every test function gets a fresh database with baseline seed data:
|
|
- Roles: admin, dev, viewer (+ permissions for propose.accept/reject/reopen)
|
|
- An admin user (id=1)
|
|
- A dev user (id=2)
|
|
- A project (id=1) with both users as members
|
|
- An open milestone (id=1) under the project
|
|
"""
|
|
|
|
import pytest
|
|
from sqlalchemy import create_engine, event
|
|
from sqlalchemy.orm import sessionmaker
|
|
from fastapi.testclient import TestClient
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Patch the production engine/SessionLocal BEFORE importing app so that
|
|
# startup events (Base.metadata.create_all, init_bootstrap, etc.) use the
|
|
# in-memory SQLite database instead of trying to connect to MySQL.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
SQLALCHEMY_DATABASE_URL = "sqlite:///file::memory:?cache=shared&uri=true"
|
|
|
|
test_engine = create_engine(
|
|
SQLALCHEMY_DATABASE_URL,
|
|
connect_args={"check_same_thread": False},
|
|
)
|
|
|
|
# SQLite foreign-key enforcement
|
|
@event.listens_for(test_engine, "connect")
|
|
def _set_sqlite_pragma(dbapi_connection, connection_record):
|
|
cursor = dbapi_connection.cursor()
|
|
cursor.execute("PRAGMA foreign_keys=ON")
|
|
cursor.close()
|
|
|
|
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=test_engine)
|
|
|
|
# Monkey-patch app.core.config so the entire app uses SQLite
|
|
import app.core.config as _cfg
|
|
_cfg.engine = test_engine
|
|
_cfg.SessionLocal = TestingSessionLocal
|
|
|
|
# Now it's safe to import app and friends
|
|
from app.core.config import Base, get_db
|
|
from app.main import app
|
|
from app.models import models
|
|
from app.models.role_permission import Role, Permission, RolePermission
|
|
from app.models.milestone import Milestone, MilestoneStatus
|
|
from app.api.deps import get_password_hash, create_access_token
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def setup_database():
|
|
"""Create all tables before each test, drop after."""
|
|
Base.metadata.create_all(bind=test_engine)
|
|
yield
|
|
Base.metadata.drop_all(bind=test_engine)
|
|
|
|
|
|
@pytest.fixture()
|
|
def db():
|
|
"""Yield a DB session for direct model manipulation in tests."""
|
|
session = TestingSessionLocal()
|
|
try:
|
|
yield session
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
def _override_get_db():
|
|
session = TestingSessionLocal()
|
|
try:
|
|
yield session
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@pytest.fixture()
|
|
def client():
|
|
"""FastAPI TestClient wired to the test DB."""
|
|
app.dependency_overrides[get_db] = _override_get_db
|
|
with TestClient(app, raise_server_exceptions=False) as c:
|
|
yield c
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Seed helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _seed_roles_and_permissions(db_session):
|
|
"""Create admin/dev/viewer roles and key permissions."""
|
|
admin_role = Role(id=1, name="admin", is_global=True)
|
|
dev_role = Role(id=2, name="dev", is_global=False)
|
|
viewer_role = Role(id=3, name="viewer", is_global=False)
|
|
db_session.add_all([admin_role, dev_role, viewer_role])
|
|
db_session.flush()
|
|
|
|
perms = []
|
|
for pname in ["propose.accept", "propose.reject", "propose.reopen",
|
|
"task.create", "task.edit", "task.delete"]:
|
|
p = Permission(name=pname, category="proposal")
|
|
db_session.add(p)
|
|
db_session.flush()
|
|
perms.append(p)
|
|
|
|
# Admin gets all permissions
|
|
for p in perms:
|
|
db_session.add(RolePermission(role_id=admin_role.id, permission_id=p.id))
|
|
|
|
# Dev gets propose.accept / reject / reopen and task perms
|
|
for p in perms:
|
|
db_session.add(RolePermission(role_id=dev_role.id, permission_id=p.id))
|
|
|
|
db_session.flush()
|
|
return admin_role, dev_role, viewer_role
|
|
|
|
|
|
def _seed_users(db_session, admin_role, dev_role):
|
|
"""Create admin + dev users."""
|
|
admin_user = models.User(
|
|
id=1, username="admin", email="admin@test.com",
|
|
hashed_password=get_password_hash("admin123"),
|
|
is_admin=True, role_id=admin_role.id,
|
|
)
|
|
dev_user = models.User(
|
|
id=2, username="developer", email="dev@test.com",
|
|
hashed_password=get_password_hash("dev123"),
|
|
is_admin=False, role_id=dev_role.id,
|
|
)
|
|
db_session.add_all([admin_user, dev_user])
|
|
db_session.flush()
|
|
return admin_user, dev_user
|
|
|
|
|
|
def _seed_project(db_session, admin_user, dev_user, dev_role):
|
|
"""Create a project with both users as members."""
|
|
project = models.Project(
|
|
id=1, name="TestProject", project_code="TPRJ",
|
|
owner_name=admin_user.username, owner_id=admin_user.id,
|
|
)
|
|
db_session.add(project)
|
|
db_session.flush()
|
|
|
|
db_session.add(models.ProjectMember(project_id=project.id, user_id=admin_user.id, role_id=1))
|
|
db_session.add(models.ProjectMember(project_id=project.id, user_id=dev_user.id, role_id=dev_role.id))
|
|
db_session.flush()
|
|
return project
|
|
|
|
|
|
def _seed_milestone(db_session, project):
|
|
"""Create an open milestone."""
|
|
ms = Milestone(
|
|
id=1, title="v1.0", milestone_code="TPRJ:M00001",
|
|
status=MilestoneStatus.OPEN, project_id=project.id, created_by_id=1,
|
|
)
|
|
db_session.add(ms)
|
|
db_session.flush()
|
|
return ms
|
|
|
|
|
|
@pytest.fixture()
|
|
def seed(db):
|
|
"""Seed the DB with roles, users, project, milestone. Returns a namespace dict."""
|
|
admin_role, dev_role, viewer_role = _seed_roles_and_permissions(db)
|
|
admin_user, dev_user = _seed_users(db, admin_role, dev_role)
|
|
project = _seed_project(db, admin_user, dev_user, dev_role)
|
|
milestone = _seed_milestone(db, project)
|
|
db.commit()
|
|
|
|
admin_token = create_access_token({"sub": str(admin_user.id)})
|
|
dev_token = create_access_token({"sub": str(dev_user.id)})
|
|
|
|
return {
|
|
"admin_user": admin_user,
|
|
"dev_user": dev_user,
|
|
"admin_role": admin_role,
|
|
"dev_role": dev_role,
|
|
"project": project,
|
|
"milestone": milestone,
|
|
"admin_token": admin_token,
|
|
"dev_token": dev_token,
|
|
}
|
|
|
|
|
|
def auth_header(token: str) -> dict:
|
|
"""Return Authorization header dict."""
|
|
return {"Authorization": f"Bearer {token}"}
|