scriptgini 1.5.4__tar.gz → 1.6.2__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.4 → scriptgini-1.6.2}/PKG-INFO +30 -5
- {scriptgini-1.5.4 → scriptgini-1.6.2}/README.md +29 -4
- scriptgini-1.6.2/app/__init__.py +3 -0
- scriptgini-1.6.2/app/celery_app.py +98 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/main.py +63 -4
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/routers/auth.py +11 -2
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/routers/bulk_jobs.py +3 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/routers/execution.py +2 -0
- scriptgini-1.6.2/app/services/rate_limit.py +137 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/tasks.py +53 -1
- {scriptgini-1.5.4 → scriptgini-1.6.2}/pyproject.toml +1 -1
- {scriptgini-1.5.4 → scriptgini-1.6.2}/scriptgini.egg-info/PKG-INFO +30 -5
- {scriptgini-1.5.4 → scriptgini-1.6.2}/scriptgini.egg-info/SOURCES.txt +4 -1
- scriptgini-1.6.2/tests/test_api_contracts.py +251 -0
- scriptgini-1.6.2/tests/test_sprint7_rate_limit.py +478 -0
- scriptgini-1.5.4/app/__init__.py +0 -3
- scriptgini-1.5.4/app/celery_app.py +0 -30
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/agents/__init__.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/agents/prompts.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/agents/script_gini_agent.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/cache.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/config.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/database.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/llm/__init__.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/llm/provider.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/models/__init__.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/models/api_key.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/models/bulk_job.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/models/execution_job.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/models/generated_script.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/models/membership.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/models/organization.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/models/project.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/models/script_revision.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/models/script_run.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/models/test_case.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/models/user.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/routers/__init__.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/routers/analytics.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/routers/api_key.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/routers/demo.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/routers/organizations.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/routers/projects.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/routers/reports.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/routers/scripts.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/routers/test_cases.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/schemas/__init__.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/schemas/analytics.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/schemas/api_key.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/schemas/auth.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/schemas/bulk_job.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/schemas/execution.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/schemas/generated_script.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/schemas/membership.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/schemas/organization.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/schemas/project.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/schemas/reports.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/schemas/script_revision.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/schemas/test_case.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/services/api_key.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/services/auth.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/services/auth_dependencies.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/services/git_export.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/app/services/rbac.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/scriptgini.egg-info/dependency_links.txt +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/scriptgini.egg-info/top_level.txt +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/setup.cfg +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/tests/test_api.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/tests/test_auth.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/tests/test_coverage.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/tests/test_infra_services_coverage.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/tests/test_sprint2_rbac.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/tests/test_sprint3_execution.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/tests/test_sprint5_reporting_analytics.py +0 -0
- {scriptgini-1.5.4 → scriptgini-1.6.2}/tests/test_sprint6_coverage_lifecycle.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scriptgini
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.6.2
|
|
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.
|
|
19
|
+
Current release: v1.6.2 (Patch release - API contract guardrails, Celery test-environment fallback, 273/273 tests passing, 100% statement coverage)
|
|
20
20
|
|
|
21
21
|
---
|
|
22
22
|
|
|
@@ -423,9 +423,33 @@ A CI gate is configured in `.github/workflows/quality-gate.yml` to enforce the s
|
|
|
423
423
|
|
|
424
424
|
## Security Notes
|
|
425
425
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
426
|
+
ScriptGini ships with a layered security model that is active in the default configuration:
|
|
427
|
+
|
|
428
|
+
### Authentication & Authorization
|
|
429
|
+
- **JWT Bearer tokens** (access + refresh, configurable expiry) protect all non-public endpoints
|
|
430
|
+
- **API keys** (hashed storage, named scopes, revocable) enable CI/CD pipelines without sharing user credentials
|
|
431
|
+
- **RBAC** — four project roles (owner / admin / member / viewer) control read and write access per project
|
|
432
|
+
- All auth events (login, register, API-key create/revoke, forbidden access) are written to structured security audit logs
|
|
433
|
+
|
|
434
|
+
### Network Controls
|
|
435
|
+
- **CORS** — only origins listed in `CORS_ALLOWED_ORIGINS` (env var) are permitted; wildcard `*` is never set in production configs
|
|
436
|
+
- **Rate limiting** — two layers:
|
|
437
|
+
- *Global middleware*: 120 req / 60 s per IP for all non-exempt paths (configurable)
|
|
438
|
+
- *Per-user/API-key*: tighter limits on sensitive actions (login: 10/min, register: 5/min, execution: 20/min, bulk jobs: 10/min)
|
|
439
|
+
- Standard `X-RateLimit-*` and `Retry-After` headers are included on all responses
|
|
440
|
+
|
|
441
|
+
### Execution Isolation
|
|
442
|
+
- Scripts run inside a **constrained runner** — only an explicit allowlist of safe import roots is permitted
|
|
443
|
+
- Runtime attribute calls are blocked (`system`, `popen`, `connect`, etc.)
|
|
444
|
+
- `__builtins__`, `__globals__`, and introspection symbols are blocked by name
|
|
445
|
+
- Execution environment variables are narrowed to an explicit `EXECUTION_ENV_ALLOWED_KEYS` allowlist
|
|
446
|
+
- Hard + soft execution timeouts enforce maximum run duration
|
|
447
|
+
|
|
448
|
+
### Secrets & Config
|
|
449
|
+
- `.env` is git-ignored — never commit API keys or `JWT_SECRET_KEY`
|
|
450
|
+
- All secrets are consumed via `pydantic-settings` env-var injection; no hardcoded defaults are safe for production
|
|
451
|
+
- `JWT_SECRET_KEY` **must** be replaced with a cryptographically random value before deployment
|
|
452
|
+
- The UI performs client-side validation only; the agent never makes unsolicited live requests to the AUT
|
|
429
453
|
|
|
430
454
|
## Change Reports
|
|
431
455
|
|
|
@@ -447,6 +471,7 @@ The project follows an **enterprise-grade development roadmap** with 6 sprints c
|
|
|
447
471
|
| **Sprint 4** | Security & Hardening | 30-36pts | ✅ Hardening increment completed (isolation + negative tests + audit controls) |
|
|
448
472
|
| **Sprint 5** | Reporting & Analytics | 28-34pts | ✅ Completed (Reports APIs, trends/flakiness, retention cleanup) |
|
|
449
473
|
| **Sprint 6** | Coverage Analytics, Script Lifecycle, and DX | 24-30pts | ✅ Completed (coverage analytics, refactor/version history/diff/rollback, 100% quality gate) |
|
|
474
|
+
| **Sprint 7** | Hardening Completion + Developer Experience | 20-24pts | ✅ Completed (per-user rate limiting, analytics indexes, OpenAPI scope docs, 271/271 tests, 100% coverage) |
|
|
450
475
|
|
|
451
476
|
---
|
|
452
477
|
|
|
@@ -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
|
+
Current release: v1.6.2 (Patch release - API contract guardrails, Celery test-environment fallback, 273/273 tests passing, 100% statement coverage)
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -409,9 +409,33 @@ A CI gate is configured in `.github/workflows/quality-gate.yml` to enforce the s
|
|
|
409
409
|
|
|
410
410
|
## Security Notes
|
|
411
411
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
412
|
+
ScriptGini ships with a layered security model that is active in the default configuration:
|
|
413
|
+
|
|
414
|
+
### Authentication & Authorization
|
|
415
|
+
- **JWT Bearer tokens** (access + refresh, configurable expiry) protect all non-public endpoints
|
|
416
|
+
- **API keys** (hashed storage, named scopes, revocable) enable CI/CD pipelines without sharing user credentials
|
|
417
|
+
- **RBAC** — four project roles (owner / admin / member / viewer) control read and write access per project
|
|
418
|
+
- All auth events (login, register, API-key create/revoke, forbidden access) are written to structured security audit logs
|
|
419
|
+
|
|
420
|
+
### Network Controls
|
|
421
|
+
- **CORS** — only origins listed in `CORS_ALLOWED_ORIGINS` (env var) are permitted; wildcard `*` is never set in production configs
|
|
422
|
+
- **Rate limiting** — two layers:
|
|
423
|
+
- *Global middleware*: 120 req / 60 s per IP for all non-exempt paths (configurable)
|
|
424
|
+
- *Per-user/API-key*: tighter limits on sensitive actions (login: 10/min, register: 5/min, execution: 20/min, bulk jobs: 10/min)
|
|
425
|
+
- Standard `X-RateLimit-*` and `Retry-After` headers are included on all responses
|
|
426
|
+
|
|
427
|
+
### Execution Isolation
|
|
428
|
+
- Scripts run inside a **constrained runner** — only an explicit allowlist of safe import roots is permitted
|
|
429
|
+
- Runtime attribute calls are blocked (`system`, `popen`, `connect`, etc.)
|
|
430
|
+
- `__builtins__`, `__globals__`, and introspection symbols are blocked by name
|
|
431
|
+
- Execution environment variables are narrowed to an explicit `EXECUTION_ENV_ALLOWED_KEYS` allowlist
|
|
432
|
+
- Hard + soft execution timeouts enforce maximum run duration
|
|
433
|
+
|
|
434
|
+
### Secrets & Config
|
|
435
|
+
- `.env` is git-ignored — never commit API keys or `JWT_SECRET_KEY`
|
|
436
|
+
- All secrets are consumed via `pydantic-settings` env-var injection; no hardcoded defaults are safe for production
|
|
437
|
+
- `JWT_SECRET_KEY` **must** be replaced with a cryptographically random value before deployment
|
|
438
|
+
- The UI performs client-side validation only; the agent never makes unsolicited live requests to the AUT
|
|
415
439
|
|
|
416
440
|
## Change Reports
|
|
417
441
|
|
|
@@ -433,6 +457,7 @@ The project follows an **enterprise-grade development roadmap** with 6 sprints c
|
|
|
433
457
|
| **Sprint 4** | Security & Hardening | 30-36pts | ✅ Hardening increment completed (isolation + negative tests + audit controls) |
|
|
434
458
|
| **Sprint 5** | Reporting & Analytics | 28-34pts | ✅ Completed (Reports APIs, trends/flakiness, retention cleanup) |
|
|
435
459
|
| **Sprint 6** | Coverage Analytics, Script Lifecycle, and DX | 24-30pts | ✅ Completed (coverage analytics, refactor/version history/diff/rollback, 100% quality gate) |
|
|
460
|
+
| **Sprint 7** | Hardening Completion + Developer Experience | 20-24pts | ✅ Completed (per-user rate limiting, analytics indexes, OpenAPI scope docs, 271/271 tests, 100% coverage) |
|
|
436
461
|
|
|
437
462
|
---
|
|
438
463
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
try:
|
|
2
|
+
from celery import Celery
|
|
3
|
+
except ModuleNotFoundError: # pragma: no cover - fallback for test environments without celery installed
|
|
4
|
+
from types import SimpleNamespace
|
|
5
|
+
|
|
6
|
+
class _TaskWrapper:
|
|
7
|
+
def __init__(self, func, *, bind: bool = False, max_retries: int | None = None):
|
|
8
|
+
self._func = func
|
|
9
|
+
self.bind = bind
|
|
10
|
+
self.max_retries = max_retries if max_retries is not None else 0
|
|
11
|
+
|
|
12
|
+
def _self_proxy(self):
|
|
13
|
+
return SimpleNamespace(
|
|
14
|
+
request=SimpleNamespace(retries=0),
|
|
15
|
+
max_retries=self.max_retries,
|
|
16
|
+
retry=self._retry,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
def _retry(self, exc=None, **kwargs):
|
|
20
|
+
if exc is not None:
|
|
21
|
+
raise exc
|
|
22
|
+
raise RuntimeError("Celery retry requested")
|
|
23
|
+
|
|
24
|
+
def run(self, *args, **kwargs):
|
|
25
|
+
if self.bind:
|
|
26
|
+
return self._func(self._self_proxy(), *args, **kwargs)
|
|
27
|
+
return self._func(*args, **kwargs)
|
|
28
|
+
|
|
29
|
+
def delay(self, *args, **kwargs):
|
|
30
|
+
return SimpleNamespace(id="stub-task", result=self.run(*args, **kwargs))
|
|
31
|
+
|
|
32
|
+
def apply(self, args=None, kwargs=None):
|
|
33
|
+
args = args or ()
|
|
34
|
+
kwargs = kwargs or {}
|
|
35
|
+
return SimpleNamespace(result=self.run(*args, **kwargs))
|
|
36
|
+
|
|
37
|
+
def __call__(self, *args, **kwargs):
|
|
38
|
+
return self.run(*args, **kwargs)
|
|
39
|
+
|
|
40
|
+
class Celery: # type: ignore[override]
|
|
41
|
+
def __init__(self, *args, **kwargs):
|
|
42
|
+
self.conf = self._Conf()
|
|
43
|
+
self.control = SimpleNamespace(revoke=lambda *a, **k: None)
|
|
44
|
+
self.AsyncResult = lambda task_id: SimpleNamespace(id=task_id, state="PENDING", result=None, info=None)
|
|
45
|
+
|
|
46
|
+
class _Conf:
|
|
47
|
+
def update(self, *args, **kwargs):
|
|
48
|
+
for mapping in args:
|
|
49
|
+
if isinstance(mapping, dict):
|
|
50
|
+
for key, value in mapping.items():
|
|
51
|
+
setattr(self, key, value)
|
|
52
|
+
for key, value in kwargs.items():
|
|
53
|
+
setattr(self, key, value)
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
def task(self, *decorator_args, **decorator_kwargs):
|
|
57
|
+
def decorator(func):
|
|
58
|
+
wrapper = _TaskWrapper(
|
|
59
|
+
func,
|
|
60
|
+
bind=bool(decorator_kwargs.get("bind", False)),
|
|
61
|
+
max_retries=decorator_kwargs.get("max_retries"),
|
|
62
|
+
)
|
|
63
|
+
wrapper.__name__ = getattr(func, "__name__", "task")
|
|
64
|
+
wrapper.__doc__ = getattr(func, "__doc__", None)
|
|
65
|
+
return wrapper
|
|
66
|
+
|
|
67
|
+
if decorator_args and callable(decorator_args[0]) and len(decorator_args) == 1 and not decorator_kwargs:
|
|
68
|
+
return decorator(decorator_args[0])
|
|
69
|
+
return decorator
|
|
70
|
+
from app.config import settings
|
|
71
|
+
|
|
72
|
+
# Initialize Celery app
|
|
73
|
+
celery_app = Celery(
|
|
74
|
+
settings.APP_NAME,
|
|
75
|
+
broker=settings.CELERY_BROKER_URL,
|
|
76
|
+
backend=settings.CELERY_RESULT_BACKEND,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Configure Celery
|
|
80
|
+
celery_app.conf.update(
|
|
81
|
+
task_serializer="json",
|
|
82
|
+
accept_content=["json"],
|
|
83
|
+
result_serializer="json",
|
|
84
|
+
timezone="UTC",
|
|
85
|
+
enable_utc=True,
|
|
86
|
+
task_track_started=True,
|
|
87
|
+
task_time_limit=settings.CELERY_TASK_HARD_TIMEOUT,
|
|
88
|
+
task_soft_time_limit=settings.CELERY_TASK_TIMEOUT,
|
|
89
|
+
worker_prefetch_multiplier=4,
|
|
90
|
+
worker_max_tasks_per_child=1000,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@celery_app.task(bind=True, max_retries=settings.CELERY_MAX_RETRIES)
|
|
95
|
+
def debug_task(self):
|
|
96
|
+
"""Test task to verify Celery is working."""
|
|
97
|
+
print(f"Request: {self.request!r}")
|
|
98
|
+
return "Celery is working!"
|
|
@@ -99,10 +99,69 @@ async def lifespan(_: FastAPI):
|
|
|
99
99
|
|
|
100
100
|
app = FastAPI(
|
|
101
101
|
title=settings.APP_NAME,
|
|
102
|
-
description=
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
102
|
+
description="""
|
|
103
|
+
Enterprise-grade Agentic AI system that converts functional test cases into high-quality automation scripts.
|
|
104
|
+
|
|
105
|
+
## Authentication
|
|
106
|
+
|
|
107
|
+
All protected endpoints require a `Bearer` token in the `Authorization` header.
|
|
108
|
+
Two credential formats are accepted:
|
|
109
|
+
|
|
110
|
+
| Format | How to obtain | Example |
|
|
111
|
+
|--------|--------------|---------|
|
|
112
|
+
| **JWT access token** | `POST /api/v1/auth/login` | `Bearer eyJ...` |
|
|
113
|
+
| **API key** | `POST /api/v1/auth/api-keys` | `Bearer <key_id>:<key_secret>` |
|
|
114
|
+
|
|
115
|
+
## Required Scopes by Endpoint Group
|
|
116
|
+
|
|
117
|
+
| Endpoint group | Required scope |
|
|
118
|
+
|---------------|---------------|
|
|
119
|
+
| Scripts (generate, view, refactor) | `scripts:read` / `scripts:write` |
|
|
120
|
+
| Test Cases | `test-cases:read` / `test-cases:write` |
|
|
121
|
+
| Execution (run, abort, status) | `execution:read` / `execution:write` |
|
|
122
|
+
| Bulk Jobs | `bulk-jobs:read` / `bulk-jobs:write` |
|
|
123
|
+
| Analytics | `analytics:read` |
|
|
124
|
+
| Reports | `reports:read` |
|
|
125
|
+
| Organizations | `orgs:read` / `orgs:write` |
|
|
126
|
+
| API Keys (manage own keys) | *(JWT only — API keys cannot create API keys)* |
|
|
127
|
+
|
|
128
|
+
## Rate Limiting
|
|
129
|
+
|
|
130
|
+
Every response on protected endpoints includes:
|
|
131
|
+
- `X-RateLimit-Limit` — maximum requests allowed in the window
|
|
132
|
+
- `X-RateLimit-Remaining` — requests remaining in the current window
|
|
133
|
+
- `X-RateLimit-Window` — window size in seconds
|
|
134
|
+
- `Retry-After` — seconds to wait (only on `429` responses)
|
|
135
|
+
|
|
136
|
+
Per-user/API-key limits (defaults, configurable via env):
|
|
137
|
+
|
|
138
|
+
| Action | Limit |
|
|
139
|
+
|--------|-------|
|
|
140
|
+
| `POST /auth/register` | 5 / 60 s per IP |
|
|
141
|
+
| `POST /auth/login` | 10 / 60 s per IP |
|
|
142
|
+
| `POST /execution/run` | 20 / 60 s per user |
|
|
143
|
+
| `POST /*/bulk-generate` | 10 / 60 s per user |
|
|
144
|
+
| `POST /*/bulk-run` | 10 / 60 s per user |
|
|
145
|
+
| All other endpoints | 120 / 60 s per IP (global middleware) |
|
|
146
|
+
|
|
147
|
+
## CI/CD Integration Example
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
# 1. Create an API key (once, as an authenticated user)
|
|
151
|
+
curl -X POST https://your-host/api/v1/auth/api-keys \\
|
|
152
|
+
-H "Authorization: Bearer $JWT" \\
|
|
153
|
+
-H "Content-Type: application/json" \\
|
|
154
|
+
-d '{"name":"ci-pipeline","scopes":["scripts:write","execution:write","bulk-jobs:write"]}'
|
|
155
|
+
|
|
156
|
+
# 2. Use the returned key_id:key_secret in subsequent calls
|
|
157
|
+
export SG_KEY="<key_id>:<key_secret>"
|
|
158
|
+
|
|
159
|
+
curl -X POST https://your-host/api/v1/execution/run \\
|
|
160
|
+
-H "Authorization: Bearer $SG_KEY" \\
|
|
161
|
+
-H "Content-Type: application/json" \\
|
|
162
|
+
-d '{"script_id": 42}'
|
|
163
|
+
```
|
|
164
|
+
""",
|
|
106
165
|
version=__version__,
|
|
107
166
|
lifespan=lifespan,
|
|
108
167
|
)
|
|
@@ -10,12 +10,17 @@ from app.schemas.auth import (
|
|
|
10
10
|
RefreshTokenRequest,
|
|
11
11
|
)
|
|
12
12
|
from app.services import auth as auth_service
|
|
13
|
+
from app.services.rate_limit import per_user_rate_limit
|
|
13
14
|
|
|
14
15
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
|
18
|
-
def register(
|
|
19
|
+
def register(
|
|
20
|
+
request: UserRegisterRequest,
|
|
21
|
+
_rl=Depends(per_user_rate_limit(max_requests=5, window_seconds=60, scope="auth:register")),
|
|
22
|
+
db: Session = Depends(get_db),
|
|
23
|
+
):
|
|
19
24
|
"""Register a new user.
|
|
20
25
|
|
|
21
26
|
- **email**: User's email address
|
|
@@ -35,7 +40,11 @@ def register(request: UserRegisterRequest, db: Session = Depends(get_db)):
|
|
|
35
40
|
|
|
36
41
|
|
|
37
42
|
@router.post("/login", response_model=TokenResponse)
|
|
38
|
-
def login(
|
|
43
|
+
def login(
|
|
44
|
+
request: UserLoginRequest,
|
|
45
|
+
_rl=Depends(per_user_rate_limit(max_requests=10, window_seconds=60, scope="auth:login")),
|
|
46
|
+
db: Session = Depends(get_db),
|
|
47
|
+
):
|
|
39
48
|
"""Authenticate user and return access and refresh tokens.
|
|
40
49
|
|
|
41
50
|
- **email**: User's email address
|
|
@@ -21,6 +21,7 @@ from app.routers.scripts import (
|
|
|
21
21
|
)
|
|
22
22
|
from app.services import rbac as rbac_service
|
|
23
23
|
from app.services.auth_dependencies import require_optional_auth_with_scopes
|
|
24
|
+
from app.services.rate_limit import per_user_rate_limit
|
|
24
25
|
|
|
25
26
|
router = APIRouter(prefix="/projects/{project_id}/scripts", tags=["Bulk Jobs"])
|
|
26
27
|
logger = logging.getLogger(__name__)
|
|
@@ -221,6 +222,7 @@ def _run_bulk_execution(job_id: int, request: BulkRunRequest):
|
|
|
221
222
|
def bulk_generate_scripts(
|
|
222
223
|
project_id: int,
|
|
223
224
|
payload: BulkGenerateRequest,
|
|
225
|
+
_rl=Depends(per_user_rate_limit(max_requests=10, window_seconds=60, scope="bulk:generate")),
|
|
224
226
|
current_user=Depends(require_optional_auth_with_scopes({"bulk-jobs:write"})),
|
|
225
227
|
db: Session = Depends(get_db),
|
|
226
228
|
):
|
|
@@ -257,6 +259,7 @@ def bulk_generate_scripts(
|
|
|
257
259
|
def bulk_run_scripts(
|
|
258
260
|
project_id: int,
|
|
259
261
|
payload: BulkRunRequest,
|
|
262
|
+
_rl=Depends(per_user_rate_limit(max_requests=10, window_seconds=60, scope="bulk:run")),
|
|
260
263
|
current_user=Depends(require_optional_auth_with_scopes({"bulk-jobs:write"})),
|
|
261
264
|
db: Session = Depends(get_db),
|
|
262
265
|
):
|
|
@@ -10,6 +10,7 @@ from app.models.generated_script import GeneratedScript, ScriptStatus
|
|
|
10
10
|
from app.schemas.execution import ExecutionJobResponse, ExecutionRunRequest
|
|
11
11
|
from app.services import rbac as rbac_service
|
|
12
12
|
from app.services.auth_dependencies import require_auth_with_scopes
|
|
13
|
+
from app.services.rate_limit import per_user_rate_limit
|
|
13
14
|
from app.tasks import execute_script
|
|
14
15
|
|
|
15
16
|
router = APIRouter(prefix="/execution", tags=["Execution"])
|
|
@@ -89,6 +90,7 @@ def _enqueue_execution_task(script_id: int, execution_env: dict[str, str], job_i
|
|
|
89
90
|
def run_execution_job(
|
|
90
91
|
payload: ExecutionRunRequest,
|
|
91
92
|
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
|
|
93
|
+
_rl=Depends(per_user_rate_limit(max_requests=20, window_seconds=60, scope="execution:run")),
|
|
92
94
|
current_user=Depends(require_auth_with_scopes({"execution:write"})),
|
|
93
95
|
db: Session = Depends(get_db),
|
|
94
96
|
):
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Redis-backed per-user/API-key rate limiter with in-memory fallback.
|
|
3
|
+
|
|
4
|
+
Usage (FastAPI dependency):
|
|
5
|
+
@router.post("/run")
|
|
6
|
+
def my_endpoint(
|
|
7
|
+
_=Depends(per_user_rate_limit(max_requests=20, window_seconds=60, scope="execution:run")),
|
|
8
|
+
current_user=Depends(require_auth_with_scopes({"execution:write"})),
|
|
9
|
+
):
|
|
10
|
+
...
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import logging
|
|
16
|
+
from typing import Callable
|
|
17
|
+
|
|
18
|
+
from fastapi import Depends, HTTPException, Request, status
|
|
19
|
+
|
|
20
|
+
from app.config import settings
|
|
21
|
+
from app.cache import cache as _redis_cache
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# In-memory fallback store: key -> (count, window_reset_at)
|
|
26
|
+
_IN_MEMORY_BUCKETS: dict[str, tuple[int, float]] = {}
|
|
27
|
+
_IN_MEMORY_LOCK = threading.Lock()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _client_ip(request: Request) -> str:
|
|
31
|
+
forwarded_for = request.headers.get("x-forwarded-for", "")
|
|
32
|
+
if forwarded_for:
|
|
33
|
+
return forwarded_for.split(",")[0].strip()
|
|
34
|
+
return request.client.host if request.client else "unknown"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _redis_rate_check(key: str, max_requests: int, window_seconds: int) -> tuple[bool, int, int]:
|
|
38
|
+
"""Sliding fixed-window counter via Redis INCR + EXPIRE. Returns (allowed, remaining, retry_after)."""
|
|
39
|
+
try:
|
|
40
|
+
if not _redis_cache.is_available():
|
|
41
|
+
raise RuntimeError("Redis unavailable")
|
|
42
|
+
|
|
43
|
+
rc = _redis_cache.redis_client
|
|
44
|
+
count = rc.incr(key)
|
|
45
|
+
if count == 1:
|
|
46
|
+
rc.expire(key, window_seconds)
|
|
47
|
+
ttl = rc.ttl(key)
|
|
48
|
+
if ttl < 0:
|
|
49
|
+
rc.expire(key, window_seconds)
|
|
50
|
+
ttl = window_seconds
|
|
51
|
+
remaining = max(0, max_requests - int(count))
|
|
52
|
+
return int(count) <= max_requests, remaining, int(ttl)
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
logger.debug("Redis rate limit unavailable, falling back to in-memory: %s", exc)
|
|
55
|
+
return _memory_rate_check(key, max_requests, window_seconds)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _memory_rate_check(key: str, max_requests: int, window_seconds: int) -> tuple[bool, int, int]:
|
|
59
|
+
"""Thread-safe fixed-window counter in process memory."""
|
|
60
|
+
now = time.monotonic()
|
|
61
|
+
with _IN_MEMORY_LOCK:
|
|
62
|
+
count, reset_at = _IN_MEMORY_BUCKETS.get(key, (0, now + window_seconds))
|
|
63
|
+
if reset_at <= now:
|
|
64
|
+
count = 0
|
|
65
|
+
reset_at = now + window_seconds
|
|
66
|
+
count += 1
|
|
67
|
+
_IN_MEMORY_BUCKETS[key] = (count, reset_at)
|
|
68
|
+
remaining = max(0, max_requests - count)
|
|
69
|
+
retry_after = max(0, int(reset_at - now))
|
|
70
|
+
return count <= max_requests, remaining, retry_after
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def per_user_rate_limit(
|
|
74
|
+
max_requests: int,
|
|
75
|
+
window_seconds: int,
|
|
76
|
+
scope: str = "",
|
|
77
|
+
) -> Callable:
|
|
78
|
+
"""
|
|
79
|
+
FastAPI dependency factory for per-user/API-key rate limiting.
|
|
80
|
+
|
|
81
|
+
Identifies the caller by:
|
|
82
|
+
1. Authenticated user ID (from JWT or API key resolved by a prior auth dependency)
|
|
83
|
+
2. Client IP address when the endpoint is unauthenticated
|
|
84
|
+
|
|
85
|
+
The ``scope`` argument acts as the rate-limit bucket label — use it to group
|
|
86
|
+
endpoints into the same limit bucket (e.g. "auth:login") or leave it empty
|
|
87
|
+
to scope per endpoint path.
|
|
88
|
+
|
|
89
|
+
Example::
|
|
90
|
+
|
|
91
|
+
@router.post("/run", ...)
|
|
92
|
+
def run(
|
|
93
|
+
_rl=Depends(per_user_rate_limit(20, 60, scope="execution:run")),
|
|
94
|
+
current_user=Depends(require_auth_with_scopes({"execution:write"})),
|
|
95
|
+
):
|
|
96
|
+
...
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def _dependency(request: Request) -> None:
|
|
100
|
+
# Resolve identity: prefer authenticated user ID, fall back to IP.
|
|
101
|
+
user = getattr(request.state, "rate_limit_user", None)
|
|
102
|
+
if user is not None:
|
|
103
|
+
identity = f"u:{user.id}"
|
|
104
|
+
else:
|
|
105
|
+
identity = f"ip:{_client_ip(request)}"
|
|
106
|
+
|
|
107
|
+
bucket = scope or request.url.path
|
|
108
|
+
key = f"ratelimit:{identity}:{bucket}"
|
|
109
|
+
allowed, remaining, retry_after = _redis_rate_check(key, max_requests, window_seconds)
|
|
110
|
+
|
|
111
|
+
request.state.__dict__.setdefault("rate_limit_headers", {})
|
|
112
|
+
request.state.rate_limit_headers.update(
|
|
113
|
+
{
|
|
114
|
+
"X-RateLimit-Limit": str(max_requests),
|
|
115
|
+
"X-RateLimit-Remaining": str(remaining),
|
|
116
|
+
"X-RateLimit-Window": str(window_seconds),
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if not allowed:
|
|
121
|
+
logger.info(
|
|
122
|
+
"rate_limit_exceeded identity=%s scope=%s path=%s",
|
|
123
|
+
identity,
|
|
124
|
+
bucket,
|
|
125
|
+
request.url.path,
|
|
126
|
+
)
|
|
127
|
+
raise HTTPException(
|
|
128
|
+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
129
|
+
detail="Rate limit exceeded. Please slow down.",
|
|
130
|
+
headers={
|
|
131
|
+
"X-RateLimit-Limit": str(max_requests),
|
|
132
|
+
"X-RateLimit-Remaining": "0",
|
|
133
|
+
"Retry-After": str(retry_after),
|
|
134
|
+
},
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
return _dependency
|
|
@@ -8,7 +8,59 @@ Examples: Script generation, test execution, file processing, etc.
|
|
|
8
8
|
from datetime import datetime, timedelta, timezone
|
|
9
9
|
import logging
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
try:
|
|
12
|
+
from celery import shared_task
|
|
13
|
+
except ModuleNotFoundError: # pragma: no cover - fallback for test environments without celery installed
|
|
14
|
+
from types import SimpleNamespace
|
|
15
|
+
|
|
16
|
+
class _TaskWrapper:
|
|
17
|
+
def __init__(self, func, *, bind: bool = False, max_retries: int | None = None):
|
|
18
|
+
self._func = func
|
|
19
|
+
self.bind = bind
|
|
20
|
+
self.max_retries = max_retries if max_retries is not None else 0
|
|
21
|
+
|
|
22
|
+
def _self_proxy(self):
|
|
23
|
+
return SimpleNamespace(
|
|
24
|
+
request=SimpleNamespace(retries=0),
|
|
25
|
+
max_retries=self.max_retries,
|
|
26
|
+
retry=self._retry,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def _retry(self, exc=None, **kwargs):
|
|
30
|
+
if exc is not None:
|
|
31
|
+
raise exc
|
|
32
|
+
raise RuntimeError("Celery retry requested")
|
|
33
|
+
|
|
34
|
+
def run(self, *args, **kwargs):
|
|
35
|
+
if self.bind:
|
|
36
|
+
return self._func(self._self_proxy(), *args, **kwargs)
|
|
37
|
+
return self._func(*args, **kwargs)
|
|
38
|
+
|
|
39
|
+
def delay(self, *args, **kwargs):
|
|
40
|
+
return SimpleNamespace(id="stub-task", result=self.run(*args, **kwargs))
|
|
41
|
+
|
|
42
|
+
def apply(self, args=None, kwargs=None):
|
|
43
|
+
args = args or ()
|
|
44
|
+
kwargs = kwargs or {}
|
|
45
|
+
return SimpleNamespace(result=self.run(*args, **kwargs))
|
|
46
|
+
|
|
47
|
+
def __call__(self, *args, **kwargs):
|
|
48
|
+
return self.run(*args, **kwargs)
|
|
49
|
+
|
|
50
|
+
def shared_task(*decorator_args, **decorator_kwargs):
|
|
51
|
+
def decorator(func):
|
|
52
|
+
wrapper = _TaskWrapper(
|
|
53
|
+
func,
|
|
54
|
+
bind=bool(decorator_kwargs.get("bind", False)),
|
|
55
|
+
max_retries=decorator_kwargs.get("max_retries"),
|
|
56
|
+
)
|
|
57
|
+
wrapper.__name__ = getattr(func, "__name__", "task")
|
|
58
|
+
wrapper.__doc__ = getattr(func, "__doc__", None)
|
|
59
|
+
return wrapper
|
|
60
|
+
|
|
61
|
+
if decorator_args and callable(decorator_args[0]) and len(decorator_args) == 1 and not decorator_kwargs:
|
|
62
|
+
return decorator(decorator_args[0])
|
|
63
|
+
return decorator
|
|
12
64
|
from app.config import settings
|
|
13
65
|
from app.database import SessionLocal
|
|
14
66
|
from app.models.execution_job import ExecutionJob, ExecutionJobStatus, can_transition
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scriptgini"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.6.2"
|
|
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"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scriptgini
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.6.2
|
|
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.
|
|
19
|
+
Current release: v1.6.2 (Patch release - API contract guardrails, Celery test-environment fallback, 273/273 tests passing, 100% statement coverage)
|
|
20
20
|
|
|
21
21
|
---
|
|
22
22
|
|
|
@@ -423,9 +423,33 @@ A CI gate is configured in `.github/workflows/quality-gate.yml` to enforce the s
|
|
|
423
423
|
|
|
424
424
|
## Security Notes
|
|
425
425
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
426
|
+
ScriptGini ships with a layered security model that is active in the default configuration:
|
|
427
|
+
|
|
428
|
+
### Authentication & Authorization
|
|
429
|
+
- **JWT Bearer tokens** (access + refresh, configurable expiry) protect all non-public endpoints
|
|
430
|
+
- **API keys** (hashed storage, named scopes, revocable) enable CI/CD pipelines without sharing user credentials
|
|
431
|
+
- **RBAC** — four project roles (owner / admin / member / viewer) control read and write access per project
|
|
432
|
+
- All auth events (login, register, API-key create/revoke, forbidden access) are written to structured security audit logs
|
|
433
|
+
|
|
434
|
+
### Network Controls
|
|
435
|
+
- **CORS** — only origins listed in `CORS_ALLOWED_ORIGINS` (env var) are permitted; wildcard `*` is never set in production configs
|
|
436
|
+
- **Rate limiting** — two layers:
|
|
437
|
+
- *Global middleware*: 120 req / 60 s per IP for all non-exempt paths (configurable)
|
|
438
|
+
- *Per-user/API-key*: tighter limits on sensitive actions (login: 10/min, register: 5/min, execution: 20/min, bulk jobs: 10/min)
|
|
439
|
+
- Standard `X-RateLimit-*` and `Retry-After` headers are included on all responses
|
|
440
|
+
|
|
441
|
+
### Execution Isolation
|
|
442
|
+
- Scripts run inside a **constrained runner** — only an explicit allowlist of safe import roots is permitted
|
|
443
|
+
- Runtime attribute calls are blocked (`system`, `popen`, `connect`, etc.)
|
|
444
|
+
- `__builtins__`, `__globals__`, and introspection symbols are blocked by name
|
|
445
|
+
- Execution environment variables are narrowed to an explicit `EXECUTION_ENV_ALLOWED_KEYS` allowlist
|
|
446
|
+
- Hard + soft execution timeouts enforce maximum run duration
|
|
447
|
+
|
|
448
|
+
### Secrets & Config
|
|
449
|
+
- `.env` is git-ignored — never commit API keys or `JWT_SECRET_KEY`
|
|
450
|
+
- All secrets are consumed via `pydantic-settings` env-var injection; no hardcoded defaults are safe for production
|
|
451
|
+
- `JWT_SECRET_KEY` **must** be replaced with a cryptographically random value before deployment
|
|
452
|
+
- The UI performs client-side validation only; the agent never makes unsolicited live requests to the AUT
|
|
429
453
|
|
|
430
454
|
## Change Reports
|
|
431
455
|
|
|
@@ -447,6 +471,7 @@ The project follows an **enterprise-grade development roadmap** with 6 sprints c
|
|
|
447
471
|
| **Sprint 4** | Security & Hardening | 30-36pts | ✅ Hardening increment completed (isolation + negative tests + audit controls) |
|
|
448
472
|
| **Sprint 5** | Reporting & Analytics | 28-34pts | ✅ Completed (Reports APIs, trends/flakiness, retention cleanup) |
|
|
449
473
|
| **Sprint 6** | Coverage Analytics, Script Lifecycle, and DX | 24-30pts | ✅ Completed (coverage analytics, refactor/version history/diff/rollback, 100% quality gate) |
|
|
474
|
+
| **Sprint 7** | Hardening Completion + Developer Experience | 20-24pts | ✅ Completed (per-user rate limiting, analytics indexes, OpenAPI scope docs, 271/271 tests, 100% coverage) |
|
|
450
475
|
|
|
451
476
|
---
|
|
452
477
|
|
|
@@ -53,16 +53,19 @@ app/services/api_key.py
|
|
|
53
53
|
app/services/auth.py
|
|
54
54
|
app/services/auth_dependencies.py
|
|
55
55
|
app/services/git_export.py
|
|
56
|
+
app/services/rate_limit.py
|
|
56
57
|
app/services/rbac.py
|
|
57
58
|
scriptgini.egg-info/PKG-INFO
|
|
58
59
|
scriptgini.egg-info/SOURCES.txt
|
|
59
60
|
scriptgini.egg-info/dependency_links.txt
|
|
60
61
|
scriptgini.egg-info/top_level.txt
|
|
61
62
|
tests/test_api.py
|
|
63
|
+
tests/test_api_contracts.py
|
|
62
64
|
tests/test_auth.py
|
|
63
65
|
tests/test_coverage.py
|
|
64
66
|
tests/test_infra_services_coverage.py
|
|
65
67
|
tests/test_sprint2_rbac.py
|
|
66
68
|
tests/test_sprint3_execution.py
|
|
67
69
|
tests/test_sprint5_reporting_analytics.py
|
|
68
|
-
tests/test_sprint6_coverage_lifecycle.py
|
|
70
|
+
tests/test_sprint6_coverage_lifecycle.py
|
|
71
|
+
tests/test_sprint7_rate_limit.py
|