scriptgini 1.0.7__tar.gz → 1.2.0__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.0.7 → scriptgini-1.2.0}/PKG-INFO +3 -3
- {scriptgini-1.0.7 → scriptgini-1.2.0}/README.md +2 -2
- scriptgini-1.2.0/app/__init__.py +3 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/main.py +4 -2
- scriptgini-1.2.0/app/models/membership.py +54 -0
- scriptgini-1.2.0/app/models/organization.py +24 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/models/project.py +3 -1
- scriptgini-1.2.0/app/routers/organizations.py +36 -0
- scriptgini-1.2.0/app/routers/projects.py +113 -0
- scriptgini-1.2.0/app/schemas/membership.py +22 -0
- scriptgini-1.2.0/app/schemas/organization.py +19 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/schemas/project.py +3 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/services/auth_dependencies.py +19 -0
- scriptgini-1.2.0/app/services/rbac.py +118 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/pyproject.toml +1 -1
- {scriptgini-1.0.7 → scriptgini-1.2.0}/scriptgini.egg-info/PKG-INFO +3 -3
- {scriptgini-1.0.7 → scriptgini-1.2.0}/scriptgini.egg-info/SOURCES.txt +8 -1
- scriptgini-1.2.0/tests/test_sprint2_rbac.py +295 -0
- scriptgini-1.0.7/app/routers/projects.py +0 -51
- scriptgini-1.0.7/app/schemas/__init__.py +0 -0
- {scriptgini-1.0.7/app → scriptgini-1.2.0/app/agents}/__init__.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/agents/prompts.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/agents/script_gini_agent.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/cache.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/celery_app.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/config.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/database.py +0 -0
- {scriptgini-1.0.7/app/agents → scriptgini-1.2.0/app/llm}/__init__.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/llm/provider.py +0 -0
- {scriptgini-1.0.7/app/llm → scriptgini-1.2.0/app/models}/__init__.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/models/api_key.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/models/bulk_job.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/models/generated_script.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/models/script_run.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/models/test_case.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/models/user.py +0 -0
- {scriptgini-1.0.7/app/models → scriptgini-1.2.0/app/routers}/__init__.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/routers/analytics.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/routers/api_key.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/routers/auth.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/routers/bulk_jobs.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/routers/demo.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/routers/scripts.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/routers/test_cases.py +0 -0
- {scriptgini-1.0.7/app/routers → scriptgini-1.2.0/app/schemas}/__init__.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/schemas/analytics.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/schemas/api_key.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/schemas/auth.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/schemas/bulk_job.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/schemas/generated_script.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/schemas/test_case.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/services/api_key.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/services/auth.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/services/git_export.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/app/tasks.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/scriptgini.egg-info/dependency_links.txt +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/scriptgini.egg-info/top_level.txt +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/setup.cfg +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/tests/test_api.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/tests/test_auth.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/tests/test_coverage.py +0 -0
- {scriptgini-1.0.7 → scriptgini-1.2.0}/tests/test_infra_services_coverage.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scriptgini
|
|
3
|
-
Version: 1.0
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Agentic AI system that converts functional test cases into automation test scripts.
|
|
5
5
|
Author: ScriptGini Team
|
|
6
6
|
License: Proprietary
|
|
@@ -436,8 +436,8 @@ The project follows an **enterprise-grade development roadmap** with 6 sprints c
|
|
|
436
436
|
|
|
437
437
|
| Sprint | Focus | Effort | Status |
|
|
438
438
|
|--------|-------|--------|--------|
|
|
439
|
-
| **Sprint 1** | IAM Core | 30-36pts |
|
|
440
|
-
| **Sprint 2** | RBAC + Multi-Tenancy | 32-38pts |
|
|
439
|
+
| **Sprint 1** | IAM Core | 30-36pts | 🟡 Core delivered (auth hardening pending) |
|
|
440
|
+
| **Sprint 2** | RBAC + Multi-Tenancy | 32-38pts | 🟡 Core delivered (RBAC hardening pending) |
|
|
441
441
|
| **Sprint 3** | Durable Execution | 34-40pts | 🔲 Pending (Redis + Celery/Arq setup) |
|
|
442
442
|
| **Sprint 4** | Security & Hardening | 30-36pts | 🔲 Pending (Container sandbox, audit logging) |
|
|
443
443
|
| **Sprint 5** | Reporting & Analytics | 28-34pts | 🔲 Pending (Artifact storage, dashboards) |
|
|
@@ -422,8 +422,8 @@ The project follows an **enterprise-grade development roadmap** with 6 sprints c
|
|
|
422
422
|
|
|
423
423
|
| Sprint | Focus | Effort | Status |
|
|
424
424
|
|--------|-------|--------|--------|
|
|
425
|
-
| **Sprint 1** | IAM Core | 30-36pts |
|
|
426
|
-
| **Sprint 2** | RBAC + Multi-Tenancy | 32-38pts |
|
|
425
|
+
| **Sprint 1** | IAM Core | 30-36pts | 🟡 Core delivered (auth hardening pending) |
|
|
426
|
+
| **Sprint 2** | RBAC + Multi-Tenancy | 32-38pts | 🟡 Core delivered (RBAC hardening pending) |
|
|
427
427
|
| **Sprint 3** | Durable Execution | 34-40pts | 🔲 Pending (Redis + Celery/Arq setup) |
|
|
428
428
|
| **Sprint 4** | Security & Hardening | 30-36pts | 🔲 Pending (Container sandbox, audit logging) |
|
|
429
429
|
| **Sprint 5** | Reporting & Analytics | 28-34pts | 🔲 Pending (Artifact storage, dashboards) |
|
|
@@ -7,9 +7,10 @@ from fastapi.responses import FileResponse
|
|
|
7
7
|
from fastapi.middleware.cors import CORSMiddleware
|
|
8
8
|
from fastapi.staticfiles import StaticFiles
|
|
9
9
|
|
|
10
|
+
from app import __version__
|
|
10
11
|
from app.config import settings
|
|
11
12
|
from app.llm.provider import get_llm_diagnostics
|
|
12
|
-
from app.routers import projects, test_cases, scripts, bulk_jobs, analytics, demo, auth, api_key
|
|
13
|
+
from app.routers import projects, test_cases, scripts, bulk_jobs, analytics, demo, auth, api_key, organizations
|
|
13
14
|
|
|
14
15
|
logging.basicConfig(level=logging.DEBUG if settings.DEBUG else logging.INFO)
|
|
15
16
|
logger = logging.getLogger(__name__)
|
|
@@ -36,7 +37,7 @@ app = FastAPI(
|
|
|
36
37
|
"Enterprise-grade Agentic AI system that converts functional test cases "
|
|
37
38
|
"into high-quality automation scripts."
|
|
38
39
|
),
|
|
39
|
-
version=
|
|
40
|
+
version=__version__,
|
|
40
41
|
lifespan=lifespan,
|
|
41
42
|
)
|
|
42
43
|
|
|
@@ -56,6 +57,7 @@ app.include_router(analytics.router, prefix="/api/v1")
|
|
|
56
57
|
app.include_router(demo.router, prefix="/api/v1")
|
|
57
58
|
app.include_router(auth.router, prefix="/api/v1")
|
|
58
59
|
app.include_router(api_key.router, prefix="/api/v1")
|
|
60
|
+
app.include_router(organizations.router, prefix="/api/v1")
|
|
59
61
|
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
|
60
62
|
|
|
61
63
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import enum
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import DateTime, Boolean, ForeignKey, Enum as SAEnum, UniqueConstraint
|
|
5
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
6
|
+
|
|
7
|
+
from app.database import Base
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Role(str, enum.Enum):
|
|
11
|
+
owner = "owner"
|
|
12
|
+
admin = "admin"
|
|
13
|
+
member = "member"
|
|
14
|
+
viewer = "viewer"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OrganizationMembership(Base):
|
|
18
|
+
__tablename__ = "organization_memberships"
|
|
19
|
+
__table_args__ = (UniqueConstraint("organization_id", "user_id", name="uq_org_membership_org_user"),)
|
|
20
|
+
|
|
21
|
+
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
|
22
|
+
organization_id: Mapped[int] = mapped_column(ForeignKey("organizations.id"), nullable=False, index=True)
|
|
23
|
+
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
|
|
24
|
+
role: Mapped[Role] = mapped_column(SAEnum(Role), nullable=False, default=Role.member)
|
|
25
|
+
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
|
26
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
27
|
+
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
|
28
|
+
)
|
|
29
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
30
|
+
DateTime(timezone=True),
|
|
31
|
+
default=lambda: datetime.now(timezone.utc),
|
|
32
|
+
onupdate=lambda: datetime.now(timezone.utc),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ProjectMembership(Base):
|
|
38
|
+
__tablename__ = "project_memberships"
|
|
39
|
+
__table_args__ = (UniqueConstraint("project_id", "user_id", name="uq_project_membership_project_user"),)
|
|
40
|
+
|
|
41
|
+
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
|
42
|
+
project_id: Mapped[int] = mapped_column(ForeignKey("projects.id"), nullable=False, index=True)
|
|
43
|
+
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
|
|
44
|
+
role: Mapped[Role] = mapped_column(SAEnum(Role), nullable=False, default=Role.member)
|
|
45
|
+
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
|
46
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
47
|
+
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
|
48
|
+
)
|
|
49
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
50
|
+
DateTime(timezone=True),
|
|
51
|
+
default=lambda: datetime.now(timezone.utc),
|
|
52
|
+
onupdate=lambda: datetime.now(timezone.utc),
|
|
53
|
+
)
|
|
54
|
+
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import String, Text, DateTime, ForeignKey
|
|
4
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
5
|
+
|
|
6
|
+
from app.database import Base
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Organization(Base):
|
|
10
|
+
__tablename__ = "organizations"
|
|
11
|
+
|
|
12
|
+
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
|
13
|
+
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
|
|
14
|
+
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
15
|
+
created_by_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
|
|
16
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
17
|
+
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
|
18
|
+
)
|
|
19
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
20
|
+
DateTime(timezone=True),
|
|
21
|
+
default=lambda: datetime.now(timezone.utc),
|
|
22
|
+
onupdate=lambda: datetime.now(timezone.utc),
|
|
23
|
+
)
|
|
24
|
+
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import enum
|
|
2
2
|
from datetime import datetime, timezone
|
|
3
3
|
|
|
4
|
-
from sqlalchemy import String, Text, DateTime, Enum as SAEnum
|
|
4
|
+
from sqlalchemy import String, Text, DateTime, Enum as SAEnum, ForeignKey
|
|
5
5
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
6
6
|
|
|
7
7
|
from app.database import Base
|
|
@@ -26,6 +26,7 @@ class Project(Base):
|
|
|
26
26
|
__tablename__ = "projects"
|
|
27
27
|
|
|
28
28
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
|
29
|
+
organization_id: Mapped[int | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
|
|
29
30
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
30
31
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
31
32
|
aut_base_url: Mapped[str] = mapped_column(String(2048), nullable=False)
|
|
@@ -44,3 +45,4 @@ class Project(Base):
|
|
|
44
45
|
default=lambda: datetime.now(timezone.utc),
|
|
45
46
|
onupdate=lambda: datetime.now(timezone.utc),
|
|
46
47
|
)
|
|
48
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
2
|
+
from sqlalchemy.exc import IntegrityError
|
|
3
|
+
from sqlalchemy.orm import Session
|
|
4
|
+
|
|
5
|
+
from app.database import get_db
|
|
6
|
+
from app.schemas.organization import OrganizationCreate, OrganizationResponse
|
|
7
|
+
from app.services.auth_dependencies import require_auth_with_scopes
|
|
8
|
+
from app.services import rbac as rbac_service
|
|
9
|
+
|
|
10
|
+
router = APIRouter(prefix="/organizations", tags=["Organizations"])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@router.post("", response_model=OrganizationResponse, status_code=status.HTTP_201_CREATED)
|
|
14
|
+
def create_organization(
|
|
15
|
+
payload: OrganizationCreate,
|
|
16
|
+
current_user=Depends(require_auth_with_scopes({"org:write"})),
|
|
17
|
+
db: Session = Depends(get_db),
|
|
18
|
+
):
|
|
19
|
+
try:
|
|
20
|
+
return rbac_service.create_organization(
|
|
21
|
+
db=db,
|
|
22
|
+
user_id=current_user.id,
|
|
23
|
+
name=payload.name,
|
|
24
|
+
description=payload.description,
|
|
25
|
+
)
|
|
26
|
+
except IntegrityError as exc:
|
|
27
|
+
db.rollback()
|
|
28
|
+
raise HTTPException(status_code=400, detail="Organization already exists") from exc
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@router.get("", response_model=list[OrganizationResponse])
|
|
32
|
+
def list_organizations(
|
|
33
|
+
current_user=Depends(require_auth_with_scopes({"org:read"})),
|
|
34
|
+
db: Session = Depends(get_db),
|
|
35
|
+
):
|
|
36
|
+
return rbac_service.list_user_organizations(db, current_user.id)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
2
|
+
from sqlalchemy.orm import Session
|
|
3
|
+
|
|
4
|
+
from app.database import get_db
|
|
5
|
+
from app.models.project import Project
|
|
6
|
+
from app.schemas.membership import ProjectMemberResponse, ProjectMemberUpsertRequest
|
|
7
|
+
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse
|
|
8
|
+
from app.services import rbac as rbac_service
|
|
9
|
+
from app.services.auth_dependencies import require_auth_with_scopes
|
|
10
|
+
|
|
11
|
+
router = APIRouter(prefix="/projects", tags=["Projects"])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _ensure_project_manager(db: Session, project_id: int, user_id: int) -> None:
|
|
15
|
+
existing_members = rbac_service.list_project_members(db, project_id)
|
|
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")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@router.post("/", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
|
|
25
|
+
def create_project(payload: ProjectCreate, db: Session = Depends(get_db)):
|
|
26
|
+
project = Project(**payload.model_dump())
|
|
27
|
+
db.add(project)
|
|
28
|
+
db.commit()
|
|
29
|
+
db.refresh(project)
|
|
30
|
+
return project
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@router.get("/", response_model=list[ProjectResponse])
|
|
34
|
+
def list_projects(skip: int = 0, limit: int = 50, db: Session = Depends(get_db)):
|
|
35
|
+
return db.query(Project).offset(skip).limit(limit).all()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@router.get("/{project_id}", response_model=ProjectResponse)
|
|
39
|
+
def get_project(project_id: int, db: Session = Depends(get_db)):
|
|
40
|
+
project = db.query(Project).filter(Project.id == project_id).first()
|
|
41
|
+
if not project:
|
|
42
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
43
|
+
return project
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@router.patch("/{project_id}", response_model=ProjectResponse)
|
|
47
|
+
def update_project(project_id: int, payload: ProjectUpdate, db: Session = Depends(get_db)):
|
|
48
|
+
project = db.query(Project).filter(Project.id == project_id).first()
|
|
49
|
+
if not project:
|
|
50
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
51
|
+
for field, value in payload.model_dump(exclude_none=True).items():
|
|
52
|
+
setattr(project, field, value)
|
|
53
|
+
db.commit()
|
|
54
|
+
db.refresh(project)
|
|
55
|
+
return project
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
59
|
+
def delete_project(project_id: int, db: Session = Depends(get_db)):
|
|
60
|
+
project = db.query(Project).filter(Project.id == project_id).first()
|
|
61
|
+
if not project:
|
|
62
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
63
|
+
db.delete(project)
|
|
64
|
+
db.commit()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@router.get("/{project_id}/members", response_model=list[ProjectMemberResponse])
|
|
68
|
+
def list_project_members(
|
|
69
|
+
project_id: int,
|
|
70
|
+
current_user=Depends(require_auth_with_scopes({"members:read"})),
|
|
71
|
+
db: Session = Depends(get_db),
|
|
72
|
+
):
|
|
73
|
+
rbac_service.ensure_project_exists(db, project_id)
|
|
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")
|
|
77
|
+
return rbac_service.list_project_members(db, project_id)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@router.put("/{project_id}/members/{user_id}", response_model=ProjectMemberResponse)
|
|
81
|
+
def upsert_project_member(
|
|
82
|
+
project_id: int,
|
|
83
|
+
user_id: int,
|
|
84
|
+
payload: ProjectMemberUpsertRequest,
|
|
85
|
+
current_user=Depends(require_auth_with_scopes({"members:write"})),
|
|
86
|
+
db: Session = Depends(get_db),
|
|
87
|
+
):
|
|
88
|
+
rbac_service.ensure_project_exists(db, project_id)
|
|
89
|
+
_ensure_project_manager(db, project_id, current_user.id)
|
|
90
|
+
|
|
91
|
+
return rbac_service.upsert_project_member(
|
|
92
|
+
db=db,
|
|
93
|
+
project_id=project_id,
|
|
94
|
+
user_id=user_id,
|
|
95
|
+
role=payload.role,
|
|
96
|
+
is_active=payload.is_active,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@router.delete("/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
101
|
+
def delete_project_member(
|
|
102
|
+
project_id: int,
|
|
103
|
+
user_id: int,
|
|
104
|
+
current_user=Depends(require_auth_with_scopes({"members:write"})),
|
|
105
|
+
db: Session = Depends(get_db),
|
|
106
|
+
):
|
|
107
|
+
rbac_service.ensure_project_exists(db, project_id)
|
|
108
|
+
_ensure_project_manager(db, project_id, current_user.id)
|
|
109
|
+
|
|
110
|
+
if not rbac_service.remove_project_member(db, project_id, user_id):
|
|
111
|
+
raise HTTPException(status_code=404, detail="Project member not found")
|
|
112
|
+
|
|
113
|
+
return None
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from app.models.membership import Role
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ProjectMemberUpsertRequest(BaseModel):
|
|
9
|
+
role: Role
|
|
10
|
+
is_active: bool = True
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProjectMemberResponse(BaseModel):
|
|
14
|
+
id: int
|
|
15
|
+
project_id: int
|
|
16
|
+
user_id: int
|
|
17
|
+
role: Role
|
|
18
|
+
is_active: bool
|
|
19
|
+
created_at: datetime
|
|
20
|
+
updated_at: datetime
|
|
21
|
+
|
|
22
|
+
model_config = {"from_attributes": True}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OrganizationCreate(BaseModel):
|
|
7
|
+
name: str = Field(..., min_length=2, max_length=255)
|
|
8
|
+
description: str | None = None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OrganizationResponse(BaseModel):
|
|
12
|
+
id: int
|
|
13
|
+
name: str
|
|
14
|
+
description: str | None
|
|
15
|
+
created_by_user_id: int
|
|
16
|
+
created_at: datetime
|
|
17
|
+
updated_at: datetime
|
|
18
|
+
|
|
19
|
+
model_config = {"from_attributes": True}
|
|
@@ -5,6 +5,7 @@ from app.models.project import TestFramework, SelectorPreference
|
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
class ProjectCreate(BaseModel):
|
|
8
|
+
organization_id: int | None = None
|
|
8
9
|
name: str = Field(..., max_length=255)
|
|
9
10
|
description: str | None = None
|
|
10
11
|
aut_base_url: str = Field(..., description="Base URL of the Application Under Test")
|
|
@@ -14,6 +15,7 @@ class ProjectCreate(BaseModel):
|
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
class ProjectUpdate(BaseModel):
|
|
18
|
+
organization_id: int | None = None
|
|
17
19
|
name: str | None = Field(None, max_length=255)
|
|
18
20
|
description: str | None = None
|
|
19
21
|
aut_base_url: str | None = None
|
|
@@ -24,6 +26,7 @@ class ProjectUpdate(BaseModel):
|
|
|
24
26
|
|
|
25
27
|
class ProjectResponse(BaseModel):
|
|
26
28
|
id: int
|
|
29
|
+
organization_id: int | None
|
|
27
30
|
name: str
|
|
28
31
|
description: str | None
|
|
29
32
|
aut_base_url: str
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
|
|
1
3
|
from fastapi import Depends, HTTPException, status
|
|
2
4
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
3
5
|
from sqlalchemy.orm import Session
|
|
@@ -89,3 +91,20 @@ def get_current_user_or_api_key(
|
|
|
89
91
|
detail="Invalid or expired credentials",
|
|
90
92
|
headers={"WWW-Authenticate": "Bearer"},
|
|
91
93
|
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def require_auth_with_scopes(required_scopes: set[str] | None = None) -> Callable:
|
|
97
|
+
required_scopes = required_scopes or set()
|
|
98
|
+
|
|
99
|
+
def _dependency(current_user=Depends(get_current_user_or_api_key)):
|
|
100
|
+
api_key = getattr(current_user, "_api_key", None)
|
|
101
|
+
if api_key is not None:
|
|
102
|
+
granted_scopes = set(api_key.scopes or [])
|
|
103
|
+
if not required_scopes.issubset(granted_scopes):
|
|
104
|
+
raise HTTPException(
|
|
105
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
106
|
+
detail="API key does not have required scopes",
|
|
107
|
+
)
|
|
108
|
+
return current_user
|
|
109
|
+
|
|
110
|
+
return _dependency
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from sqlalchemy.orm import Session
|
|
2
|
+
|
|
3
|
+
from app.models.membership import ProjectMembership, Role, OrganizationMembership
|
|
4
|
+
from app.models.organization import Organization
|
|
5
|
+
from app.models.project import Project
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
MANAGER_ROLES = {Role.owner, Role.admin}
|
|
9
|
+
READ_ROLES = {Role.owner, Role.admin, Role.member, Role.viewer}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def create_organization(db: Session, user_id: int, name: str, description: str | None = None) -> Organization:
|
|
13
|
+
organization = Organization(name=name, description=description, created_by_user_id=user_id)
|
|
14
|
+
db.add(organization)
|
|
15
|
+
db.flush()
|
|
16
|
+
|
|
17
|
+
owner_membership = OrganizationMembership(
|
|
18
|
+
organization_id=organization.id,
|
|
19
|
+
user_id=user_id,
|
|
20
|
+
role=Role.owner,
|
|
21
|
+
is_active=True,
|
|
22
|
+
)
|
|
23
|
+
db.add(owner_membership)
|
|
24
|
+
db.commit()
|
|
25
|
+
db.refresh(organization)
|
|
26
|
+
return organization
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def list_user_organizations(db: Session, user_id: int) -> list[Organization]:
|
|
30
|
+
return (
|
|
31
|
+
db.query(Organization)
|
|
32
|
+
.join(OrganizationMembership, OrganizationMembership.organization_id == Organization.id)
|
|
33
|
+
.filter(
|
|
34
|
+
OrganizationMembership.user_id == user_id,
|
|
35
|
+
OrganizationMembership.is_active.is_(True),
|
|
36
|
+
)
|
|
37
|
+
.all()
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_user_project_membership(db: Session, project_id: int, user_id: int) -> ProjectMembership | None:
|
|
42
|
+
return (
|
|
43
|
+
db.query(ProjectMembership)
|
|
44
|
+
.filter(
|
|
45
|
+
ProjectMembership.project_id == project_id,
|
|
46
|
+
ProjectMembership.user_id == user_id,
|
|
47
|
+
ProjectMembership.is_active.is_(True),
|
|
48
|
+
)
|
|
49
|
+
.first()
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def ensure_project_exists(db: Session, project_id: int) -> Project:
|
|
54
|
+
project = db.query(Project).filter(Project.id == project_id).first()
|
|
55
|
+
if not project:
|
|
56
|
+
from fastapi import HTTPException
|
|
57
|
+
|
|
58
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
59
|
+
return project
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def list_project_members(db: Session, project_id: int) -> list[ProjectMembership]:
|
|
63
|
+
return (
|
|
64
|
+
db.query(ProjectMembership)
|
|
65
|
+
.filter(ProjectMembership.project_id == project_id)
|
|
66
|
+
.order_by(ProjectMembership.id.asc())
|
|
67
|
+
.all()
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def upsert_project_member(
|
|
72
|
+
db: Session,
|
|
73
|
+
project_id: int,
|
|
74
|
+
user_id: int,
|
|
75
|
+
role: Role,
|
|
76
|
+
is_active: bool,
|
|
77
|
+
) -> ProjectMembership:
|
|
78
|
+
membership = (
|
|
79
|
+
db.query(ProjectMembership)
|
|
80
|
+
.filter(
|
|
81
|
+
ProjectMembership.project_id == project_id,
|
|
82
|
+
ProjectMembership.user_id == user_id,
|
|
83
|
+
)
|
|
84
|
+
.first()
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if membership is None:
|
|
88
|
+
membership = ProjectMembership(
|
|
89
|
+
project_id=project_id,
|
|
90
|
+
user_id=user_id,
|
|
91
|
+
role=role,
|
|
92
|
+
is_active=is_active,
|
|
93
|
+
)
|
|
94
|
+
db.add(membership)
|
|
95
|
+
else:
|
|
96
|
+
membership.role = role
|
|
97
|
+
membership.is_active = is_active
|
|
98
|
+
|
|
99
|
+
db.commit()
|
|
100
|
+
db.refresh(membership)
|
|
101
|
+
return membership
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def remove_project_member(db: Session, project_id: int, user_id: int) -> bool:
|
|
105
|
+
membership = (
|
|
106
|
+
db.query(ProjectMembership)
|
|
107
|
+
.filter(
|
|
108
|
+
ProjectMembership.project_id == project_id,
|
|
109
|
+
ProjectMembership.user_id == user_id,
|
|
110
|
+
)
|
|
111
|
+
.first()
|
|
112
|
+
)
|
|
113
|
+
if not membership:
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
db.delete(membership)
|
|
117
|
+
db.commit()
|
|
118
|
+
return True
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scriptgini"
|
|
7
|
-
version = "1.0
|
|
7
|
+
version = "1.2.0"
|
|
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.0
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Agentic AI system that converts functional test cases into automation test scripts.
|
|
5
5
|
Author: ScriptGini Team
|
|
6
6
|
License: Proprietary
|
|
@@ -436,8 +436,8 @@ The project follows an **enterprise-grade development roadmap** with 6 sprints c
|
|
|
436
436
|
|
|
437
437
|
| Sprint | Focus | Effort | Status |
|
|
438
438
|
|--------|-------|--------|--------|
|
|
439
|
-
| **Sprint 1** | IAM Core | 30-36pts |
|
|
440
|
-
| **Sprint 2** | RBAC + Multi-Tenancy | 32-38pts |
|
|
439
|
+
| **Sprint 1** | IAM Core | 30-36pts | 🟡 Core delivered (auth hardening pending) |
|
|
440
|
+
| **Sprint 2** | RBAC + Multi-Tenancy | 32-38pts | 🟡 Core delivered (RBAC hardening pending) |
|
|
441
441
|
| **Sprint 3** | Durable Execution | 34-40pts | 🔲 Pending (Redis + Celery/Arq setup) |
|
|
442
442
|
| **Sprint 4** | Security & Hardening | 30-36pts | 🔲 Pending (Container sandbox, audit logging) |
|
|
443
443
|
| **Sprint 5** | Reporting & Analytics | 28-34pts | 🔲 Pending (Artifact storage, dashboards) |
|
|
@@ -16,6 +16,8 @@ app/models/__init__.py
|
|
|
16
16
|
app/models/api_key.py
|
|
17
17
|
app/models/bulk_job.py
|
|
18
18
|
app/models/generated_script.py
|
|
19
|
+
app/models/membership.py
|
|
20
|
+
app/models/organization.py
|
|
19
21
|
app/models/project.py
|
|
20
22
|
app/models/script_run.py
|
|
21
23
|
app/models/test_case.py
|
|
@@ -26,6 +28,7 @@ app/routers/api_key.py
|
|
|
26
28
|
app/routers/auth.py
|
|
27
29
|
app/routers/bulk_jobs.py
|
|
28
30
|
app/routers/demo.py
|
|
31
|
+
app/routers/organizations.py
|
|
29
32
|
app/routers/projects.py
|
|
30
33
|
app/routers/scripts.py
|
|
31
34
|
app/routers/test_cases.py
|
|
@@ -35,12 +38,15 @@ app/schemas/api_key.py
|
|
|
35
38
|
app/schemas/auth.py
|
|
36
39
|
app/schemas/bulk_job.py
|
|
37
40
|
app/schemas/generated_script.py
|
|
41
|
+
app/schemas/membership.py
|
|
42
|
+
app/schemas/organization.py
|
|
38
43
|
app/schemas/project.py
|
|
39
44
|
app/schemas/test_case.py
|
|
40
45
|
app/services/api_key.py
|
|
41
46
|
app/services/auth.py
|
|
42
47
|
app/services/auth_dependencies.py
|
|
43
48
|
app/services/git_export.py
|
|
49
|
+
app/services/rbac.py
|
|
44
50
|
scriptgini.egg-info/PKG-INFO
|
|
45
51
|
scriptgini.egg-info/SOURCES.txt
|
|
46
52
|
scriptgini.egg-info/dependency_links.txt
|
|
@@ -48,4 +54,5 @@ scriptgini.egg-info/top_level.txt
|
|
|
48
54
|
tests/test_api.py
|
|
49
55
|
tests/test_auth.py
|
|
50
56
|
tests/test_coverage.py
|
|
51
|
-
tests/test_infra_services_coverage.py
|
|
57
|
+
tests/test_infra_services_coverage.py
|
|
58
|
+
tests/test_sprint2_rbac.py
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from fastapi.testclient import TestClient
|
|
3
|
+
from sqlalchemy import create_engine
|
|
4
|
+
from sqlalchemy.orm import sessionmaker
|
|
5
|
+
from sqlalchemy.pool import StaticPool
|
|
6
|
+
|
|
7
|
+
import app.models.user # noqa: F401
|
|
8
|
+
import app.models.api_key # noqa: F401
|
|
9
|
+
import app.models.project # noqa: F401
|
|
10
|
+
import app.models.organization # noqa: F401
|
|
11
|
+
import app.models.membership # noqa: F401
|
|
12
|
+
|
|
13
|
+
from app.database import Base, get_db
|
|
14
|
+
from app.main import app as fastapi_app
|
|
15
|
+
|
|
16
|
+
TEST_DATABASE_URL = "sqlite:///:memory:"
|
|
17
|
+
|
|
18
|
+
engine = create_engine(
|
|
19
|
+
TEST_DATABASE_URL,
|
|
20
|
+
connect_args={"check_same_thread": False},
|
|
21
|
+
poolclass=StaticPool,
|
|
22
|
+
)
|
|
23
|
+
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture(autouse=True)
|
|
27
|
+
def setup_db():
|
|
28
|
+
Base.metadata.create_all(bind=engine)
|
|
29
|
+
|
|
30
|
+
def override_get_db():
|
|
31
|
+
db = TestingSessionLocal()
|
|
32
|
+
try:
|
|
33
|
+
yield db
|
|
34
|
+
finally:
|
|
35
|
+
db.close()
|
|
36
|
+
|
|
37
|
+
fastapi_app.dependency_overrides[get_db] = override_get_db
|
|
38
|
+
try:
|
|
39
|
+
yield
|
|
40
|
+
finally:
|
|
41
|
+
fastapi_app.dependency_overrides.clear()
|
|
42
|
+
Base.metadata.drop_all(bind=engine)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.fixture
|
|
46
|
+
def client():
|
|
47
|
+
return TestClient(fastapi_app)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _register_and_login(client: TestClient, email: str, password: str = "TestPassword123!") -> tuple[int, dict[str, str]]:
|
|
51
|
+
register_resp = client.post(
|
|
52
|
+
"/api/v1/auth/register",
|
|
53
|
+
json={"email": email, "password": password},
|
|
54
|
+
)
|
|
55
|
+
assert register_resp.status_code == 201
|
|
56
|
+
user_id = register_resp.json()["id"]
|
|
57
|
+
|
|
58
|
+
login_resp = client.post(
|
|
59
|
+
"/api/v1/auth/login",
|
|
60
|
+
json={"email": email, "password": password},
|
|
61
|
+
)
|
|
62
|
+
assert login_resp.status_code == 200
|
|
63
|
+
token = login_resp.json()["access_token"]
|
|
64
|
+
return user_id, {"Authorization": f"Bearer {token}"}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _create_project(client: TestClient) -> int:
|
|
68
|
+
resp = client.post(
|
|
69
|
+
"/api/v1/projects/",
|
|
70
|
+
json={
|
|
71
|
+
"name": "RBAC Project",
|
|
72
|
+
"aut_base_url": "https://example.com",
|
|
73
|
+
},
|
|
74
|
+
)
|
|
75
|
+
assert resp.status_code == 201
|
|
76
|
+
return resp.json()["id"]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TestOrganizationEndpoints:
|
|
80
|
+
def test_jwt_create_and_list_organizations(self, client: TestClient):
|
|
81
|
+
_, headers = _register_and_login(client, "org-admin@example.com")
|
|
82
|
+
|
|
83
|
+
create_resp = client.post(
|
|
84
|
+
"/api/v1/organizations",
|
|
85
|
+
headers=headers,
|
|
86
|
+
json={"name": "Acme QA", "description": "Org for QA"},
|
|
87
|
+
)
|
|
88
|
+
assert create_resp.status_code == 201
|
|
89
|
+
|
|
90
|
+
list_resp = client.get("/api/v1/organizations", headers=headers)
|
|
91
|
+
assert list_resp.status_code == 200
|
|
92
|
+
assert len(list_resp.json()) == 1
|
|
93
|
+
assert list_resp.json()[0]["name"] == "Acme QA"
|
|
94
|
+
|
|
95
|
+
def test_api_key_scope_enforced_on_organizations(self, client: TestClient):
|
|
96
|
+
_, headers = _register_and_login(client, "api-key-org@example.com")
|
|
97
|
+
|
|
98
|
+
create_key_resp = client.post(
|
|
99
|
+
"/api/v1/auth/api-keys",
|
|
100
|
+
headers=headers,
|
|
101
|
+
json={"name": "Org Writer", "scopes": ["org:write"]},
|
|
102
|
+
)
|
|
103
|
+
assert create_key_resp.status_code == 201
|
|
104
|
+
key_data = create_key_resp.json()
|
|
105
|
+
auth_header = {"Authorization": f"Bearer {key_data['id']}:{key_data['secret_key']}"}
|
|
106
|
+
|
|
107
|
+
create_org = client.post(
|
|
108
|
+
"/api/v1/organizations",
|
|
109
|
+
headers=auth_header,
|
|
110
|
+
json={"name": "Scoped Org", "description": "Scoped create"},
|
|
111
|
+
)
|
|
112
|
+
assert create_org.status_code == 201
|
|
113
|
+
|
|
114
|
+
list_orgs_forbidden = client.get("/api/v1/organizations", headers=auth_header)
|
|
115
|
+
assert list_orgs_forbidden.status_code == 403
|
|
116
|
+
|
|
117
|
+
def test_duplicate_organization_name_returns_400(self, client: TestClient):
|
|
118
|
+
_, headers = _register_and_login(client, "org-dup@example.com")
|
|
119
|
+
|
|
120
|
+
first = client.post(
|
|
121
|
+
"/api/v1/organizations",
|
|
122
|
+
headers=headers,
|
|
123
|
+
json={"name": "Duplicate Org", "description": "first"},
|
|
124
|
+
)
|
|
125
|
+
assert first.status_code == 201
|
|
126
|
+
|
|
127
|
+
second = client.post(
|
|
128
|
+
"/api/v1/organizations",
|
|
129
|
+
headers=headers,
|
|
130
|
+
json={"name": "Duplicate Org", "description": "second"},
|
|
131
|
+
)
|
|
132
|
+
assert second.status_code == 400
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class TestProjectMembersRBAC:
|
|
136
|
+
@pytest.mark.parametrize(
|
|
137
|
+
"role,expected_put,expected_delete",
|
|
138
|
+
[
|
|
139
|
+
("owner", 200, 204),
|
|
140
|
+
("admin", 200, 204),
|
|
141
|
+
("member", 403, 403),
|
|
142
|
+
("viewer", 403, 403),
|
|
143
|
+
],
|
|
144
|
+
)
|
|
145
|
+
def test_role_matrix_for_member_management(
|
|
146
|
+
self,
|
|
147
|
+
client: TestClient,
|
|
148
|
+
role: str,
|
|
149
|
+
expected_put: int,
|
|
150
|
+
expected_delete: int,
|
|
151
|
+
):
|
|
152
|
+
owner_id, owner_headers = _register_and_login(client, "owner@example.com")
|
|
153
|
+
actor_id, actor_headers = _register_and_login(client, f"actor-{role}@example.com")
|
|
154
|
+
target_id, _ = _register_and_login(client, f"target-{role}@example.com")
|
|
155
|
+
|
|
156
|
+
project_id = _create_project(client)
|
|
157
|
+
|
|
158
|
+
bootstrap_owner = client.put(
|
|
159
|
+
f"/api/v1/projects/{project_id}/members/{owner_id}",
|
|
160
|
+
headers=owner_headers,
|
|
161
|
+
json={"role": "owner", "is_active": True},
|
|
162
|
+
)
|
|
163
|
+
assert bootstrap_owner.status_code == 200
|
|
164
|
+
|
|
165
|
+
add_actor = client.put(
|
|
166
|
+
f"/api/v1/projects/{project_id}/members/{actor_id}",
|
|
167
|
+
headers=owner_headers,
|
|
168
|
+
json={"role": role, "is_active": True},
|
|
169
|
+
)
|
|
170
|
+
assert add_actor.status_code == 200
|
|
171
|
+
|
|
172
|
+
list_resp = client.get(
|
|
173
|
+
f"/api/v1/projects/{project_id}/members",
|
|
174
|
+
headers=actor_headers,
|
|
175
|
+
)
|
|
176
|
+
assert list_resp.status_code == 200
|
|
177
|
+
|
|
178
|
+
mutate_resp = client.put(
|
|
179
|
+
f"/api/v1/projects/{project_id}/members/{target_id}",
|
|
180
|
+
headers=actor_headers,
|
|
181
|
+
json={"role": "viewer", "is_active": True},
|
|
182
|
+
)
|
|
183
|
+
assert mutate_resp.status_code == expected_put
|
|
184
|
+
|
|
185
|
+
if expected_put == 200:
|
|
186
|
+
delete_resp = client.delete(
|
|
187
|
+
f"/api/v1/projects/{project_id}/members/{target_id}",
|
|
188
|
+
headers=actor_headers,
|
|
189
|
+
)
|
|
190
|
+
assert delete_resp.status_code == expected_delete
|
|
191
|
+
else:
|
|
192
|
+
# Non-manager roles should not be able to delete members either.
|
|
193
|
+
delete_resp = client.delete(
|
|
194
|
+
f"/api/v1/projects/{project_id}/members/{owner_id}",
|
|
195
|
+
headers=actor_headers,
|
|
196
|
+
)
|
|
197
|
+
assert delete_resp.status_code == expected_delete
|
|
198
|
+
|
|
199
|
+
def test_project_members_requires_scoped_api_key(self, client: TestClient):
|
|
200
|
+
owner_id, owner_headers = _register_and_login(client, "owner-api-key@example.com")
|
|
201
|
+
project_id = _create_project(client)
|
|
202
|
+
|
|
203
|
+
bootstrap_owner = client.put(
|
|
204
|
+
f"/api/v1/projects/{project_id}/members/{owner_id}",
|
|
205
|
+
headers=owner_headers,
|
|
206
|
+
json={"role": "owner", "is_active": True},
|
|
207
|
+
)
|
|
208
|
+
assert bootstrap_owner.status_code == 200
|
|
209
|
+
|
|
210
|
+
create_key_resp = client.post(
|
|
211
|
+
"/api/v1/auth/api-keys",
|
|
212
|
+
headers=owner_headers,
|
|
213
|
+
json={"name": "Read members", "scopes": ["members:read"]},
|
|
214
|
+
)
|
|
215
|
+
assert create_key_resp.status_code == 201
|
|
216
|
+
key_data = create_key_resp.json()
|
|
217
|
+
|
|
218
|
+
key_headers = {"Authorization": f"Bearer {key_data['id']}:{key_data['secret_key']}"}
|
|
219
|
+
list_resp = client.get(f"/api/v1/projects/{project_id}/members", headers=key_headers)
|
|
220
|
+
assert list_resp.status_code == 200
|
|
221
|
+
|
|
222
|
+
put_resp = client.put(
|
|
223
|
+
f"/api/v1/projects/{project_id}/members/{owner_id}",
|
|
224
|
+
headers=key_headers,
|
|
225
|
+
json={"role": "owner", "is_active": True},
|
|
226
|
+
)
|
|
227
|
+
assert put_resp.status_code == 403
|
|
228
|
+
|
|
229
|
+
def test_list_members_forbidden_for_non_member(self, client: TestClient):
|
|
230
|
+
owner_id, owner_headers = _register_and_login(client, "owner-forbidden@example.com")
|
|
231
|
+
_, outsider_headers = _register_and_login(client, "outsider@example.com")
|
|
232
|
+
project_id = _create_project(client)
|
|
233
|
+
|
|
234
|
+
bootstrap_owner = client.put(
|
|
235
|
+
f"/api/v1/projects/{project_id}/members/{owner_id}",
|
|
236
|
+
headers=owner_headers,
|
|
237
|
+
json={"role": "owner", "is_active": True},
|
|
238
|
+
)
|
|
239
|
+
assert bootstrap_owner.status_code == 200
|
|
240
|
+
|
|
241
|
+
forbidden = client.get(
|
|
242
|
+
f"/api/v1/projects/{project_id}/members",
|
|
243
|
+
headers=outsider_headers,
|
|
244
|
+
)
|
|
245
|
+
assert forbidden.status_code == 403
|
|
246
|
+
|
|
247
|
+
def test_list_members_missing_project_returns_404(self, client: TestClient):
|
|
248
|
+
_, headers = _register_and_login(client, "owner-missing-project@example.com")
|
|
249
|
+
resp = client.get("/api/v1/projects/99999/members", headers=headers)
|
|
250
|
+
assert resp.status_code == 404
|
|
251
|
+
|
|
252
|
+
def test_delete_missing_member_returns_404(self, client: TestClient):
|
|
253
|
+
owner_id, owner_headers = _register_and_login(client, "owner-delete-missing@example.com")
|
|
254
|
+
project_id = _create_project(client)
|
|
255
|
+
|
|
256
|
+
bootstrap_owner = client.put(
|
|
257
|
+
f"/api/v1/projects/{project_id}/members/{owner_id}",
|
|
258
|
+
headers=owner_headers,
|
|
259
|
+
json={"role": "owner", "is_active": True},
|
|
260
|
+
)
|
|
261
|
+
assert bootstrap_owner.status_code == 200
|
|
262
|
+
|
|
263
|
+
delete_missing = client.delete(
|
|
264
|
+
f"/api/v1/projects/{project_id}/members/99999",
|
|
265
|
+
headers=owner_headers,
|
|
266
|
+
)
|
|
267
|
+
assert delete_missing.status_code == 404
|
|
268
|
+
|
|
269
|
+
def test_upsert_existing_member_updates_role(self, client: TestClient):
|
|
270
|
+
owner_id, owner_headers = _register_and_login(client, "owner-update-member@example.com")
|
|
271
|
+
actor_id, _ = _register_and_login(client, "actor-update-member@example.com")
|
|
272
|
+
project_id = _create_project(client)
|
|
273
|
+
|
|
274
|
+
bootstrap_owner = client.put(
|
|
275
|
+
f"/api/v1/projects/{project_id}/members/{owner_id}",
|
|
276
|
+
headers=owner_headers,
|
|
277
|
+
json={"role": "owner", "is_active": True},
|
|
278
|
+
)
|
|
279
|
+
assert bootstrap_owner.status_code == 200
|
|
280
|
+
|
|
281
|
+
add_actor = client.put(
|
|
282
|
+
f"/api/v1/projects/{project_id}/members/{actor_id}",
|
|
283
|
+
headers=owner_headers,
|
|
284
|
+
json={"role": "viewer", "is_active": True},
|
|
285
|
+
)
|
|
286
|
+
assert add_actor.status_code == 200
|
|
287
|
+
assert add_actor.json()["role"] == "viewer"
|
|
288
|
+
|
|
289
|
+
update_actor = client.put(
|
|
290
|
+
f"/api/v1/projects/{project_id}/members/{actor_id}",
|
|
291
|
+
headers=owner_headers,
|
|
292
|
+
json={"role": "admin", "is_active": True},
|
|
293
|
+
)
|
|
294
|
+
assert update_actor.status_code == 200
|
|
295
|
+
assert update_actor.json()["role"] == "admin"
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
from fastapi import APIRouter, Depends, HTTPException, status
|
|
2
|
-
from sqlalchemy.orm import Session
|
|
3
|
-
|
|
4
|
-
from app.database import get_db
|
|
5
|
-
from app.models.project import Project
|
|
6
|
-
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse
|
|
7
|
-
|
|
8
|
-
router = APIRouter(prefix="/projects", tags=["Projects"])
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
@router.post("/", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
|
|
12
|
-
def create_project(payload: ProjectCreate, db: Session = Depends(get_db)):
|
|
13
|
-
project = Project(**payload.model_dump())
|
|
14
|
-
db.add(project)
|
|
15
|
-
db.commit()
|
|
16
|
-
db.refresh(project)
|
|
17
|
-
return project
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
@router.get("/", response_model=list[ProjectResponse])
|
|
21
|
-
def list_projects(skip: int = 0, limit: int = 50, db: Session = Depends(get_db)):
|
|
22
|
-
return db.query(Project).offset(skip).limit(limit).all()
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
@router.get("/{project_id}", response_model=ProjectResponse)
|
|
26
|
-
def get_project(project_id: int, db: Session = Depends(get_db)):
|
|
27
|
-
project = db.query(Project).filter(Project.id == project_id).first()
|
|
28
|
-
if not project:
|
|
29
|
-
raise HTTPException(status_code=404, detail="Project not found")
|
|
30
|
-
return project
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
@router.patch("/{project_id}", response_model=ProjectResponse)
|
|
34
|
-
def update_project(project_id: int, payload: ProjectUpdate, db: Session = Depends(get_db)):
|
|
35
|
-
project = db.query(Project).filter(Project.id == project_id).first()
|
|
36
|
-
if not project:
|
|
37
|
-
raise HTTPException(status_code=404, detail="Project not found")
|
|
38
|
-
for field, value in payload.model_dump(exclude_none=True).items():
|
|
39
|
-
setattr(project, field, value)
|
|
40
|
-
db.commit()
|
|
41
|
-
db.refresh(project)
|
|
42
|
-
return project
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
46
|
-
def delete_project(project_id: int, db: Session = Depends(get_db)):
|
|
47
|
-
project = db.query(Project).filter(Project.id == project_id).first()
|
|
48
|
-
if not project:
|
|
49
|
-
raise HTTPException(status_code=404, detail="Project not found")
|
|
50
|
-
db.delete(project)
|
|
51
|
-
db.commit()
|
|
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
|