scriptgini 1.5.3__tar.gz → 1.5.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {scriptgini-1.5.3 → scriptgini-1.5.4}/PKG-INFO +2 -2
- {scriptgini-1.5.3 → scriptgini-1.5.4}/README.md +1 -1
- scriptgini-1.5.4/app/__init__.py +3 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/routers/analytics.py +14 -1
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/routers/bulk_jobs.py +13 -1
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/routers/projects.py +38 -16
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/routers/scripts.py +62 -6
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/routers/test_cases.py +39 -5
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/services/auth_dependencies.py +63 -19
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/services/rbac.py +37 -2
- {scriptgini-1.5.3 → scriptgini-1.5.4}/pyproject.toml +2 -1
- {scriptgini-1.5.3 → scriptgini-1.5.4}/scriptgini.egg-info/PKG-INFO +2 -2
- {scriptgini-1.5.3 → scriptgini-1.5.4}/tests/test_auth.py +5 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/tests/test_infra_services_coverage.py +26 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/tests/test_sprint2_rbac.py +87 -1
- {scriptgini-1.5.3 → scriptgini-1.5.4}/tests/test_sprint3_execution.py +2 -3
- {scriptgini-1.5.3 → scriptgini-1.5.4}/tests/test_sprint5_reporting_analytics.py +28 -21
- {scriptgini-1.5.3 → scriptgini-1.5.4}/tests/test_sprint6_coverage_lifecycle.py +0 -5
- scriptgini-1.5.3/app/__init__.py +0 -3
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/agents/__init__.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/agents/prompts.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/agents/script_gini_agent.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/cache.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/celery_app.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/config.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/database.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/llm/__init__.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/llm/provider.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/main.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/models/__init__.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/models/api_key.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/models/bulk_job.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/models/execution_job.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/models/generated_script.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/models/membership.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/models/organization.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/models/project.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/models/script_revision.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/models/script_run.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/models/test_case.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/models/user.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/routers/__init__.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/routers/api_key.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/routers/auth.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/routers/demo.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/routers/execution.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/routers/organizations.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/routers/reports.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/schemas/__init__.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/schemas/analytics.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/schemas/api_key.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/schemas/auth.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/schemas/bulk_job.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/schemas/execution.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/schemas/generated_script.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/schemas/membership.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/schemas/organization.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/schemas/project.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/schemas/reports.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/schemas/script_revision.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/schemas/test_case.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/services/api_key.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/services/auth.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/services/git_export.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/app/tasks.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/scriptgini.egg-info/SOURCES.txt +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/scriptgini.egg-info/dependency_links.txt +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/scriptgini.egg-info/top_level.txt +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/setup.cfg +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/tests/test_api.py +0 -0
- {scriptgini-1.5.3 → scriptgini-1.5.4}/tests/test_coverage.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scriptgini
|
|
3
|
-
Version: 1.5.
|
|
3
|
+
Version: 1.5.4
|
|
4
4
|
Summary: Agentic AI system that converts functional test cases into automation test scripts.
|
|
5
5
|
Author: ScriptGini Team
|
|
6
6
|
License: Proprietary
|
|
@@ -16,7 +16,7 @@ Description-Content-Type: text/markdown
|
|
|
16
16
|
|
|
17
17
|
> **Enterprise-grade Agentic AI system that converts functional test cases into high-quality, review-ready automation test scripts.**
|
|
18
18
|
|
|
19
|
-
Current release: v1.5.
|
|
19
|
+
Current release: v1.5.4 (Sprint 6 patch - RBAC enforcement hardening with 100% passing tests and 100% statement coverage)
|
|
20
20
|
|
|
21
21
|
---
|
|
22
22
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> **Enterprise-grade Agentic AI system that converts functional test cases into high-quality, review-ready automation test scripts.**
|
|
4
4
|
|
|
5
|
-
Current release: v1.5.
|
|
5
|
+
Current release: v1.5.4 (Sprint 6 patch - RBAC enforcement hardening with 100% passing tests and 100% statement coverage)
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -20,16 +20,23 @@ from app.schemas.analytics import (
|
|
|
20
20
|
TrendPointResponse,
|
|
21
21
|
TrendsResponse,
|
|
22
22
|
)
|
|
23
|
+
from app.services import rbac as rbac_service
|
|
24
|
+
from app.services.auth_dependencies import require_optional_auth_with_scopes
|
|
23
25
|
|
|
24
26
|
router = APIRouter(prefix="/projects/{project_id}/analytics", tags=["Run Analytics"])
|
|
25
27
|
insights_router = APIRouter(prefix="/analytics", tags=["Run Analytics"])
|
|
26
28
|
|
|
27
29
|
|
|
28
30
|
@router.get("/runs", response_model=RunAnalyticsResponse)
|
|
29
|
-
def get_run_analytics(
|
|
31
|
+
def get_run_analytics(
|
|
32
|
+
project_id: int,
|
|
33
|
+
current_user=Depends(require_optional_auth_with_scopes({"analytics:read"})),
|
|
34
|
+
db: Session = Depends(get_db),
|
|
35
|
+
):
|
|
30
36
|
project = db.query(Project).filter(Project.id == project_id).first()
|
|
31
37
|
if not project:
|
|
32
38
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
39
|
+
rbac_service.ensure_project_read_access(db, project_id, getattr(current_user, "id", None))
|
|
33
40
|
|
|
34
41
|
aggregate = (
|
|
35
42
|
db.query(
|
|
@@ -98,12 +105,14 @@ def get_trends(
|
|
|
98
105
|
project_id: int | None = None,
|
|
99
106
|
start_date: date | None = None,
|
|
100
107
|
end_date: date | None = None,
|
|
108
|
+
current_user=Depends(require_optional_auth_with_scopes({"analytics:read"})),
|
|
101
109
|
db: Session = Depends(get_db),
|
|
102
110
|
):
|
|
103
111
|
if project_id is not None:
|
|
104
112
|
project = db.query(Project).filter(Project.id == project_id).first()
|
|
105
113
|
if not project:
|
|
106
114
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
115
|
+
rbac_service.ensure_project_read_access(db, project_id, getattr(current_user, "id", None))
|
|
107
116
|
|
|
108
117
|
start_dt, end_dt = _resolve_datetime_range(start_date, end_date)
|
|
109
118
|
bucket_expr = func.date(ScriptRun.created_at)
|
|
@@ -148,12 +157,14 @@ def get_flakiness(
|
|
|
148
157
|
start_date: date | None = None,
|
|
149
158
|
end_date: date | None = None,
|
|
150
159
|
min_runs: int = 3,
|
|
160
|
+
current_user=Depends(require_optional_auth_with_scopes({"analytics:read"})),
|
|
151
161
|
db: Session = Depends(get_db),
|
|
152
162
|
):
|
|
153
163
|
if project_id is not None:
|
|
154
164
|
project = db.query(Project).filter(Project.id == project_id).first()
|
|
155
165
|
if not project:
|
|
156
166
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
167
|
+
rbac_service.ensure_project_read_access(db, project_id, getattr(current_user, "id", None))
|
|
157
168
|
|
|
158
169
|
min_runs = max(1, min_runs)
|
|
159
170
|
start_dt, end_dt = _resolve_datetime_range(start_date, end_date)
|
|
@@ -205,6 +216,7 @@ def get_flakiness(
|
|
|
205
216
|
@insights_router.get("/coverage", response_model=CoverageResponse)
|
|
206
217
|
def get_coverage(
|
|
207
218
|
project_id: int | None = None,
|
|
219
|
+
current_user=Depends(require_optional_auth_with_scopes({"analytics:read"})),
|
|
208
220
|
db: Session = Depends(get_db),
|
|
209
221
|
):
|
|
210
222
|
"""Map test cases to scripted/executed/passed state, grouped by project module (derived from test case title prefix)."""
|
|
@@ -212,6 +224,7 @@ def get_coverage(
|
|
|
212
224
|
project = db.query(Project).filter(Project.id == project_id).first()
|
|
213
225
|
if not project:
|
|
214
226
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
227
|
+
rbac_service.ensure_project_read_access(db, project_id, getattr(current_user, "id", None))
|
|
215
228
|
|
|
216
229
|
tc_query = db.query(TestCase)
|
|
217
230
|
if project_id is not None:
|
|
@@ -19,6 +19,8 @@ from app.routers.scripts import (
|
|
|
19
19
|
_get_project_or_404,
|
|
20
20
|
_run_python_script,
|
|
21
21
|
)
|
|
22
|
+
from app.services import rbac as rbac_service
|
|
23
|
+
from app.services.auth_dependencies import require_optional_auth_with_scopes
|
|
22
24
|
|
|
23
25
|
router = APIRouter(prefix="/projects/{project_id}/scripts", tags=["Bulk Jobs"])
|
|
24
26
|
logger = logging.getLogger(__name__)
|
|
@@ -219,9 +221,11 @@ def _run_bulk_execution(job_id: int, request: BulkRunRequest):
|
|
|
219
221
|
def bulk_generate_scripts(
|
|
220
222
|
project_id: int,
|
|
221
223
|
payload: BulkGenerateRequest,
|
|
224
|
+
current_user=Depends(require_optional_auth_with_scopes({"bulk-jobs:write"})),
|
|
222
225
|
db: Session = Depends(get_db),
|
|
223
226
|
):
|
|
224
227
|
_get_project_or_404(project_id, db)
|
|
228
|
+
rbac_service.ensure_project_manager_access(db, project_id, getattr(current_user, "id", None))
|
|
225
229
|
test_cases = _resolve_test_cases(project_id, payload.test_case_ids, db)
|
|
226
230
|
if not test_cases:
|
|
227
231
|
raise HTTPException(status_code=400, detail="No test cases found for bulk generation")
|
|
@@ -253,9 +257,11 @@ def bulk_generate_scripts(
|
|
|
253
257
|
def bulk_run_scripts(
|
|
254
258
|
project_id: int,
|
|
255
259
|
payload: BulkRunRequest,
|
|
260
|
+
current_user=Depends(require_optional_auth_with_scopes({"bulk-jobs:write"})),
|
|
256
261
|
db: Session = Depends(get_db),
|
|
257
262
|
):
|
|
258
263
|
_get_project_or_404(project_id, db)
|
|
264
|
+
rbac_service.ensure_project_manager_access(db, project_id, getattr(current_user, "id", None))
|
|
259
265
|
test_cases = _resolve_test_cases(project_id, payload.test_case_ids, db)
|
|
260
266
|
if not test_cases:
|
|
261
267
|
raise HTTPException(status_code=400, detail="No test cases found for bulk run")
|
|
@@ -284,7 +290,13 @@ def bulk_run_scripts(
|
|
|
284
290
|
|
|
285
291
|
|
|
286
292
|
@router.get("/bulk-jobs/{job_id}", response_model=BulkJobResponse)
|
|
287
|
-
def get_bulk_job(
|
|
293
|
+
def get_bulk_job(
|
|
294
|
+
project_id: int,
|
|
295
|
+
job_id: int,
|
|
296
|
+
current_user=Depends(require_optional_auth_with_scopes({"bulk-jobs:read"})),
|
|
297
|
+
db: Session = Depends(get_db),
|
|
298
|
+
):
|
|
288
299
|
_get_project_or_404(project_id, db)
|
|
300
|
+
rbac_service.ensure_project_read_access(db, project_id, getattr(current_user, "id", None))
|
|
289
301
|
job = _get_bulk_job_or_404(project_id, job_id, db)
|
|
290
302
|
return _serialize_bulk_job(job, db)
|
|
@@ -6,27 +6,36 @@ from app.models.project import Project
|
|
|
6
6
|
from app.schemas.membership import ProjectMemberResponse, ProjectMemberUpsertRequest
|
|
7
7
|
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse
|
|
8
8
|
from app.services import rbac as rbac_service
|
|
9
|
-
from app.services.auth_dependencies import require_auth_with_scopes
|
|
9
|
+
from app.services.auth_dependencies import require_auth_with_scopes, require_optional_auth_with_scopes
|
|
10
10
|
|
|
11
11
|
router = APIRouter(prefix="/projects", tags=["Projects"])
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def _ensure_project_manager(db: Session, project_id: int, user_id: int) -> None:
|
|
15
|
-
|
|
16
|
-
if not existing_members:
|
|
17
|
-
return
|
|
18
|
-
|
|
19
|
-
membership = rbac_service.get_user_project_membership(db, project_id, user_id)
|
|
20
|
-
if membership is None or membership.role not in rbac_service.MANAGER_ROLES:
|
|
21
|
-
raise HTTPException(status_code=403, detail="Insufficient project role")
|
|
15
|
+
rbac_service.ensure_project_manager_access(db, project_id, user_id)
|
|
22
16
|
|
|
23
17
|
|
|
24
18
|
@router.post("/", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
|
|
25
|
-
def create_project(
|
|
19
|
+
def create_project(
|
|
20
|
+
payload: ProjectCreate,
|
|
21
|
+
current_user=Depends(require_optional_auth_with_scopes({"projects:write"})),
|
|
22
|
+
db: Session = Depends(get_db),
|
|
23
|
+
):
|
|
26
24
|
project = Project(**payload.model_dump())
|
|
27
25
|
db.add(project)
|
|
28
26
|
db.commit()
|
|
29
27
|
db.refresh(project)
|
|
28
|
+
|
|
29
|
+
# Auto-bootstrap project owner membership for authenticated creators.
|
|
30
|
+
if current_user is not None:
|
|
31
|
+
rbac_service.upsert_project_member(
|
|
32
|
+
db=db,
|
|
33
|
+
project_id=project.id,
|
|
34
|
+
user_id=current_user.id,
|
|
35
|
+
role=rbac_service.Role.owner,
|
|
36
|
+
is_active=True,
|
|
37
|
+
)
|
|
38
|
+
|
|
30
39
|
return project
|
|
31
40
|
|
|
32
41
|
|
|
@@ -36,18 +45,29 @@ def list_projects(skip: int = 0, limit: int = 50, db: Session = Depends(get_db))
|
|
|
36
45
|
|
|
37
46
|
|
|
38
47
|
@router.get("/{project_id}", response_model=ProjectResponse)
|
|
39
|
-
def get_project(
|
|
48
|
+
def get_project(
|
|
49
|
+
project_id: int,
|
|
50
|
+
current_user=Depends(require_optional_auth_with_scopes({"projects:read"})),
|
|
51
|
+
db: Session = Depends(get_db),
|
|
52
|
+
):
|
|
40
53
|
project = db.query(Project).filter(Project.id == project_id).first()
|
|
41
54
|
if not project:
|
|
42
55
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
56
|
+
rbac_service.ensure_project_read_access(db, project_id, getattr(current_user, "id", None))
|
|
43
57
|
return project
|
|
44
58
|
|
|
45
59
|
|
|
46
60
|
@router.patch("/{project_id}", response_model=ProjectResponse)
|
|
47
|
-
def update_project(
|
|
61
|
+
def update_project(
|
|
62
|
+
project_id: int,
|
|
63
|
+
payload: ProjectUpdate,
|
|
64
|
+
current_user=Depends(require_optional_auth_with_scopes({"projects:write"})),
|
|
65
|
+
db: Session = Depends(get_db),
|
|
66
|
+
):
|
|
48
67
|
project = db.query(Project).filter(Project.id == project_id).first()
|
|
49
68
|
if not project:
|
|
50
69
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
70
|
+
rbac_service.ensure_project_manager_access(db, project_id, getattr(current_user, "id", None))
|
|
51
71
|
for field, value in payload.model_dump(exclude_none=True).items():
|
|
52
72
|
setattr(project, field, value)
|
|
53
73
|
db.commit()
|
|
@@ -56,10 +76,15 @@ def update_project(project_id: int, payload: ProjectUpdate, db: Session = Depend
|
|
|
56
76
|
|
|
57
77
|
|
|
58
78
|
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
59
|
-
def delete_project(
|
|
79
|
+
def delete_project(
|
|
80
|
+
project_id: int,
|
|
81
|
+
current_user=Depends(require_optional_auth_with_scopes({"projects:write"})),
|
|
82
|
+
db: Session = Depends(get_db),
|
|
83
|
+
):
|
|
60
84
|
project = db.query(Project).filter(Project.id == project_id).first()
|
|
61
85
|
if not project:
|
|
62
86
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
87
|
+
rbac_service.ensure_project_manager_access(db, project_id, getattr(current_user, "id", None))
|
|
63
88
|
db.delete(project)
|
|
64
89
|
db.commit()
|
|
65
90
|
|
|
@@ -70,10 +95,7 @@ def list_project_members(
|
|
|
70
95
|
current_user=Depends(require_auth_with_scopes({"members:read"})),
|
|
71
96
|
db: Session = Depends(get_db),
|
|
72
97
|
):
|
|
73
|
-
rbac_service.
|
|
74
|
-
membership = rbac_service.get_user_project_membership(db, project_id, current_user.id)
|
|
75
|
-
if membership is None or membership.role not in rbac_service.READ_ROLES:
|
|
76
|
-
raise HTTPException(status_code=403, detail="Insufficient project role")
|
|
98
|
+
rbac_service.ensure_project_read_access(db, project_id, current_user.id)
|
|
77
99
|
return rbac_service.list_project_members(db, project_id)
|
|
78
100
|
|
|
79
101
|
|
|
@@ -33,6 +33,8 @@ from app.models.script_revision import ScriptRevision, RevisionChangeType
|
|
|
33
33
|
from app.agents.script_gini_agent import run_agent
|
|
34
34
|
from app.llm.provider import LLMProvider, get_llm_diagnostics
|
|
35
35
|
from app.services.git_export import export_generated_script
|
|
36
|
+
from app.services import rbac as rbac_service
|
|
37
|
+
from app.services.auth_dependencies import require_optional_auth_with_scopes
|
|
36
38
|
from app.tasks import process_script_generation_job
|
|
37
39
|
|
|
38
40
|
logger = logging.getLogger(__name__)
|
|
@@ -174,8 +176,15 @@ def _provider_readiness(provider: LLMProvider) -> tuple[bool, str]:
|
|
|
174
176
|
|
|
175
177
|
|
|
176
178
|
@router.get("/providers/{provider}/ready")
|
|
177
|
-
def provider_ready(
|
|
179
|
+
def provider_ready(
|
|
180
|
+
project_id: int,
|
|
181
|
+
tc_id: int,
|
|
182
|
+
provider: LLMProvider,
|
|
183
|
+
current_user=Depends(require_optional_auth_with_scopes({"scripts:read"})),
|
|
184
|
+
db: Session = Depends(get_db),
|
|
185
|
+
):
|
|
178
186
|
_get_project_or_404(project_id, db)
|
|
187
|
+
rbac_service.ensure_project_read_access(db, project_id, getattr(current_user, "id", None))
|
|
179
188
|
_get_tc_or_404(project_id, tc_id, db)
|
|
180
189
|
ok, detail = _provider_readiness(provider)
|
|
181
190
|
return {"ready": ok, "detail": detail}
|
|
@@ -464,10 +473,12 @@ def generate_script(
|
|
|
464
473
|
project_id: int,
|
|
465
474
|
tc_id: int,
|
|
466
475
|
payload: GenerateScriptRequest,
|
|
476
|
+
current_user=Depends(require_optional_auth_with_scopes({"scripts:write"})),
|
|
467
477
|
db: Session = Depends(get_db),
|
|
468
478
|
):
|
|
469
479
|
"""Kick off async script generation. Poll GET /scripts/{id} for the result."""
|
|
470
480
|
project = _get_project_or_404(project_id, db)
|
|
481
|
+
rbac_service.ensure_project_manager_access(db, project_id, getattr(current_user, "id", None))
|
|
471
482
|
_get_tc_or_404(project_id, tc_id, db)
|
|
472
483
|
|
|
473
484
|
framework = (payload.framework or project.default_framework).value
|
|
@@ -506,9 +517,11 @@ def list_scripts(
|
|
|
506
517
|
tc_id: int,
|
|
507
518
|
skip: int = 0,
|
|
508
519
|
limit: int = 50,
|
|
520
|
+
current_user=Depends(require_optional_auth_with_scopes({"scripts:read"})),
|
|
509
521
|
db: Session = Depends(get_db),
|
|
510
522
|
):
|
|
511
523
|
_get_project_or_404(project_id, db)
|
|
524
|
+
rbac_service.ensure_project_read_access(db, project_id, getattr(current_user, "id", None))
|
|
512
525
|
_get_tc_or_404(project_id, tc_id, db)
|
|
513
526
|
return (
|
|
514
527
|
db.query(GeneratedScript)
|
|
@@ -521,15 +534,29 @@ def list_scripts(
|
|
|
521
534
|
|
|
522
535
|
|
|
523
536
|
@router.get("/{script_id}", response_model=GeneratedScriptResponse)
|
|
524
|
-
def get_script(
|
|
537
|
+
def get_script(
|
|
538
|
+
project_id: int,
|
|
539
|
+
tc_id: int,
|
|
540
|
+
script_id: int,
|
|
541
|
+
current_user=Depends(require_optional_auth_with_scopes({"scripts:read"})),
|
|
542
|
+
db: Session = Depends(get_db),
|
|
543
|
+
):
|
|
525
544
|
_get_project_or_404(project_id, db)
|
|
545
|
+
rbac_service.ensure_project_read_access(db, project_id, getattr(current_user, "id", None))
|
|
526
546
|
_get_tc_or_404(project_id, tc_id, db)
|
|
527
547
|
return _get_script_or_404(project_id, tc_id, script_id, db)
|
|
528
548
|
|
|
529
549
|
|
|
530
550
|
@router.get("/{script_id}/runs", response_model=list[ScriptRunResponse])
|
|
531
|
-
def list_script_runs(
|
|
551
|
+
def list_script_runs(
|
|
552
|
+
project_id: int,
|
|
553
|
+
tc_id: int,
|
|
554
|
+
script_id: int,
|
|
555
|
+
current_user=Depends(require_optional_auth_with_scopes({"scripts:read"})),
|
|
556
|
+
db: Session = Depends(get_db),
|
|
557
|
+
):
|
|
532
558
|
_get_project_or_404(project_id, db)
|
|
559
|
+
rbac_service.ensure_project_read_access(db, project_id, getattr(current_user, "id", None))
|
|
533
560
|
_get_tc_or_404(project_id, tc_id, db)
|
|
534
561
|
_get_script_or_404(project_id, tc_id, script_id, db)
|
|
535
562
|
return (
|
|
@@ -541,8 +568,15 @@ def list_script_runs(project_id: int, tc_id: int, script_id: int, db: Session =
|
|
|
541
568
|
|
|
542
569
|
|
|
543
570
|
@router.post("/{script_id}/run", response_model=ScriptRunResponse)
|
|
544
|
-
def run_script(
|
|
571
|
+
def run_script(
|
|
572
|
+
project_id: int,
|
|
573
|
+
tc_id: int,
|
|
574
|
+
script_id: int,
|
|
575
|
+
current_user=Depends(require_optional_auth_with_scopes({"scripts:write"})),
|
|
576
|
+
db: Session = Depends(get_db),
|
|
577
|
+
):
|
|
545
578
|
project = _get_project_or_404(project_id, db)
|
|
579
|
+
rbac_service.ensure_project_manager_access(db, project_id, getattr(current_user, "id", None))
|
|
546
580
|
_get_tc_or_404(project_id, tc_id, db)
|
|
547
581
|
script = _get_script_or_404(project_id, tc_id, script_id, db)
|
|
548
582
|
|
|
@@ -582,8 +616,15 @@ def run_script(project_id: int, tc_id: int, script_id: int, db: Session = Depend
|
|
|
582
616
|
|
|
583
617
|
|
|
584
618
|
@router.post("/{script_id}/github-export")
|
|
585
|
-
def github_export_script(
|
|
619
|
+
def github_export_script(
|
|
620
|
+
project_id: int,
|
|
621
|
+
tc_id: int,
|
|
622
|
+
script_id: int,
|
|
623
|
+
current_user=Depends(require_optional_auth_with_scopes({"scripts:write"})),
|
|
624
|
+
db: Session = Depends(get_db),
|
|
625
|
+
):
|
|
586
626
|
project = _get_project_or_404(project_id, db)
|
|
627
|
+
rbac_service.ensure_project_manager_access(db, project_id, getattr(current_user, "id", None))
|
|
587
628
|
tc = _get_tc_or_404(project_id, tc_id, db)
|
|
588
629
|
script = _get_script_or_404(project_id, tc_id, script_id, db)
|
|
589
630
|
|
|
@@ -619,8 +660,15 @@ def github_export_script(project_id: int, tc_id: int, script_id: int, db: Sessio
|
|
|
619
660
|
|
|
620
661
|
|
|
621
662
|
@router.delete("/{script_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
622
|
-
def delete_script(
|
|
663
|
+
def delete_script(
|
|
664
|
+
project_id: int,
|
|
665
|
+
tc_id: int,
|
|
666
|
+
script_id: int,
|
|
667
|
+
current_user=Depends(require_optional_auth_with_scopes({"scripts:write"})),
|
|
668
|
+
db: Session = Depends(get_db),
|
|
669
|
+
):
|
|
623
670
|
_get_project_or_404(project_id, db)
|
|
671
|
+
rbac_service.ensure_project_manager_access(db, project_id, getattr(current_user, "id", None))
|
|
624
672
|
script = _get_script_or_404(project_id, tc_id, script_id, db)
|
|
625
673
|
db.delete(script)
|
|
626
674
|
db.commit()
|
|
@@ -666,10 +714,12 @@ def refactor_script(
|
|
|
666
714
|
tc_id: int,
|
|
667
715
|
script_id: int,
|
|
668
716
|
payload: RefactorScriptRequest,
|
|
717
|
+
current_user=Depends(require_optional_auth_with_scopes({"scripts:write"})),
|
|
669
718
|
db: Session = Depends(get_db),
|
|
670
719
|
):
|
|
671
720
|
"""Submit an error log; the LLM heals the script and saves a new revision."""
|
|
672
721
|
project = _get_project_or_404(project_id, db)
|
|
722
|
+
rbac_service.ensure_project_manager_access(db, project_id, getattr(current_user, "id", None))
|
|
673
723
|
tc = _get_tc_or_404(project_id, tc_id, db)
|
|
674
724
|
script = _get_script_or_404(project_id, tc_id, script_id, db)
|
|
675
725
|
|
|
@@ -727,9 +777,11 @@ def get_version_history(
|
|
|
727
777
|
project_id: int,
|
|
728
778
|
tc_id: int,
|
|
729
779
|
script_id: int,
|
|
780
|
+
current_user=Depends(require_optional_auth_with_scopes({"scripts:read"})),
|
|
730
781
|
db: Session = Depends(get_db),
|
|
731
782
|
):
|
|
732
783
|
_get_project_or_404(project_id, db)
|
|
784
|
+
rbac_service.ensure_project_read_access(db, project_id, getattr(current_user, "id", None))
|
|
733
785
|
_get_tc_or_404(project_id, tc_id, db)
|
|
734
786
|
_get_script_or_404(project_id, tc_id, script_id, db)
|
|
735
787
|
|
|
@@ -752,10 +804,12 @@ def get_revision_diff(
|
|
|
752
804
|
tc_id: int,
|
|
753
805
|
script_id: int,
|
|
754
806
|
revision_id: int,
|
|
807
|
+
current_user=Depends(require_optional_auth_with_scopes({"scripts:read"})),
|
|
755
808
|
db: Session = Depends(get_db),
|
|
756
809
|
):
|
|
757
810
|
"""Return a unified diff between the revision just before `revision_id` and `revision_id`."""
|
|
758
811
|
_get_project_or_404(project_id, db)
|
|
812
|
+
rbac_service.ensure_project_read_access(db, project_id, getattr(current_user, "id", None))
|
|
759
813
|
_get_tc_or_404(project_id, tc_id, db)
|
|
760
814
|
_get_script_or_404(project_id, tc_id, script_id, db)
|
|
761
815
|
|
|
@@ -803,10 +857,12 @@ def rollback_script(
|
|
|
803
857
|
tc_id: int,
|
|
804
858
|
script_id: int,
|
|
805
859
|
revision_id: int,
|
|
860
|
+
current_user=Depends(require_optional_auth_with_scopes({"scripts:write"})),
|
|
806
861
|
db: Session = Depends(get_db),
|
|
807
862
|
):
|
|
808
863
|
"""Restore a script to a previously saved revision and record a rolled_back snapshot."""
|
|
809
864
|
_get_project_or_404(project_id, db)
|
|
865
|
+
rbac_service.ensure_project_manager_access(db, project_id, getattr(current_user, "id", None))
|
|
810
866
|
_get_tc_or_404(project_id, tc_id, db)
|
|
811
867
|
script = _get_script_or_404(project_id, tc_id, script_id, db)
|
|
812
868
|
|
|
@@ -5,6 +5,8 @@ from app.database import get_db
|
|
|
5
5
|
from app.models.project import Project
|
|
6
6
|
from app.models.test_case import TestCase
|
|
7
7
|
from app.schemas.test_case import TestCaseCreate, TestCaseUpdate, TestCaseResponse
|
|
8
|
+
from app.services import rbac as rbac_service
|
|
9
|
+
from app.services.auth_dependencies import require_optional_auth_with_scopes
|
|
8
10
|
|
|
9
11
|
router = APIRouter(prefix="/projects/{project_id}/test-cases", tags=["Test Cases"])
|
|
10
12
|
|
|
@@ -17,8 +19,14 @@ def _get_project_or_404(project_id: int, db: Session) -> Project:
|
|
|
17
19
|
|
|
18
20
|
|
|
19
21
|
@router.post("/", response_model=TestCaseResponse, status_code=status.HTTP_201_CREATED)
|
|
20
|
-
def create_test_case(
|
|
22
|
+
def create_test_case(
|
|
23
|
+
project_id: int,
|
|
24
|
+
payload: TestCaseCreate,
|
|
25
|
+
current_user=Depends(require_optional_auth_with_scopes({"test-cases:write"})),
|
|
26
|
+
db: Session = Depends(get_db),
|
|
27
|
+
):
|
|
21
28
|
_get_project_or_404(project_id, db)
|
|
29
|
+
rbac_service.ensure_project_manager_access(db, project_id, getattr(current_user, "id", None))
|
|
22
30
|
tc = TestCase(project_id=project_id, **payload.model_dump())
|
|
23
31
|
db.add(tc)
|
|
24
32
|
db.commit()
|
|
@@ -27,14 +35,27 @@ def create_test_case(project_id: int, payload: TestCaseCreate, db: Session = Dep
|
|
|
27
35
|
|
|
28
36
|
|
|
29
37
|
@router.get("/", response_model=list[TestCaseResponse])
|
|
30
|
-
def list_test_cases(
|
|
38
|
+
def list_test_cases(
|
|
39
|
+
project_id: int,
|
|
40
|
+
skip: int = 0,
|
|
41
|
+
limit: int = 100,
|
|
42
|
+
current_user=Depends(require_optional_auth_with_scopes({"test-cases:read"})),
|
|
43
|
+
db: Session = Depends(get_db),
|
|
44
|
+
):
|
|
31
45
|
_get_project_or_404(project_id, db)
|
|
46
|
+
rbac_service.ensure_project_read_access(db, project_id, getattr(current_user, "id", None))
|
|
32
47
|
return db.query(TestCase).filter(TestCase.project_id == project_id).offset(skip).limit(limit).all()
|
|
33
48
|
|
|
34
49
|
|
|
35
50
|
@router.get("/{tc_id}", response_model=TestCaseResponse)
|
|
36
|
-
def get_test_case(
|
|
51
|
+
def get_test_case(
|
|
52
|
+
project_id: int,
|
|
53
|
+
tc_id: int,
|
|
54
|
+
current_user=Depends(require_optional_auth_with_scopes({"test-cases:read"})),
|
|
55
|
+
db: Session = Depends(get_db),
|
|
56
|
+
):
|
|
37
57
|
_get_project_or_404(project_id, db)
|
|
58
|
+
rbac_service.ensure_project_read_access(db, project_id, getattr(current_user, "id", None))
|
|
38
59
|
tc = db.query(TestCase).filter(TestCase.project_id == project_id, TestCase.id == tc_id).first()
|
|
39
60
|
if not tc:
|
|
40
61
|
raise HTTPException(status_code=404, detail="Test case not found")
|
|
@@ -42,8 +63,15 @@ def get_test_case(project_id: int, tc_id: int, db: Session = Depends(get_db)):
|
|
|
42
63
|
|
|
43
64
|
|
|
44
65
|
@router.patch("/{tc_id}", response_model=TestCaseResponse)
|
|
45
|
-
def update_test_case(
|
|
66
|
+
def update_test_case(
|
|
67
|
+
project_id: int,
|
|
68
|
+
tc_id: int,
|
|
69
|
+
payload: TestCaseUpdate,
|
|
70
|
+
current_user=Depends(require_optional_auth_with_scopes({"test-cases:write"})),
|
|
71
|
+
db: Session = Depends(get_db),
|
|
72
|
+
):
|
|
46
73
|
_get_project_or_404(project_id, db)
|
|
74
|
+
rbac_service.ensure_project_manager_access(db, project_id, getattr(current_user, "id", None))
|
|
47
75
|
tc = db.query(TestCase).filter(TestCase.project_id == project_id, TestCase.id == tc_id).first()
|
|
48
76
|
if not tc:
|
|
49
77
|
raise HTTPException(status_code=404, detail="Test case not found")
|
|
@@ -55,8 +83,14 @@ def update_test_case(project_id: int, tc_id: int, payload: TestCaseUpdate, db: S
|
|
|
55
83
|
|
|
56
84
|
|
|
57
85
|
@router.delete("/{tc_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
58
|
-
def delete_test_case(
|
|
86
|
+
def delete_test_case(
|
|
87
|
+
project_id: int,
|
|
88
|
+
tc_id: int,
|
|
89
|
+
current_user=Depends(require_optional_auth_with_scopes({"test-cases:write"})),
|
|
90
|
+
db: Session = Depends(get_db),
|
|
91
|
+
):
|
|
59
92
|
_get_project_or_404(project_id, db)
|
|
93
|
+
rbac_service.ensure_project_manager_access(db, project_id, getattr(current_user, "id", None))
|
|
60
94
|
tc = db.query(TestCase).filter(TestCase.project_id == project_id, TestCase.id == tc_id).first()
|
|
61
95
|
if not tc:
|
|
62
96
|
raise HTTPException(status_code=404, detail="Test case not found")
|
|
@@ -9,6 +9,28 @@ from app.services import auth as auth_service
|
|
|
9
9
|
from app.services import api_key as api_key_service
|
|
10
10
|
|
|
11
11
|
security = HTTPBearer()
|
|
12
|
+
optional_security = HTTPBearer(auto_error=False)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _resolve_user_or_api_key_identity(token: str, db: Session):
|
|
16
|
+
# First try to verify as JWT token.
|
|
17
|
+
token_data = auth_service.verify_token(token, token_type="access")
|
|
18
|
+
if token_data:
|
|
19
|
+
user = auth_service.get_user_by_id(db, token_data.user_id)
|
|
20
|
+
if user and user.is_active:
|
|
21
|
+
return user
|
|
22
|
+
|
|
23
|
+
# Then try key_id:key_secret API key format.
|
|
24
|
+
if ":" in token:
|
|
25
|
+
key_id, key_secret = token.split(":", 1)
|
|
26
|
+
api_key = api_key_service.verify_api_key(db, key_id, key_secret)
|
|
27
|
+
if api_key:
|
|
28
|
+
user = auth_service.get_user_by_id(db, api_key.user_id)
|
|
29
|
+
if user and user.is_active:
|
|
30
|
+
user._api_key = api_key
|
|
31
|
+
return user
|
|
32
|
+
|
|
33
|
+
return None
|
|
12
34
|
|
|
13
35
|
|
|
14
36
|
def get_current_user(
|
|
@@ -66,25 +88,9 @@ def get_current_user_or_api_key(
|
|
|
66
88
|
return {"user_id": current_user.id}
|
|
67
89
|
"""
|
|
68
90
|
token = credentials.credentials
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if token_data:
|
|
73
|
-
user = auth_service.get_user_by_id(db, token_data.user_id)
|
|
74
|
-
if user and user.is_active:
|
|
75
|
-
return user
|
|
76
|
-
|
|
77
|
-
# Try to parse as API key (key_id:key_secret format)
|
|
78
|
-
if ":" in token:
|
|
79
|
-
key_id, key_secret = token.split(":", 1)
|
|
80
|
-
api_key = api_key_service.verify_api_key(db, key_id, key_secret)
|
|
81
|
-
if api_key:
|
|
82
|
-
# Return the user associated with the API key
|
|
83
|
-
user = auth_service.get_user_by_id(db, api_key.user_id)
|
|
84
|
-
if user and user.is_active:
|
|
85
|
-
# Attach api_key to user for scope checking in routes
|
|
86
|
-
user._api_key = api_key
|
|
87
|
-
return user
|
|
91
|
+
user = _resolve_user_or_api_key_identity(token, db)
|
|
92
|
+
if user is not None:
|
|
93
|
+
return user
|
|
88
94
|
|
|
89
95
|
raise HTTPException(
|
|
90
96
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
@@ -93,6 +99,24 @@ def get_current_user_or_api_key(
|
|
|
93
99
|
)
|
|
94
100
|
|
|
95
101
|
|
|
102
|
+
def get_optional_current_user_or_api_key(
|
|
103
|
+
credentials: HTTPAuthorizationCredentials | None = Depends(optional_security),
|
|
104
|
+
db: Session = Depends(get_db),
|
|
105
|
+
):
|
|
106
|
+
if credentials is None:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
user = _resolve_user_or_api_key_identity(credentials.credentials, db)
|
|
110
|
+
if user is not None:
|
|
111
|
+
return user
|
|
112
|
+
|
|
113
|
+
raise HTTPException(
|
|
114
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
115
|
+
detail="Invalid or expired credentials",
|
|
116
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
96
120
|
def require_auth_with_scopes(required_scopes: set[str] | None = None) -> Callable:
|
|
97
121
|
required_scopes = required_scopes or set()
|
|
98
122
|
|
|
@@ -108,3 +132,23 @@ def require_auth_with_scopes(required_scopes: set[str] | None = None) -> Callabl
|
|
|
108
132
|
return current_user
|
|
109
133
|
|
|
110
134
|
return _dependency
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def require_optional_auth_with_scopes(required_scopes: set[str] | None = None) -> Callable:
|
|
138
|
+
required_scopes = required_scopes or set()
|
|
139
|
+
|
|
140
|
+
def _dependency(current_user=Depends(get_optional_current_user_or_api_key)):
|
|
141
|
+
if current_user is None:
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
api_key = getattr(current_user, "_api_key", None)
|
|
145
|
+
if api_key is not None:
|
|
146
|
+
granted_scopes = set(api_key.scopes or [])
|
|
147
|
+
if not required_scopes.issubset(granted_scopes):
|
|
148
|
+
raise HTTPException(
|
|
149
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
150
|
+
detail="API key does not have required scopes",
|
|
151
|
+
)
|
|
152
|
+
return current_user
|
|
153
|
+
|
|
154
|
+
return _dependency
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from fastapi import HTTPException, status
|
|
1
2
|
from sqlalchemy.orm import Session
|
|
2
3
|
|
|
3
4
|
from app.models.membership import ProjectMembership, Role, OrganizationMembership
|
|
@@ -53,12 +54,46 @@ def get_user_project_membership(db: Session, project_id: int, user_id: int) -> P
|
|
|
53
54
|
def ensure_project_exists(db: Session, project_id: int) -> Project:
|
|
54
55
|
project = db.query(Project).filter(Project.id == project_id).first()
|
|
55
56
|
if not project:
|
|
56
|
-
from fastapi import HTTPException
|
|
57
|
-
|
|
58
57
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
59
58
|
return project
|
|
60
59
|
|
|
61
60
|
|
|
61
|
+
def ensure_project_role(
|
|
62
|
+
db: Session,
|
|
63
|
+
project_id: int,
|
|
64
|
+
user_id: int | None,
|
|
65
|
+
allowed_roles: set[Role],
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Enforce membership role only when a project has explicit membership records.
|
|
68
|
+
|
|
69
|
+
For legacy projects without membership rows, access remains open to preserve
|
|
70
|
+
backwards compatibility until all projects are bootstrapped.
|
|
71
|
+
"""
|
|
72
|
+
ensure_project_exists(db, project_id)
|
|
73
|
+
existing_members = list_project_members(db, project_id)
|
|
74
|
+
if not existing_members:
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
if user_id is None:
|
|
78
|
+
raise HTTPException(
|
|
79
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
80
|
+
detail="Authentication required for this project",
|
|
81
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
membership = get_user_project_membership(db, project_id, user_id)
|
|
85
|
+
if membership is None or membership.role not in allowed_roles:
|
|
86
|
+
raise HTTPException(status_code=403, detail="Insufficient project role")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def ensure_project_read_access(db: Session, project_id: int, user_id: int | None) -> None:
|
|
90
|
+
ensure_project_role(db, project_id, user_id, READ_ROLES)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def ensure_project_manager_access(db: Session, project_id: int, user_id: int | None) -> None:
|
|
94
|
+
ensure_project_role(db, project_id, user_id, MANAGER_ROLES)
|
|
95
|
+
|
|
96
|
+
|
|
62
97
|
def list_project_members(db: Session, project_id: int) -> list[ProjectMembership]:
|
|
63
98
|
return (
|
|
64
99
|
db.query(ProjectMembership)
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scriptgini"
|
|
7
|
-
version = "1.5.
|
|
7
|
+
version = "1.5.4"
|
|
8
8
|
description = "Agentic AI system that converts functional test cases into automation test scripts."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -28,6 +28,7 @@ include = ["app*"]
|
|
|
28
28
|
exclude = ["tests*", "functional_test_cases*"]
|
|
29
29
|
|
|
30
30
|
[tool.pytest.ini_options]
|
|
31
|
+
testpaths = ["tests"]
|
|
31
32
|
filterwarnings = [
|
|
32
33
|
"ignore::langchain_core._api.deprecation.LangChainPendingDeprecationWarning",
|
|
33
34
|
"ignore::PendingDeprecationWarning:langgraph\\.cache\\.base\\.__init__",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scriptgini
|
|
3
|
-
Version: 1.5.
|
|
3
|
+
Version: 1.5.4
|
|
4
4
|
Summary: Agentic AI system that converts functional test cases into automation test scripts.
|
|
5
5
|
Author: ScriptGini Team
|
|
6
6
|
License: Proprietary
|
|
@@ -16,7 +16,7 @@ Description-Content-Type: text/markdown
|
|
|
16
16
|
|
|
17
17
|
> **Enterprise-grade Agentic AI system that converts functional test cases into high-quality, review-ready automation test scripts.**
|
|
18
18
|
|
|
19
|
-
Current release: v1.5.
|
|
19
|
+
Current release: v1.5.4 (Sprint 6 patch - RBAC enforcement hardening with 100% passing tests and 100% statement coverage)
|
|
20
20
|
|
|
21
21
|
---
|
|
22
22
|
|
|
@@ -310,6 +310,11 @@ class TestAPIKeyManagement:
|
|
|
310
310
|
assert data["name"] == "Test Key"
|
|
311
311
|
assert "secret_key" not in data # Secret should not be in response
|
|
312
312
|
|
|
313
|
+
def test_get_api_key_not_found(self, auth_headers):
|
|
314
|
+
"""Test getting a missing API key returns 404."""
|
|
315
|
+
response = client.get("/api/v1/auth/api-keys/99999", headers=auth_headers)
|
|
316
|
+
assert response.status_code == 404
|
|
317
|
+
|
|
313
318
|
def test_update_api_key(self, db_session, test_user, auth_headers):
|
|
314
319
|
"""Test updating an API key."""
|
|
315
320
|
# Create key
|
|
@@ -460,6 +460,32 @@ def test_auth_dependencies_or_api_key_paths(monkeypatch):
|
|
|
460
460
|
assert e.value.status_code == 401
|
|
461
461
|
|
|
462
462
|
|
|
463
|
+
def test_auth_dependencies_optional_current_user_invalid_credentials(monkeypatch):
|
|
464
|
+
from app.services import auth_dependencies as deps
|
|
465
|
+
|
|
466
|
+
db = object()
|
|
467
|
+
creds = SimpleNamespace(credentials="invalid_key_format")
|
|
468
|
+
|
|
469
|
+
monkeypatch.setattr(deps.auth_service, "verify_token", lambda *a, **k: None)
|
|
470
|
+
|
|
471
|
+
with pytest.raises(HTTPException) as exc:
|
|
472
|
+
deps.get_optional_current_user_or_api_key(creds, db)
|
|
473
|
+
|
|
474
|
+
assert exc.value.status_code == 401
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def test_require_optional_auth_with_scopes_rejects_insufficient_api_key_scopes():
|
|
478
|
+
from app.services import auth_dependencies as deps
|
|
479
|
+
|
|
480
|
+
api_key_user = SimpleNamespace(_api_key=SimpleNamespace(scopes=["scripts:read"]))
|
|
481
|
+
dependency = deps.require_optional_auth_with_scopes({"scripts:write"})
|
|
482
|
+
|
|
483
|
+
with pytest.raises(HTTPException) as exc:
|
|
484
|
+
dependency(api_key_user)
|
|
485
|
+
|
|
486
|
+
assert exc.value.status_code == 403
|
|
487
|
+
|
|
488
|
+
|
|
463
489
|
def test_api_key_service_verify_and_crud_paths(db_session, monkeypatch):
|
|
464
490
|
from app.services import api_key as svc
|
|
465
491
|
|
|
@@ -64,9 +64,10 @@ def _register_and_login(client: TestClient, email: str, password: str = "TestPas
|
|
|
64
64
|
return user_id, {"Authorization": f"Bearer {token}"}
|
|
65
65
|
|
|
66
66
|
|
|
67
|
-
def _create_project(client: TestClient) -> int:
|
|
67
|
+
def _create_project(client: TestClient, headers: dict[str, str] | None = None) -> int:
|
|
68
68
|
resp = client.post(
|
|
69
69
|
"/api/v1/projects/",
|
|
70
|
+
headers=headers,
|
|
70
71
|
json={
|
|
71
72
|
"name": "RBAC Project",
|
|
72
73
|
"aut_base_url": "https://example.com",
|
|
@@ -293,3 +294,88 @@ class TestProjectMembersRBAC:
|
|
|
293
294
|
)
|
|
294
295
|
assert update_actor.status_code == 200
|
|
295
296
|
assert update_actor.json()["role"] == "admin"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class TestCrossDomainProjectRBAC:
|
|
300
|
+
def test_project_and_testcase_routes_block_outsider(self, client: TestClient):
|
|
301
|
+
owner_id, owner_headers = _register_and_login(client, "owner-domain@example.com")
|
|
302
|
+
_, outsider_headers = _register_and_login(client, "outsider-domain@example.com")
|
|
303
|
+
|
|
304
|
+
project_id = _create_project(client, headers=owner_headers)
|
|
305
|
+
|
|
306
|
+
owner_member = client.put(
|
|
307
|
+
f"/api/v1/projects/{project_id}/members/{owner_id}",
|
|
308
|
+
headers=owner_headers,
|
|
309
|
+
json={"role": "owner", "is_active": True},
|
|
310
|
+
)
|
|
311
|
+
assert owner_member.status_code == 200
|
|
312
|
+
|
|
313
|
+
get_project = client.get(f"/api/v1/projects/{project_id}", headers=outsider_headers)
|
|
314
|
+
assert get_project.status_code == 403
|
|
315
|
+
|
|
316
|
+
list_test_cases = client.get(
|
|
317
|
+
f"/api/v1/projects/{project_id}/test-cases/",
|
|
318
|
+
headers=outsider_headers,
|
|
319
|
+
)
|
|
320
|
+
assert list_test_cases.status_code == 403
|
|
321
|
+
|
|
322
|
+
create_test_case = client.post(
|
|
323
|
+
f"/api/v1/projects/{project_id}/test-cases/",
|
|
324
|
+
headers=outsider_headers,
|
|
325
|
+
json={"title": "Blocked", "format": "step_based", "content": "Step 1"},
|
|
326
|
+
)
|
|
327
|
+
assert create_test_case.status_code == 403
|
|
328
|
+
|
|
329
|
+
def test_scripts_bulk_and_analytics_routes_block_outsider(self, client: TestClient):
|
|
330
|
+
owner_id, owner_headers = _register_and_login(client, "owner-domain-2@example.com")
|
|
331
|
+
_, outsider_headers = _register_and_login(client, "outsider-domain-2@example.com")
|
|
332
|
+
|
|
333
|
+
project_id = _create_project(client, headers=owner_headers)
|
|
334
|
+
|
|
335
|
+
owner_member = client.put(
|
|
336
|
+
f"/api/v1/projects/{project_id}/members/{owner_id}",
|
|
337
|
+
headers=owner_headers,
|
|
338
|
+
json={"role": "owner", "is_active": True},
|
|
339
|
+
)
|
|
340
|
+
assert owner_member.status_code == 200
|
|
341
|
+
|
|
342
|
+
tc_resp = client.post(
|
|
343
|
+
f"/api/v1/projects/{project_id}/test-cases/",
|
|
344
|
+
headers=owner_headers,
|
|
345
|
+
json={"title": "TC-Allowed", "format": "step_based", "content": "Step 1"},
|
|
346
|
+
)
|
|
347
|
+
assert tc_resp.status_code == 201
|
|
348
|
+
tc_id = tc_resp.json()["id"]
|
|
349
|
+
|
|
350
|
+
scripts_list = client.get(
|
|
351
|
+
f"/api/v1/projects/{project_id}/test-cases/{tc_id}/scripts/",
|
|
352
|
+
headers=outsider_headers,
|
|
353
|
+
)
|
|
354
|
+
assert scripts_list.status_code == 403
|
|
355
|
+
|
|
356
|
+
bulk_generate = client.post(
|
|
357
|
+
f"/api/v1/projects/{project_id}/scripts/bulk-generate",
|
|
358
|
+
headers=outsider_headers,
|
|
359
|
+
json={"test_case_ids": [tc_id], "framework": "playwright_python", "llm_provider": "openai"},
|
|
360
|
+
)
|
|
361
|
+
assert bulk_generate.status_code == 403
|
|
362
|
+
|
|
363
|
+
analytics = client.get(
|
|
364
|
+
f"/api/v1/projects/{project_id}/analytics/runs",
|
|
365
|
+
headers=outsider_headers,
|
|
366
|
+
)
|
|
367
|
+
assert analytics.status_code == 403
|
|
368
|
+
|
|
369
|
+
def test_project_route_requires_auth_when_memberships_exist(self, client: TestClient):
|
|
370
|
+
owner_id, owner_headers = _register_and_login(client, "owner-auth-required@example.com")
|
|
371
|
+
project_id = _create_project(client, headers=owner_headers)
|
|
372
|
+
|
|
373
|
+
owner_member = client.put(
|
|
374
|
+
f"/api/v1/projects/{project_id}/members/{owner_id}",
|
|
375
|
+
headers=owner_headers,
|
|
376
|
+
json={"role": "owner", "is_active": True},
|
|
377
|
+
)
|
|
378
|
+
assert owner_member.status_code == 200
|
|
379
|
+
|
|
380
|
+
anonymous = client.get(f"/api/v1/projects/{project_id}")
|
|
381
|
+
assert anonymous.status_code == 401
|
|
@@ -83,17 +83,16 @@ def _create_scoped_api_key(client: TestClient, owner_headers: dict[str, str], sc
|
|
|
83
83
|
def _create_project_with_script(client: TestClient, owner_id: int, owner_headers: dict[str, str]) -> tuple[int, int]:
|
|
84
84
|
project_resp = client.post(
|
|
85
85
|
"/api/v1/projects/",
|
|
86
|
+
headers=owner_headers,
|
|
86
87
|
json={"name": "Exec Project", "aut_base_url": "https://example.com"},
|
|
87
88
|
)
|
|
88
89
|
assert project_resp.status_code == 201
|
|
89
90
|
project_id = project_resp.json()["id"]
|
|
90
91
|
|
|
91
92
|
db = TestingSessionLocal()
|
|
92
|
-
db.add(ProjectMembership(project_id=project_id, user_id=owner_id, role=Role.owner, is_active=True))
|
|
93
|
-
db.commit()
|
|
94
|
-
|
|
95
93
|
tc_resp = client.post(
|
|
96
94
|
f"/api/v1/projects/{project_id}/test-cases/",
|
|
95
|
+
headers=owner_headers,
|
|
97
96
|
json={"title": "TC-Exec", "format": "step_based", "content": "Step 1"},
|
|
98
97
|
)
|
|
99
98
|
assert tc_resp.status_code == 201
|
|
@@ -85,20 +85,23 @@ def _create_scoped_api_key(client: TestClient, owner_headers: dict[str, str], sc
|
|
|
85
85
|
return {"Authorization": f"Bearer {key['id']}:{key['secret_key']}"}
|
|
86
86
|
|
|
87
87
|
|
|
88
|
-
def _create_project_with_script(
|
|
88
|
+
def _create_project_with_script(
|
|
89
|
+
client: TestClient,
|
|
90
|
+
owner_id: int,
|
|
91
|
+
owner_headers: dict[str, str],
|
|
92
|
+
) -> tuple[int, int, int]:
|
|
89
93
|
project_resp = client.post(
|
|
90
94
|
"/api/v1/projects/",
|
|
95
|
+
headers=owner_headers,
|
|
91
96
|
json={"name": "Reporting Project", "aut_base_url": "https://example.com"},
|
|
92
97
|
)
|
|
93
98
|
assert project_resp.status_code == 201
|
|
94
99
|
project_id = project_resp.json()["id"]
|
|
95
100
|
|
|
96
101
|
db = TestingSessionLocal()
|
|
97
|
-
db.add(ProjectMembership(project_id=project_id, user_id=owner_id, role=Role.owner, is_active=True))
|
|
98
|
-
db.commit()
|
|
99
|
-
|
|
100
102
|
tc_resp = client.post(
|
|
101
103
|
f"/api/v1/projects/{project_id}/test-cases/",
|
|
104
|
+
headers=owner_headers,
|
|
102
105
|
json={"title": "TC-Report", "format": "step_based", "content": "Step 1"},
|
|
103
106
|
)
|
|
104
107
|
assert tc_resp.status_code == 201
|
|
@@ -124,7 +127,7 @@ def _create_project_with_script(client: TestClient, owner_id: int) -> tuple[int,
|
|
|
124
127
|
|
|
125
128
|
def test_reports_endpoints_return_summary_logs_artifacts_and_download(client: TestClient):
|
|
126
129
|
owner_id, owner_headers = _register_and_login(client, "sprint5-owner@example.com")
|
|
127
|
-
project_id, tc_id, script_id = _create_project_with_script(client, owner_id)
|
|
130
|
+
project_id, tc_id, script_id = _create_project_with_script(client, owner_id, owner_headers)
|
|
128
131
|
key_headers = _create_scoped_api_key(client, owner_headers, ["execution:read"])
|
|
129
132
|
|
|
130
133
|
db = TestingSessionLocal()
|
|
@@ -182,7 +185,7 @@ def test_reports_endpoints_return_summary_logs_artifacts_and_download(client: Te
|
|
|
182
185
|
|
|
183
186
|
def test_reports_download_404_for_missing_artifact(client: TestClient):
|
|
184
187
|
owner_id, owner_headers = _register_and_login(client, "sprint5-missing@example.com")
|
|
185
|
-
project_id, tc_id, script_id = _create_project_with_script(client, owner_id)
|
|
188
|
+
project_id, tc_id, script_id = _create_project_with_script(client, owner_id, owner_headers)
|
|
186
189
|
key_headers = _create_scoped_api_key(client, owner_headers, ["execution:read"])
|
|
187
190
|
|
|
188
191
|
db = TestingSessionLocal()
|
|
@@ -206,8 +209,8 @@ def test_reports_download_404_for_missing_artifact(client: TestClient):
|
|
|
206
209
|
|
|
207
210
|
|
|
208
211
|
def test_analytics_trends_returns_bucketed_series(client: TestClient):
|
|
209
|
-
owner_id,
|
|
210
|
-
project_id, tc_id, script_id = _create_project_with_script(client, owner_id)
|
|
212
|
+
owner_id, owner_headers = _register_and_login(client, "sprint5-trends@example.com")
|
|
213
|
+
project_id, tc_id, script_id = _create_project_with_script(client, owner_id, owner_headers)
|
|
211
214
|
|
|
212
215
|
db = TestingSessionLocal()
|
|
213
216
|
now = datetime.now(timezone.utc)
|
|
@@ -244,7 +247,7 @@ def test_analytics_trends_returns_bucketed_series(client: TestClient):
|
|
|
244
247
|
db.commit()
|
|
245
248
|
db.close()
|
|
246
249
|
|
|
247
|
-
resp = client.get(f"/api/v1/analytics/trends?project_id={project_id}")
|
|
250
|
+
resp = client.get(f"/api/v1/analytics/trends?project_id={project_id}", headers=owner_headers)
|
|
248
251
|
assert resp.status_code == 200
|
|
249
252
|
payload = resp.json()
|
|
250
253
|
assert payload["project_id"] == project_id
|
|
@@ -253,8 +256,8 @@ def test_analytics_trends_returns_bucketed_series(client: TestClient):
|
|
|
253
256
|
|
|
254
257
|
|
|
255
258
|
def test_analytics_flakiness_ranks_by_failure_rate(client: TestClient):
|
|
256
|
-
owner_id,
|
|
257
|
-
project_id, tc_id, script_id = _create_project_with_script(client, owner_id)
|
|
259
|
+
owner_id, owner_headers = _register_and_login(client, "sprint5-flaky@example.com")
|
|
260
|
+
project_id, tc_id, script_id = _create_project_with_script(client, owner_id, owner_headers)
|
|
258
261
|
|
|
259
262
|
db = TestingSessionLocal()
|
|
260
263
|
now = datetime.now(timezone.utc)
|
|
@@ -305,7 +308,7 @@ def test_analytics_flakiness_ranks_by_failure_rate(client: TestClient):
|
|
|
305
308
|
db.commit()
|
|
306
309
|
db.close()
|
|
307
310
|
|
|
308
|
-
resp = client.get(f"/api/v1/analytics/flakiness?project_id={project_id}&min_runs=3")
|
|
311
|
+
resp = client.get(f"/api/v1/analytics/flakiness?project_id={project_id}&min_runs=3", headers=owner_headers)
|
|
309
312
|
assert resp.status_code == 200
|
|
310
313
|
payload = resp.json()
|
|
311
314
|
assert payload["project_id"] == project_id
|
|
@@ -377,7 +380,7 @@ def test_reports_not_found_and_no_membership_access(client: TestClient):
|
|
|
377
380
|
def test_reports_access_denied_and_payload_fallbacks(client: TestClient):
|
|
378
381
|
owner_id, owner_headers = _register_and_login(client, "sprint5-owner-denied@example.com")
|
|
379
382
|
outsider_id, outsider_headers = _register_and_login(client, "sprint5-outsider@example.com")
|
|
380
|
-
project_id, tc_id, script_id = _create_project_with_script(client, owner_id)
|
|
383
|
+
project_id, tc_id, script_id = _create_project_with_script(client, owner_id, owner_headers)
|
|
381
384
|
|
|
382
385
|
owner_key_headers = _create_scoped_api_key(client, owner_headers, ["execution:read"])
|
|
383
386
|
outsider_key_headers = _create_scoped_api_key(client, outsider_headers, ["execution:read"])
|
|
@@ -414,7 +417,7 @@ def test_reports_access_denied_and_payload_fallbacks(client: TestClient):
|
|
|
414
417
|
|
|
415
418
|
def test_reports_download_branches_non_list_missing_content_and_invalid_base64(client: TestClient):
|
|
416
419
|
owner_id, owner_headers = _register_and_login(client, "sprint5-download-branches@example.com")
|
|
417
|
-
project_id, tc_id, script_id = _create_project_with_script(client, owner_id)
|
|
420
|
+
project_id, tc_id, script_id = _create_project_with_script(client, owner_id, owner_headers)
|
|
418
421
|
key_headers = _create_scoped_api_key(client, owner_headers, ["execution:read"])
|
|
419
422
|
|
|
420
423
|
db = TestingSessionLocal()
|
|
@@ -487,8 +490,8 @@ def test_reports_download_branches_non_list_missing_content_and_invalid_base64(c
|
|
|
487
490
|
|
|
488
491
|
|
|
489
492
|
def test_analytics_trends_and_flakiness_not_found_and_date_filters(client: TestClient):
|
|
490
|
-
owner_id,
|
|
491
|
-
project_id, tc_id, script_id = _create_project_with_script(client, owner_id)
|
|
493
|
+
owner_id, owner_headers = _register_and_login(client, "sprint5-analytics-filters@example.com")
|
|
494
|
+
project_id, tc_id, script_id = _create_project_with_script(client, owner_id, owner_headers)
|
|
492
495
|
|
|
493
496
|
db = TestingSessionLocal()
|
|
494
497
|
now = datetime.now(timezone.utc)
|
|
@@ -533,12 +536,16 @@ def test_analytics_trends_and_flakiness_not_found_and_date_filters(client: TestC
|
|
|
533
536
|
|
|
534
537
|
start_str = (now - timedelta(days=2)).date().isoformat()
|
|
535
538
|
end_str = now.date().isoformat()
|
|
536
|
-
trends = client.get(
|
|
539
|
+
trends = client.get(
|
|
540
|
+
f"/api/v1/analytics/trends?project_id={project_id}&start_date={start_str}&end_date={end_str}",
|
|
541
|
+
headers=owner_headers,
|
|
542
|
+
)
|
|
537
543
|
assert trends.status_code == 200
|
|
538
544
|
assert len(trends.json()["points"]) == 1
|
|
539
545
|
|
|
540
546
|
flakiness = client.get(
|
|
541
|
-
f"/api/v1/analytics/flakiness?project_id={project_id}&start_date={start_str}&end_date={end_str}&min_runs=0"
|
|
547
|
+
f"/api/v1/analytics/flakiness?project_id={project_id}&start_date={start_str}&end_date={end_str}&min_runs=0",
|
|
548
|
+
headers=owner_headers,
|
|
542
549
|
)
|
|
543
550
|
assert flakiness.status_code == 200
|
|
544
551
|
assert flakiness.json()["min_runs"] == 1
|
|
@@ -546,7 +553,7 @@ def test_analytics_trends_and_flakiness_not_found_and_date_filters(client: TestC
|
|
|
546
553
|
|
|
547
554
|
def test_reports_artifact_non_list_and_download_skips_non_dict(client: TestClient):
|
|
548
555
|
owner_id, owner_headers = _register_and_login(client, "sprint5-branch-lastmile@example.com")
|
|
549
|
-
project_id, tc_id, script_id = _create_project_with_script(client, owner_id)
|
|
556
|
+
project_id, tc_id, script_id = _create_project_with_script(client, owner_id, owner_headers)
|
|
550
557
|
key_headers = _create_scoped_api_key(client, owner_headers, ["execution:read"])
|
|
551
558
|
|
|
552
559
|
db = TestingSessionLocal()
|
|
@@ -589,8 +596,8 @@ def test_reports_artifact_non_list_and_download_skips_non_dict(client: TestClien
|
|
|
589
596
|
|
|
590
597
|
|
|
591
598
|
def test_cleanup_old_artifacts_real_session_branch(client: TestClient, monkeypatch):
|
|
592
|
-
owner_id,
|
|
593
|
-
project_id, tc_id, script_id = _create_project_with_script(client, owner_id)
|
|
599
|
+
owner_id, owner_headers = _register_and_login(client, "sprint5-cleanup@example.com")
|
|
600
|
+
project_id, tc_id, script_id = _create_project_with_script(client, owner_id, owner_headers)
|
|
594
601
|
|
|
595
602
|
db = TestingSessionLocal()
|
|
596
603
|
now = datetime.now(timezone.utc)
|
|
@@ -77,11 +77,6 @@ def _create_project_and_tc(client: TestClient, owner_id: int, title: str = "Logi
|
|
|
77
77
|
assert proj.status_code == 201
|
|
78
78
|
project_id = proj.json()["id"]
|
|
79
79
|
|
|
80
|
-
db = TestingSessionLocal()
|
|
81
|
-
db.add(ProjectMembership(project_id=project_id, user_id=owner_id, role=Role.owner, is_active=True))
|
|
82
|
-
db.commit()
|
|
83
|
-
db.close()
|
|
84
|
-
|
|
85
80
|
tc = client.post(
|
|
86
81
|
f"/api/v1/projects/{project_id}/test-cases/",
|
|
87
82
|
json={"title": title, "format": "step_based", "content": "Step 1: Open login page"},
|
scriptgini-1.5.3/app/__init__.py
DELETED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|