diff --git a/.gitignore b/.gitignore index 8d5292d1..fcf494be 100644 --- a/.gitignore +++ b/.gitignore @@ -92,5 +92,6 @@ /web/**/thumb /web/**/sketch -# Prevent uploading DB files -*.sqlite* \ No newline at end of file +# Prevent some sensitive files from being committed +*.sqlite* +.env \ No newline at end of file diff --git a/.tool-versions b/.tool-versions index d7b4824c..a1420ab1 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -python 3.13.1 -nodejs 23.4.0 +python 3.13.5t +nodejs 24.2.0 diff --git a/api/asset_manager/src/config.py b/api/asset_manager/src/config.py index 4f2424bf..40b8e6a8 100644 --- a/api/asset_manager/src/config.py +++ b/api/asset_manager/src/config.py @@ -9,12 +9,13 @@ class Settings(BaseSettings): PROJECT_SUMMARY: str = "Product API for StoneEdge." PROJECT_PUBLIC_URL: str = "localhost" SECRET_KEY: str | None = None + USE_HTTPS_ONLY: bool = False + IS_TESTING: bool = False PSQL_USERNAME: str = "user" PSQL_PASSWORD: str = "password" PSQL_HOSTNAME: str = "localhost" PSQL_PORT: int = 5432 PSQL_DB_NAME: str = "stoneedge" - PSQL_TEST_DB_NAME: str = "stoneedge_testing" ACCESS_TOKEN_EXPIRE_MIN: int = 10 REFRESH_TOKEN_EXPIRE_MIN: int = 20 BACKEND_CORS_ORIGINS: list = ["*"] diff --git a/api/asset_manager/src/database.py b/api/asset_manager/src/database.py index df48d021..2ee292b5 100644 --- a/api/asset_manager/src/database.py +++ b/api/asset_manager/src/database.py @@ -5,7 +5,6 @@ from aerich import Command modules: dict[str, Any] = { "models": [ - "modules.assets.models", "modules.auth.models", "modules.users.models", "modules.organizations.models", @@ -14,6 +13,12 @@ modules: dict[str, Any] = { TORTOISE_ORM = { "connections": { + "testing": { + "engine": "tortoise.backends.sqlite", + "credentials": { + "file_path": "stoneedge.sqlite" + } + }, "default": { "engine": "tortoise.backends.asyncpg", "credentials": { @@ -28,7 +33,7 @@ TORTOISE_ORM = { "apps": { "models": { "models": modules.get("models", []) + ["aerich.models"], - "default_connection": "default", + "default_connection": "testing" if settings.IS_TESTING else "default", }, }, } diff --git a/api/asset_manager/src/main.py b/api/asset_manager/src/main.py index dae869b3..5b71c909 100644 --- a/api/asset_manager/src/main.py +++ b/api/asset_manager/src/main.py @@ -29,7 +29,8 @@ app = FastAPI( default_response_class=msgspec_jsonresponse, ) -app.add_middleware(HTTPSRedirectMiddleware) +if settings.USE_HTTPS_ONLY: + app.add_middleware(HTTPSRedirectMiddleware) app.add_middleware(TrustedHostMiddleware, allowed_hosts=[settings.PROJECT_PUBLIC_URL,]) # Set all CORS enabled origins diff --git a/api/asset_manager/src/migrations/models/0_20250122175143_init.py b/api/asset_manager/src/migrations/models/0_20250122175143_init.py deleted file mode 100644 index 88e0a6d0..00000000 --- a/api/asset_manager/src/migrations/models/0_20250122175143_init.py +++ /dev/null @@ -1,75 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - CREATE TABLE IF NOT EXISTS "asset" ( - "id" UUID NOT NULL PRIMARY KEY, - "name" VARCHAR(128) NOT NULL -); -CREATE TABLE IF NOT EXISTS "acl" ( - "id" UUID NOT NULL PRIMARY KEY, - "READ" BOOL NOT NULL DEFAULT False, - "WRITE" BOOL NOT NULL DEFAULT False, - "REPORT" BOOL NOT NULL DEFAULT False, - "MANAGE" BOOL NOT NULL DEFAULT False, - "ADMIN" BOOL NOT NULL DEFAULT False -); -COMMENT ON TABLE "acl" IS 'ACL'; -CREATE TABLE IF NOT EXISTS "organization" ( - "created_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - "modified_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - "disabled_at" TIMESTAMPTZ, - "id" UUID NOT NULL PRIMARY KEY, - "name" VARCHAR(128) NOT NULL, - "type" VARCHAR(128) NOT NULL, - "disabled" BOOL NOT NULL DEFAULT False -); -COMMENT ON TABLE "organization" IS 'Organization'; -CREATE TABLE IF NOT EXISTS "user" ( - "created_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - "modified_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - "disabled_at" TIMESTAMPTZ, - "id" UUID NOT NULL PRIMARY KEY, - "email" VARCHAR(128) NOT NULL, - "username" TEXT NOT NULL, - "name" TEXT NOT NULL, - "surname" TEXT NOT NULL, - "password" VARCHAR(128), - "disabled" BOOL NOT NULL DEFAULT False -); -COMMENT ON TABLE "user" IS 'User'; -CREATE TABLE IF NOT EXISTS "token" ( - "created_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - "modified_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - "disabled_at" TIMESTAMPTZ, - "id" UUID NOT NULL PRIMARY KEY, - "token_type" VARCHAR(128) NOT NULL DEFAULT 'Bearer', - "access_token" VARCHAR(128), - "refresh_token" VARCHAR(128), - "disabled" BOOL NOT NULL DEFAULT False, - "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE -); -COMMENT ON TABLE "token" IS 'Token'; -CREATE TABLE IF NOT EXISTS "membership" ( - "created_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - "modified_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - "disabled_at" TIMESTAMPTZ, - "id" UUID NOT NULL PRIMARY KEY, - "disabled" BOOL NOT NULL DEFAULT False, - "acl_id" UUID NOT NULL REFERENCES "acl" ("id") ON DELETE CASCADE, - "organization_id" UUID NOT NULL REFERENCES "organization" ("id") ON DELETE CASCADE, - "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE -); -COMMENT ON TABLE "membership" IS 'Membership'; -CREATE TABLE IF NOT EXISTS "aerich" ( - "id" SERIAL NOT NULL PRIMARY KEY, - "version" VARCHAR(255) NOT NULL, - "app" VARCHAR(100) NOT NULL, - "content" JSONB NOT NULL -);""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - """ diff --git a/api/asset_manager/src/migrations/models/0_20250623123107_init.py b/api/asset_manager/src/migrations/models/0_20250623123107_init.py new file mode 100644 index 00000000..92eb1c66 --- /dev/null +++ b/api/asset_manager/src/migrations/models/0_20250623123107_init.py @@ -0,0 +1,76 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + CREATE TABLE IF NOT EXISTS "acl" ( + "id" CHAR(36) NOT NULL PRIMARY KEY, + "READ" INT NOT NULL DEFAULT 0, + "WRITE" INT NOT NULL DEFAULT 0, + "REPORT" INT NOT NULL DEFAULT 0, + "MANAGE" INT NOT NULL DEFAULT 0, + "ADMIN" INT NOT NULL DEFAULT 0 +) /* ACL */; +CREATE TABLE IF NOT EXISTS "organization" ( + "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "modified_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "disabled_at" TIMESTAMP, + "id" CHAR(36) NOT NULL PRIMARY KEY, + "name" VARCHAR(128) NOT NULL, + "type" VARCHAR(128) NOT NULL, + "disabled" INT NOT NULL DEFAULT 0 +) /* Organization */; +CREATE TABLE IF NOT EXISTS "user" ( + "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "modified_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "disabled_at" TIMESTAMP, + "id" CHAR(36) NOT NULL PRIMARY KEY, + "email" VARCHAR(128) NOT NULL, + "username" TEXT NOT NULL, + "name" TEXT NOT NULL, + "surname" TEXT NOT NULL, + "password" VARCHAR(128), + "disabled" INT NOT NULL DEFAULT 0 +) /* User */; +CREATE TABLE IF NOT EXISTS "token" ( + "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "modified_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "disabled_at" TIMESTAMP, + "id" CHAR(36) NOT NULL PRIMARY KEY, + "token_type" VARCHAR(128) NOT NULL DEFAULT 'Bearer', + "access_token" TEXT, + "refresh_token" TEXT, + "disabled" INT NOT NULL DEFAULT 0, + "user_id" CHAR(36) NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE +) /* Token */; +CREATE TABLE IF NOT EXISTS "membership" ( + "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "modified_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "disabled_at" TIMESTAMP, + "id" CHAR(36) NOT NULL PRIMARY KEY, + "disabled" INT NOT NULL DEFAULT 0, + "acl_id" CHAR(36) NOT NULL REFERENCES "acl" ("id") ON DELETE CASCADE, + "organization_id" CHAR(36) NOT NULL REFERENCES "organization" ("id") ON DELETE CASCADE, + "user_id" CHAR(36) NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE +) /* Membership */; +CREATE TABLE IF NOT EXISTS "aerich" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "version" VARCHAR(255) NOT NULL, + "app" VARCHAR(100) NOT NULL, + "content" JSON NOT NULL +); +CREATE TABLE IF NOT EXISTS "Membership" ( + "organization_id" CHAR(36) NOT NULL REFERENCES "organization" ("id") ON DELETE NO ACTION, + "user_id" CHAR(36) NOT NULL REFERENCES "user" ("id") ON DELETE NO ACTION +); +CREATE UNIQUE INDEX IF NOT EXISTS "uidx_Membership_organiz_b0a446" ON "Membership" ("organization_id", "user_id"); +CREATE TABLE IF NOT EXISTS "Membership" ( + "user_id" CHAR(36) NOT NULL REFERENCES "user" ("id") ON DELETE NO ACTION, + "organization_id" CHAR(36) NOT NULL REFERENCES "organization" ("id") ON DELETE NO ACTION +); +CREATE UNIQUE INDEX IF NOT EXISTS "uidx_Membership_user_id_cc48d3" ON "Membership" ("user_id", "organization_id");""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + """ diff --git a/api/asset_manager/src/migrations/models/1_20250123190326_second_try.py b/api/asset_manager/src/migrations/models/1_20250123190326_second_try.py deleted file mode 100644 index c4617ab5..00000000 --- a/api/asset_manager/src/migrations/models/1_20250123190326_second_try.py +++ /dev/null @@ -1,15 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "asset" ADD "disabled_at" TIMESTAMPTZ; - ALTER TABLE "asset" ADD "modified_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP; - ALTER TABLE "asset" ADD "created_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP;""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "asset" DROP COLUMN "disabled_at"; - ALTER TABLE "asset" DROP COLUMN "modified_at"; - ALTER TABLE "asset" DROP COLUMN "created_at";""" diff --git a/api/asset_manager/src/migrations/models/2_20250214131414_update.py b/api/asset_manager/src/migrations/models/2_20250214131414_update.py deleted file mode 100644 index 3b9f1a5e..00000000 --- a/api/asset_manager/src/migrations/models/2_20250214131414_update.py +++ /dev/null @@ -1,13 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "token" ALTER COLUMN "refresh_token" TYPE TEXT USING "refresh_token"::TEXT; - ALTER TABLE "token" ALTER COLUMN "access_token" TYPE TEXT USING "access_token"::TEXT;""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "token" ALTER COLUMN "refresh_token" TYPE VARCHAR(128) USING "refresh_token"::VARCHAR(128); - ALTER TABLE "token" ALTER COLUMN "access_token" TYPE VARCHAR(128) USING "access_token"::VARCHAR(128);""" diff --git a/api/asset_manager/src/requirements/requirements.txt b/api/asset_manager/src/requirements/requirements.txt index 4d6f979c..a6e9b290 100644 --- a/api/asset_manager/src/requirements/requirements.txt +++ b/api/asset_manager/src/requirements/requirements.txt @@ -1,21 +1,21 @@ -aerich>=0.8.0 -fastapi[all]>=0.115.5 -python-dotenv>=0.21.0 -tortoise-orm[asyncpg]>=0.22.1 -uvicorn>=0.31.1 -black>=24.10.0 -joserfc>=1.0.1 +aerich>=0.9.0 +fastapi[all]>=0.115.12 +tortoise-orm[asyncpg]>=0.25.1 +uvicorn>=0.34.3 +black>=25.1.0 +joserfc>=1.1.0 passlib>=1.7.4 -pytz>=2024.2 -ptpython>=0.25 +pytz>=2025.2 +ptpython>=3.0.30 msgspec>=0.19.0 -bcrypt>=4.2.1 +bcrypt>=4.3.0 +tomlkit>=0.13.3 # Test Suite httpx>=0.28.1 -pytest>=8.3.4 -mock>=5.1.0 +mock>=5.2.0 +pytest>=8.4.0 asyncio>=3.4.3 -pytest-mock>=3.14.0 -pytest-asyncio>=0.25.3 +pytest-mock>=3.14.1 +pytest-asyncio>=1.0.0 asgi-lifespan>=2.1.0 \ No newline at end of file diff --git a/api/asset_manager/src/tests/conftest.py b/api/asset_manager/src/tests/conftest.py index 8f2665c5..1b909d53 100644 --- a/api/asset_manager/src/tests/conftest.py +++ b/api/asset_manager/src/tests/conftest.py @@ -2,18 +2,10 @@ import asyncio from contextlib import asynccontextmanager from typing import AsyncGenerator import httpx, pytest -from config import settings -from glob import glob from asgi_lifespan import LifespanManager # type: ignore -settings.PSQL_DB_NAME = settings.PSQL_TEST_DB_NAME - -pytest_plugins = [ - fixture.replace("/", ".").replace("\\", ".").replace(".py", "") - for fixture in glob("tests/fixtures/*.py") - if "__" not in fixture -] +from tests.fixtures.account import * try: from main import app @@ -27,12 +19,12 @@ except ImportError: ClientManagerType = AsyncGenerator[httpx.AsyncClient, None] -@pytest.fixture(scope="session") +@pytest.fixture def anyio_backend(): return "asyncio" -@pytest.fixture(scope="session") +@pytest.fixture def event_loop(): loop = asyncio.get_event_loop() yield loop diff --git a/api/asset_manager/src/tests/fixtures/account.py b/api/asset_manager/src/tests/fixtures/account.py new file mode 100644 index 00000000..68146a8d --- /dev/null +++ b/api/asset_manager/src/tests/fixtures/account.py @@ -0,0 +1,60 @@ + +import pytest +from dataclasses import dataclass +from modules.auth.utils import create_jwt_tokens +from modules.organizations.models import Organization, OrganizationType +from modules.users.models import ACL, Membership, User +from modules.auth.models import Token + +from config import settings + +crypt = settings.CRYPT + +@dataclass +class user_creation_return_type: + user: User + organization: Organization + acl: ACL + tokens: Token + +@pytest.fixture() +async def create_user_with_org(): + async def inner_function(email="user@localhost.com", + username="user", + name="awesome", + surname="user", + password="password-dont-use", + organization_name="simple organization", + organization_type=OrganizationType.HOME, + is_admin=False) -> user_creation_return_type: + org: Organization = await Organization.create( + name=organization_name, + type=organization_type + ) + + acl: ACL = await ACL.create( + READ=True, + WRITE=True, + REPORT=True, + MANAGE=True if is_admin else False, + ADMIN=True if is_admin else False, + ) + + user: User = await User.create( + email=email, + username=username, + name=name, + surname=surname, + password=crypt.hash(password), + ) + + await Membership.create( + organization=org, + user=user, + acl=acl + ) + + tokens: Token = await create_jwt_tokens(user=user) + + return user, org, acl, tokens + return inner_function \ No newline at end of file diff --git a/api/asset_manager/src/tests/fixtures/account_fixtures.py b/api/asset_manager/src/tests/fixtures/account_fixtures.py deleted file mode 100644 index ac49fac0..00000000 --- a/api/asset_manager/src/tests/fixtures/account_fixtures.py +++ /dev/null @@ -1,86 +0,0 @@ -from modules.organizations.models import Organization, OrganizationType -from modules.users.models import ACL, Membership, User -import pytest # type=ignore -from config import settings - -crypt = settings.CRYPT - - -@pytest.fixture() -async def use_user_account(): - org, _ = await Organization.get_or_create( - id="6ad4c94e-0522-4912-8d16-02d451f4c92d", - defaults={ - "name": "User's Organization", - "type": OrganizationType.HOME, - }, - ) - acl, _ = await ACL.get_or_create( - id="a4e927a3-36e5-4761-badb-0a44ade6616f", - defaults={ - "READ": True, - "WRITE": True, - "REPORT": True, - "MANAGE": False, - "ADMIN": False, - }, - ) - user, _ = await User.get_or_create( - id="24235427-9662-4ba3-a9c5-00000000000b", - defaults={ - "email": "user@localhost.com", - "username": "user", - "name": "awesome", - "surname": "user", - "password": crypt.hash("userpassword"), - }, - ) - membership, _ = await Membership.get_or_create( - id="833b9511-b2da-4760-8fa4-1a5c7059911e", - defaults={ - "organization": org, - "user": user, - "acl": acl, - }, - ) - return org, acl, user, membership - - -@pytest.fixture() -async def use_admin_account(): - org, _ = await Organization.get_or_create( - id="de001f44-1bb8-4667-9f9d-2d62d6ad7270", - defaults={ - "name": "Admin's Organization", - "type": OrganizationType.EXTRA_LARGE_ORGANIZATION, - }, - ) - acl, _ = await ACL.get_or_create( - id="83c1bfe6-c2ed-4ba1-be03-0e5c1960ec31", - defaults={ - "READ": True, - "WRITE": True, - "REPORT": True, - "MANAGE": True, - "ADMIN": True, - }, - ) - user, _ = await User.get_or_create( - id="24235427-9662-4ba3-a9c5-00000000000a", - defaults={ - "email": "admin@localhost.com", - "username": "admin", - "name": "awesome", - "surname": "admin", - "password": crypt.hash("adminpassword"), - }, - ) - membership, _ = await Membership.get_or_create( - id="393473ee-c218-4bcf-82cd-cb676c4d8a33", - defaults={ - "organization": org, - "user": user, - "acl": acl, - }, - ) - return org, acl, user, membership diff --git a/api/asset_manager/src/tests/test_authentication/test_authentication.py b/api/asset_manager/src/tests/test_authentication/test_authentication.py index 5ed5092c..cd40e14b 100644 --- a/api/asset_manager/src/tests/test_authentication/test_authentication.py +++ b/api/asset_manager/src/tests/test_authentication/test_authentication.py @@ -26,9 +26,9 @@ class TestAuthentication(object): @pytest.mark.asyncio async def test_authentication_with_existing_user_and_wrong_password( - self, client: AsyncClient, use_admin_account + self, client: AsyncClient, create_user_with_org ): - _, _, _, _ = use_admin_account + _, _, _, _ = await create_user_with_org() response = await client.post( "https://localhost/api/v1/auth/login", data={ @@ -42,9 +42,9 @@ class TestAuthentication(object): @pytest.mark.asyncio async def test_authentication_with_existing_user_and_password( - self, client: AsyncClient, use_admin_account + self, client: AsyncClient, create_user_with_org ): - _, _, admin, _ = use_admin_account + admin, _, _, _ = await create_user_with_org(email="admin@localhost.com", password="adminpassword") response = await client.post( "https://localhost/api/v1/auth/login", data={ @@ -70,9 +70,9 @@ class TestAuthentication(object): @pytest.mark.asyncio async def test_logging_out_destroys_tokens( - self, client: AsyncClient, use_user_account + self, client: AsyncClient, create_user_with_org ): - _, _, user, _ = use_user_account + user, _, _, _ = await create_user_with_org(email="user@localhost.com", password="userpassword") response = await client.post( "https://localhost/api/v1/auth/login", data={ @@ -117,9 +117,9 @@ class TestAuthentication(object): @pytest.mark.asyncio async def test_create_new_tokens_upon_refresh( - self, client: AsyncClient, use_admin_account + self, client: AsyncClient, create_user_with_org ): - _, _, admin, _ = use_admin_account + admin, _, _, _ = await create_user_with_org(email="admin@localhost.com", password="adminpassword") token = await client.post( "https://localhost/api/v1/auth/login", data={