diff --git a/devplacepy/database/users.py b/devplacepy/database/users.py
index e4375918..d73f3e17 100644
--- a/devplacepy/database/users.py
+++ b/devplacepy/database/users.py
@@ -15,6 +15,9 @@ def get_users_by_uids(uids):
_admins_cache = TTLCache(ttl=300, max_size=4)
+# The primary administrator must be an account that can actually authenticate, so scan a
+# few of the earliest admins and skip any that are soft-deleted or deactivated.
+PRIMARY_ADMIN_CANDIDATES = 50
def invalidate_admins_cache() -> None:
@@ -71,6 +74,12 @@ def get_online_users(cutoff_iso: str, limit: int = 30) -> list:
)
+def _can_hold_primary_admin(row, tracks_active):
+ if row.get("deleted_at"):
+ return False
+ return not tracks_active or bool(row.get("is_active"))
+
+
def get_primary_admin_uid():
sync_local_cache("admins", _admins_cache)
cached = _admins_cache.get("primary")
@@ -80,11 +89,17 @@ def get_primary_admin_uid():
return None
rows = list(
db.query(
- "SELECT uid FROM users WHERE role = 'Admin' "
- "ORDER BY created_at ASC, id ASC LIMIT 1"
+ "SELECT * FROM users WHERE role = 'Admin' "
+ "ORDER BY (created_at IS NULL OR created_at = ''), created_at ASC, id ASC "
+ "LIMIT :cap",
+ cap=PRIMARY_ADMIN_CANDIDATES,
)
)
- primary = rows[0]["uid"] if rows else None
+ tracks_active = "is_active" in db["users"].columns
+ primary = next(
+ (row["uid"] for row in rows if _can_hold_primary_admin(row, tracks_active)),
+ None,
+ )
_admins_cache.set("primary", primary or "")
return primary
diff --git a/devplacepy/templates/projects.html b/devplacepy/templates/projects.html
index 6e1003d7..966c8e06 100644
--- a/devplacepy/templates/projects.html
+++ b/devplacepy/templates/projects.html
@@ -69,10 +69,10 @@
-
-
{{ project.get('project_type', 'software').replace('_', ' ') }}
+
{{ (project.get('project_type') or 'software').replace('_', ' ') }}
{% if project.get('platforms') %}
{% for p in project['platforms'].split(',') %}
diff --git a/tests/api/admin/services/config.py b/tests/api/admin/services/config.py
index 963b6194..7e3eb6bc 100644
--- a/tests/api/admin/services/config.py
+++ b/tests/api/admin/services/config.py
@@ -4,7 +4,13 @@ import time
import pytest
import requests
from tests.conftest import BASE_URL
-from devplacepy.database import get_table, refresh_snapshot, set_setting, get_setting
+from devplacepy.database import (
+ clear_settings_cache,
+ get_setting,
+ get_table,
+ refresh_snapshot,
+ set_setting,
+)
JSON = {"Accept": "application/json"}
_counter_cfg = [0]
@@ -58,6 +64,9 @@ def test_admin_saves_service_config(seeded_db):
assert r.status_code == 200, r.text[:300]
assert r.json().get("ok") is True
refresh_snapshot()
+ # the server persisted it in its own process; drop this process's 60s settings
+ # cache so the assertion reads the database rather than a pre-save snapshot
+ clear_settings_cache()
assert get_setting("news_ai_model", "") == new_model
finally:
if original:
diff --git a/tests/api/issues/create.py b/tests/api/issues/create.py
index 66d5e622..c1ea0130 100644
--- a/tests/api/issues/create.py
+++ b/tests/api/issues/create.py
@@ -157,7 +157,9 @@ def _make_user_issues_gitea():
_counter_issues_gitea[0] += 1
uid = f"issuetest-user-{_counter_issues_gitea[0]}"
username = f"issuetester{_counter_issues_gitea[0]}"
- get_table("users").insert(
+ # upsert, not insert: tests/unit/services/gitea.py is a twin fixture sharing this
+ # uid namespace with its own counter, so a combined unit+api run collides on the uid
+ get_table("users").upsert(
{
"uid": uid,
"username": username,
@@ -165,7 +167,8 @@ def _make_user_issues_gitea():
"xp": 0,
"level": 1,
"is_active": True,
- }
+ },
+ ["uid"],
)
return uid, username
def _unread(user_uid):
diff --git a/tests/conftest.py b/tests/conftest.py
index ceb51296..ef1386d5 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -14,6 +14,10 @@ SCREENSHOT_DIR = Path("/tmp/devplace_test_screenshots")
PORT = int(os.environ.get("DEVPLACE_TEST_PORT", "10501"))
BASE_URL = f"http://127.0.0.1:{PORT}"
+# Older than any user a fixture can create, so the seeded admin stays primary administrator.
+PRIMARY_ADMIN_CREATED_AT = "2000-01-01T00:00:00"
+# The cross-worker cache-version read is 1s; give the server a beat to see the new admin set.
+CACHE_VERSION_PROPAGATION_SECONDS = 1.5
_TEST_DB = tempfile.NamedTemporaryFile(suffix=f"_{PORT}.db", delete=False)
_TEST_DB.close()
@@ -317,6 +321,22 @@ def seeded_db(app_server):
row = users_table.find_one(username=username)
if row and row.get("role") != role:
users_table.update({"uid": row["uid"], "role": role}, ["uid"])
+
+ # The primary administrator is the earliest-created Admin, and unit-tier fixtures
+ # create their own admins in this same database before the api tier seeds alice.
+ # Back-date her so she is unambiguously the platform's first administrator, which
+ # is what every primary-admin test (dbapi, backup download, container isolation)
+ # assumes. Without this, a combined unit+api run hands primary admin to a fixture
+ # user and those tests 403.
+ from devplacepy.database import invalidate_admins_cache as _invalidate_admins
+
+ alice_row = users_table.find_one(username="alice_test")
+ if alice_row:
+ users_table.update(
+ {"uid": alice_row["uid"], "created_at": PRIMARY_ADMIN_CREATED_AT}, ["uid"]
+ )
+ _invalidate_admins()
+ time.sleep(CACHE_VERSION_PROPAGATION_SECONDS)
return {
"alice": {
"username": "alice_test",
diff --git a/tests/unit/database/users.py b/tests/unit/database/users.py
new file mode 100644
index 00000000..8fe0c0e0
--- /dev/null
+++ b/tests/unit/database/users.py
@@ -0,0 +1,69 @@
+# retoor
+
+def test_primary_admin_ignores_accounts_without_a_signup_time(local_db):
+ from devplacepy.database import get_primary_admin_uid, invalidate_admins_cache
+
+ users = local_db["users"]
+ users.insert(
+ {
+ "uid": "pa-real",
+ "username": "pa-real",
+ "role": "Admin",
+ "created_at": "2020-01-01T00:00:00",
+ "deleted_at": None,
+ }
+ )
+ users.insert(
+ {
+ "uid": "pa-null",
+ "username": "pa-null",
+ "role": "Admin",
+ "created_at": None,
+ "deleted_at": None,
+ }
+ )
+ users.insert(
+ {
+ "uid": "pa-blank",
+ "username": "pa-blank",
+ "role": "Admin",
+ "created_at": "",
+ "deleted_at": None,
+ }
+ )
+ invalidate_admins_cache()
+ try:
+ assert get_primary_admin_uid() == "pa-real"
+ finally:
+ for uid in ("pa-real", "pa-null", "pa-blank"):
+ users.delete(uid=uid)
+ invalidate_admins_cache()
+
+
+def test_primary_admin_skips_deactivated_and_deleted_founders(local_db):
+ from devplacepy.database import get_primary_admin_uid, invalidate_admins_cache
+
+ users = local_db["users"]
+ seeded = [
+ ("pa-deleted", "2001-01-01T00:00:00", True, "2020-01-01T00:00:00"),
+ ("pa-inactive", "2002-01-01T00:00:00", False, None),
+ ("pa-usable", "2003-01-01T00:00:00", True, None),
+ ]
+ for uid, created_at, active, deleted_at in seeded:
+ users.insert(
+ {
+ "uid": uid,
+ "username": uid,
+ "role": "Admin",
+ "created_at": created_at,
+ "is_active": active,
+ "deleted_at": deleted_at,
+ }
+ )
+ invalidate_admins_cache()
+ try:
+ assert get_primary_admin_uid() == "pa-usable"
+ finally:
+ for uid, _, _, _ in seeded:
+ users.delete(uid=uid)
+ invalidate_admins_cache()
diff --git a/tests/unit/services/devii/container/controller.py b/tests/unit/services/devii/container/controller.py
index f592e303..a262c6b6 100644
--- a/tests/unit/services/devii/container/controller.py
+++ b/tests/unit/services/devii/container/controller.py
@@ -23,6 +23,10 @@ def _make_user_at(role, created_at):
"email": f"{username}@t.dev",
"role": role,
"created_at": created_at.isoformat(),
+ # every real account is created active; the primary administrator must be
+ # an account that can actually authenticate
+ "is_active": True,
+ "deleted_at": None,
}
)
invalidate_admins_cache()
diff --git a/tests/unit/utils.py b/tests/unit/utils.py
index d75f9343..6eff68d7 100644
--- a/tests/unit/utils.py
+++ b/tests/unit/utils.py
@@ -170,6 +170,10 @@ def _seed_user_at(role, created_at):
"email": f"pa_{uid[:8]}@t.dev",
"role": role,
"created_at": created_at.isoformat(),
+ # every real account is created active; the primary administrator must be
+ # an account that can actually authenticate
+ "is_active": True,
+ "deleted_at": None,
}
)
invalidate_admins_cache()