Phase 3: Fix medium-priority issues (M1-M4, M6)
M1: Password complexity validation (min 8 chars, uppercase, lowercase, digit) M2: Remove is_system_admin from login response (prevent role leaking) M3: Permission cache invalidates on DB error instead of using stale data M4: .env.docker.example already fixed in B9 (SECRET_KEY, FRONTEND_URL, SMTP) M6: Frontend test setup auto-wraps with QueryClientProvider (fixes ~29 test failures) Remaining: M5 (frontend component integration — WelcomeDialog, SavedFilterBar, etc.)
This commit is contained in:
@@ -337,11 +337,13 @@ async def get_cached_permissions(
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Failed to query current permission_version for cache validation "
|
"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,
|
user_id, tenant_id,
|
||||||
exc_info=True,
|
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:
|
if cached_version == current_version:
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ async def login(
|
|||||||
"email": user.email,
|
"email": user.email,
|
||||||
"name": user.name,
|
"name": user.name,
|
||||||
"role": role,
|
"role": role,
|
||||||
"is_system_admin": user.is_system_admin,
|
|
||||||
"tenant_id": str(tenant.id),
|
"tenant_id": str(tenant.id),
|
||||||
"tenant_name": tenant.name,
|
"tenant_name": tenant.name,
|
||||||
"csrf_token": csrf_token,
|
"csrf_token": csrf_token,
|
||||||
|
|||||||
+7
-1
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
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):
|
class LoginRequest(BaseModel):
|
||||||
@@ -19,6 +20,11 @@ class PasswordResetConfirm(BaseModel):
|
|||||||
token: str = Field(..., min_length=1)
|
token: str = Field(..., min_length=1)
|
||||||
new_password: str = Field(..., min_length=8)
|
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):
|
class SwitchTenantRequest(BaseModel):
|
||||||
tenant_id: str = Field(..., min_length=1)
|
tenant_id: str = Field(..., min_length=1)
|
||||||
|
|||||||
+23
-1
@@ -2,7 +2,24 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
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):
|
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"])
|
role_id: str | None = Field(default=None, description="UUID of a custom Role", examples=["550e8400-e29b-41d4-a716-446655440000"])
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
|
|
||||||
|
@field_validator("password")
|
||||||
|
@classmethod
|
||||||
|
def validate_password(cls, v: str) -> str:
|
||||||
|
return _validate_password_complexity(v)
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
class UserUpdate(BaseModel):
|
||||||
name: str | None = Field(None, min_length=1, max_length=200)
|
name: str | None = Field(None, min_length=1, max_length=200)
|
||||||
|
|||||||
@@ -1,6 +1,40 @@
|
|||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
import { vi, beforeEach, afterEach, beforeAll } from 'vitest';
|
import { vi, beforeEach, afterEach, beforeAll } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
import i18n from '@/i18n';
|
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
|
// Mock matchMedia for jsdom
|
||||||
Object.defineProperty(window, 'matchMedia', {
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
|||||||
Reference in New Issue
Block a user