11"""Tests for extensions portal endpoints."""
22
3+ import os
34from pathlib import Path
4- from unittest .mock import AsyncMock , patch
5+ from unittest .mock import AsyncMock , MagicMock , patch
56
67import yaml
78from models import ServiceStatus
@@ -274,7 +275,7 @@ def test_user_ext_compose_yaml_healthy(self, test_client, monkeypatch, tmp_path)
274275 assert ext ["status" ] == "enabled"
275276
276277 def test_user_ext_compose_yaml_no_service (self , test_client , monkeypatch , tmp_path ):
277- """User extension with compose.yaml but no running container → enabled (file-based status) ."""
278+ """User extension with compose.yaml but no running container → stopped ."""
278279 user_dir = tmp_path / "user"
279280 ext_dir = user_dir / "my-ext"
280281 ext_dir .mkdir (parents = True )
@@ -284,18 +285,20 @@ def test_user_ext_compose_yaml_no_service(self, test_client, monkeypatch, tmp_pa
284285 _patch_extensions_config (monkeypatch , catalog , tmp_path = tmp_path )
285286 monkeypatch .setattr ("routers.extensions.USER_EXTENSIONS_DIR" , user_dir )
286287
287- # No service in health results — svc is None
288- with patch ("helpers.get_all_services" , new_callable = AsyncMock ,
289- return_value = []):
290- resp = test_client .get (
291- "/api/extensions/catalog" ,
292- headers = test_client .auth_headers ,
293- )
288+ # No service in health results — svc is None → stopped
289+ with patch ("user_extensions.get_user_services_cached" ,
290+ return_value = {}):
291+ with patch ("helpers.get_all_services" , new_callable = AsyncMock ,
292+ return_value = []):
293+ resp = test_client .get (
294+ "/api/extensions/catalog" ,
295+ headers = test_client .auth_headers ,
296+ )
294297
295298 assert resp .status_code == 200
296299 ext = resp .json ()["extensions" ][0 ]
297300 assert ext ["id" ] == "my-ext"
298- assert ext ["status" ] == "enabled "
301+ assert ext ["status" ] == "stopped "
299302
300303 def test_user_ext_compose_yaml_disabled (self , test_client , monkeypatch , tmp_path ):
301304 """User extension with compose.yaml.disabled → disabled."""
@@ -526,16 +529,20 @@ def test_enable_renames_to_compose_yaml(self, test_client, monkeypatch, tmp_path
526529 assert (user_dir / "my-ext" / "compose.yaml" ).exists ()
527530 assert not (user_dir / "my-ext" / "compose.yaml.disabled" ).exists ()
528531
529- def test_enable_already_enabled_409 (self , test_client , monkeypatch , tmp_path ):
530- """409 when extension is already enabled ."""
532+ def test_enable_stopped_starts_without_rename (self , test_client , monkeypatch , tmp_path ):
533+ """Enable when compose.yaml exists (stopped) → starts without rename ."""
531534 user_dir = _setup_user_ext (tmp_path , "my-ext" , enabled = True )
532535 _patch_mutation_config (monkeypatch , tmp_path , user_dir = user_dir )
533536
534537 resp = test_client .post (
535538 "/api/extensions/my-ext/enable" ,
536539 headers = test_client .auth_headers ,
537540 )
538- assert resp .status_code == 409
541+ assert resp .status_code == 200
542+ data = resp .json ()
543+ assert data ["action" ] == "enabled"
544+ # compose.yaml still exists (no rename happened)
545+ assert (user_dir / "my-ext" / "compose.yaml" ).exists ()
539546
540547 def test_enable_allows_core_service_dependency (self , test_client , monkeypatch , tmp_path ):
541548 """Enable succeeds when depends_on includes a core service."""
@@ -1086,6 +1093,181 @@ def test_install_rejects_oversized_extension(
10861093 assert "50MB" in resp .json ()["detail" ]
10871094
10881095
1096+ # --- Extension lifecycle status (stopped / health-based) ---
1097+
1098+
1099+ class TestExtensionLifecycleStatus :
1100+
1101+ def test_user_extension_enabled_and_healthy (self , test_client , monkeypatch , tmp_path ):
1102+ """User extension with compose.yaml + healthy container → enabled."""
1103+ user_dir = tmp_path / "user"
1104+ ext_dir = user_dir / "my-ext"
1105+ ext_dir .mkdir (parents = True )
1106+ (ext_dir / "compose.yaml" ).write_text (_SAFE_COMPOSE )
1107+ (ext_dir / "manifest.yaml" ).write_text (yaml .dump ({
1108+ "schema_version" : "dream.services.v1" ,
1109+ "service" : {"id" : "my-ext" , "name" : "My Ext" , "port" : 8080 ,
1110+ "health" : "/health" },
1111+ }))
1112+
1113+ catalog = [_make_catalog_ext ("my-ext" , "My Extension" )]
1114+ _patch_extensions_config (monkeypatch , catalog , tmp_path = tmp_path )
1115+ monkeypatch .setattr ("routers.extensions.USER_EXTENSIONS_DIR" , user_dir )
1116+
1117+ mock_svc = _make_service_status ("my-ext" , "healthy" )
1118+ with patch ("user_extensions.get_user_services_cached" ,
1119+ return_value = {"my-ext" : {"host" : "my-ext" , "port" : 8080 ,
1120+ "health" : "/health" , "name" : "My Ext" }}):
1121+ with patch ("helpers.get_all_services" , new_callable = AsyncMock ,
1122+ return_value = []):
1123+ with patch ("helpers.check_service_health" , new_callable = AsyncMock ,
1124+ return_value = mock_svc ):
1125+ resp = test_client .get (
1126+ "/api/extensions/catalog" ,
1127+ headers = test_client .auth_headers ,
1128+ )
1129+
1130+ assert resp .status_code == 200
1131+ ext = resp .json ()["extensions" ][0 ]
1132+ assert ext ["status" ] == "enabled"
1133+
1134+ def test_user_extension_enabled_but_unhealthy (self , test_client , monkeypatch , tmp_path ):
1135+ """User extension with compose.yaml + unhealthy container → stopped."""
1136+ user_dir = tmp_path / "user"
1137+ ext_dir = user_dir / "my-ext"
1138+ ext_dir .mkdir (parents = True )
1139+ (ext_dir / "compose.yaml" ).write_text (_SAFE_COMPOSE )
1140+ (ext_dir / "manifest.yaml" ).write_text (yaml .dump ({
1141+ "schema_version" : "dream.services.v1" ,
1142+ "service" : {"id" : "my-ext" , "name" : "My Ext" , "port" : 8080 ,
1143+ "health" : "/health" },
1144+ }))
1145+
1146+ catalog = [_make_catalog_ext ("my-ext" , "My Extension" )]
1147+ _patch_extensions_config (monkeypatch , catalog , tmp_path = tmp_path )
1148+ monkeypatch .setattr ("routers.extensions.USER_EXTENSIONS_DIR" , user_dir )
1149+
1150+ mock_svc = _make_service_status ("my-ext" , "down" )
1151+ with patch ("user_extensions.get_user_services_cached" ,
1152+ return_value = {"my-ext" : {"host" : "my-ext" , "port" : 8080 ,
1153+ "health" : "/health" , "name" : "My Ext" }}):
1154+ with patch ("helpers.get_all_services" , new_callable = AsyncMock ,
1155+ return_value = []):
1156+ with patch ("helpers.check_service_health" , new_callable = AsyncMock ,
1157+ return_value = mock_svc ):
1158+ resp = test_client .get (
1159+ "/api/extensions/catalog" ,
1160+ headers = test_client .auth_headers ,
1161+ )
1162+
1163+ assert resp .status_code == 200
1164+ ext = resp .json ()["extensions" ][0 ]
1165+ assert ext ["status" ] == "stopped"
1166+
1167+ def test_user_extension_disabled_unchanged (self , test_client , monkeypatch , tmp_path ):
1168+ """User extension with compose.yaml.disabled → disabled (unchanged)."""
1169+ user_dir = tmp_path / "user"
1170+ ext_dir = user_dir / "my-ext"
1171+ ext_dir .mkdir (parents = True )
1172+ (ext_dir / "compose.yaml.disabled" ).write_text (_SAFE_COMPOSE )
1173+
1174+ catalog = [_make_catalog_ext ("my-ext" , "My Extension" )]
1175+ _patch_extensions_config (monkeypatch , catalog , tmp_path = tmp_path )
1176+ monkeypatch .setattr ("routers.extensions.USER_EXTENSIONS_DIR" , user_dir )
1177+
1178+ with patch ("user_extensions.get_user_services_cached" ,
1179+ return_value = {}):
1180+ with patch ("helpers.get_all_services" , new_callable = AsyncMock ,
1181+ return_value = []):
1182+ resp = test_client .get (
1183+ "/api/extensions/catalog" ,
1184+ headers = test_client .auth_headers ,
1185+ )
1186+
1187+ assert resp .status_code == 200
1188+ ext = resp .json ()["extensions" ][0 ]
1189+ assert ext ["status" ] == "disabled"
1190+
1191+ def test_core_service_status_unchanged (self , test_client , monkeypatch , tmp_path ):
1192+ """Core service healthy → enabled, unhealthy → disabled (unchanged)."""
1193+ catalog = [_make_catalog_ext ("core-svc" , "Core Service" )]
1194+ services = {"core-svc" : {"host" : "localhost" , "port" : 8080 , "name" : "Core" }}
1195+ _patch_extensions_config (monkeypatch , catalog , services , tmp_path = tmp_path )
1196+
1197+ mock_svc = _make_service_status ("core-svc" , "healthy" )
1198+ with patch ("user_extensions.get_user_services_cached" ,
1199+ return_value = {}):
1200+ with patch ("helpers.get_all_services" , new_callable = AsyncMock ,
1201+ return_value = [mock_svc ]):
1202+ resp = test_client .get (
1203+ "/api/extensions/catalog" ,
1204+ headers = test_client .auth_headers ,
1205+ )
1206+
1207+ assert resp .status_code == 200
1208+ ext = resp .json ()["extensions" ][0 ]
1209+ assert ext ["status" ] == "enabled"
1210+
1211+ def test_catalog_includes_user_extension_health (self , test_client , monkeypatch , tmp_path ):
1212+ """Catalog response includes 'stopped' in summary counts."""
1213+ user_dir = tmp_path / "user"
1214+ ext_dir = user_dir / "my-ext"
1215+ ext_dir .mkdir (parents = True )
1216+ (ext_dir / "compose.yaml" ).write_text (_SAFE_COMPOSE )
1217+
1218+ catalog = [_make_catalog_ext ("my-ext" , "My Extension" )]
1219+ _patch_extensions_config (monkeypatch , catalog , tmp_path = tmp_path )
1220+ monkeypatch .setattr ("routers.extensions.USER_EXTENSIONS_DIR" , user_dir )
1221+
1222+ # No health data → stopped
1223+ with patch ("user_extensions.get_user_services_cached" ,
1224+ return_value = {}):
1225+ with patch ("helpers.get_all_services" , new_callable = AsyncMock ,
1226+ return_value = []):
1227+ resp = test_client .get (
1228+ "/api/extensions/catalog" ,
1229+ headers = test_client .auth_headers ,
1230+ )
1231+
1232+ assert resp .status_code == 200
1233+ summary = resp .json ()["summary" ]
1234+ assert summary ["stopped" ] == 1
1235+ assert summary ["installed" ] == 1
1236+
1237+ def test_enable_stopped_extension (self , test_client , monkeypatch , tmp_path ):
1238+ """Enable when compose.yaml exists (stopped) → starts without rename."""
1239+ user_dir = _setup_user_ext (tmp_path , "my-ext" , enabled = True )
1240+ _patch_mutation_config (monkeypatch , tmp_path , user_dir = user_dir )
1241+
1242+ resp = test_client .post (
1243+ "/api/extensions/my-ext/enable" ,
1244+ headers = test_client .auth_headers ,
1245+ )
1246+
1247+ assert resp .status_code == 200
1248+ data = resp .json ()
1249+ assert data ["action" ] == "enabled"
1250+ # compose.yaml should still exist (not renamed)
1251+ assert (user_dir / "my-ext" / "compose.yaml" ).exists ()
1252+
1253+ def test_enable_stopped_rejects_malicious_compose (self , test_client , monkeypatch , tmp_path ):
1254+ """Enable stopped ext with malicious compose.yaml → 400."""
1255+ bad_compose = "services:\n svc:\n image: test\n privileged: true\n "
1256+ user_dir = tmp_path / "user"
1257+ user_dir .mkdir (exist_ok = True )
1258+ ext_dir = user_dir / "bad-ext"
1259+ ext_dir .mkdir (exist_ok = True )
1260+ (ext_dir / "compose.yaml" ).write_text (bad_compose )
1261+ _patch_mutation_config (monkeypatch , tmp_path , user_dir = user_dir )
1262+
1263+ resp = test_client .post (
1264+ "/api/extensions/bad-ext/enable" ,
1265+ headers = test_client .auth_headers ,
1266+ )
1267+ assert resp .status_code == 400
1268+ assert "privileged" in resp .json ()["detail" ]
1269+
1270+
10891271# --- Symlink handling ---
10901272
10911273
@@ -1106,6 +1288,26 @@ def test_copytree_safe_skips_symlinks(self, tmp_path):
11061288 assert (dst / "real.txt" ).exists ()
11071289 assert not (dst / "link.txt" ).exists ()
11081290
1291+ def test_enable_stopped_rejects_symlinked_compose (
1292+ self , test_client , monkeypatch , tmp_path ,
1293+ ):
1294+ """Enable stopped ext rejects a compose.yaml that is a symlink."""
1295+ user_dir = tmp_path / "user"
1296+ ext_dir = user_dir / "my-ext"
1297+ ext_dir .mkdir (parents = True )
1298+ # Create a real file and symlink compose.yaml to it
1299+ real_compose = tmp_path / "real-compose.yaml"
1300+ real_compose .write_text (_SAFE_COMPOSE )
1301+ (ext_dir / "compose.yaml" ).symlink_to (real_compose )
1302+ _patch_mutation_config (monkeypatch , tmp_path , user_dir = user_dir )
1303+
1304+ resp = test_client .post (
1305+ "/api/extensions/my-ext/enable" ,
1306+ headers = test_client .auth_headers ,
1307+ )
1308+ assert resp .status_code == 400
1309+ assert "symlink" in resp .json ()["detail" ]
1310+
11091311 def test_enable_rejects_symlinked_compose (
11101312 self , test_client , monkeypatch , tmp_path ,
11111313 ):
0 commit comments