diff --git a/app/core/permissions.py b/app/core/permissions.py index 1e9cfab..feb03c7 100644 --- a/app/core/permissions.py +++ b/app/core/permissions.py @@ -337,11 +337,13 @@ async def get_cached_permissions( except Exception: logger.warning( "Failed to query current permission_version for cache validation " - "(user=%s, tenant=%s) — using cached data", + "(user=%s, tenant=%s) — invalidating cache and re-resolving", user_id, tenant_id, exc_info=True, ) - current_version = cached_version # assume cache is valid if we can't check + # Invalidate stale cache — do NOT trust cached permissions on DB error + await redis.delete(cache_key) + return None # Fall through to re-resolution from DB if cached_version == current_version: return data diff --git a/app/routes/auth.py b/app/routes/auth.py index e3ef375..821d9bf 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -75,7 +75,6 @@ async def login( "email": user.email, "name": user.name, "role": role, - "is_system_admin": user.is_system_admin, "tenant_id": str(tenant.id), "tenant_name": tenant.name, "csrf_token": csrf_token, diff --git a/app/schemas/auth.py b/app/schemas/auth.py index 491b8b9..549f8a9 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -2,7 +2,8 @@ from __future__ import annotations -from pydantic import BaseModel, EmailStr, Field +from pydantic import BaseModel, EmailStr, Field, field_validator +from app.schemas.user import _validate_password_complexity class LoginRequest(BaseModel): @@ -19,6 +20,11 @@ class PasswordResetConfirm(BaseModel): token: str = Field(..., min_length=1) new_password: str = Field(..., min_length=8) + @field_validator("new_password") + @classmethod + def validate_password(cls, v: str) -> str: + return _validate_password_complexity(v) + class SwitchTenantRequest(BaseModel): tenant_id: str = Field(..., min_length=1) diff --git a/app/schemas/user.py b/app/schemas/user.py index 7f07297..18d127b 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -2,7 +2,24 @@ from __future__ import annotations -from pydantic import BaseModel, EmailStr, Field +import re +from pydantic import BaseModel, EmailStr, Field, field_validator + + +def _validate_password_complexity(password: str) -> str: + """Validate password meets complexity requirements. + + Requires: min 8 chars, at least 1 uppercase, 1 lowercase, 1 digit. + """ + if len(password) < 8: + raise ValueError("Password must be at least 8 characters") + if not re.search(r"[A-Z]", password): + raise ValueError("Password must contain at least one uppercase letter") + if not re.search(r"[a-z]", password): + raise ValueError("Password must contain at least one lowercase letter") + if not re.search(r"\d", password): + raise ValueError("Password must contain at least one digit") + return password class UserCreate(BaseModel): @@ -13,6 +30,11 @@ class UserCreate(BaseModel): role_id: str | None = Field(default=None, description="UUID of a custom Role", examples=["550e8400-e29b-41d4-a716-446655440000"]) is_active: bool = True + @field_validator("password") + @classmethod + def validate_password(cls, v: str) -> str: + return _validate_password_complexity(v) + class UserUpdate(BaseModel): name: str | None = Field(None, min_length=1, max_length=200) diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 02f4b6e..b91c4d6 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -1,6 +1,40 @@ import '@testing-library/jest-dom'; import { vi, beforeEach, afterEach, beforeAll } from 'vitest'; +import React from 'react'; import i18n from '@/i18n'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, renderHook } from '@testing-library/react'; + +// ── Global QueryClient for tests ────────────────────────────────────────── +// Many tests use hooks from @tanstack/react-query without explicitly wrapping +// in a QueryClientProvider. We monkey-patch render/renderHook to automatically +// wrap with the provider. +function getTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, staleTime: 0, gcTime: 0 }, + mutations: { retry: false }, + }, + }); +} + +const originalRender = render; +const originalRenderHook = renderHook; + +// Override global render to auto-wrap with QueryClientProvider +(globalThis as any).render = (ui: React.ReactElement, options?: any) => { + const client = getTestQueryClient(); + const wrapped = React.createElement(QueryClientProvider, { client }, ui); + return originalRender(wrapped, options); +}; + +// Override global renderHook to auto-wrap with QueryClientProvider +(globalThis as any).renderHook = (hook: any, options?: any) => { + const client = getTestQueryClient(); + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client }, children); + return originalRenderHook(hook, { wrapper, ...options }); +}; // Mock matchMedia for jsdom Object.defineProperty(window, 'matchMedia', {