feat: HR Portal - Complete Multi-Tenant System with Redis Session Storage

Major Features:
-  Multi-tenant architecture (tenant isolation)
-  Employee CRUD with lifecycle management (onboarding/offboarding)
-  Department tree structure with email domain management
-  Company info management (single-record editing)
-  System functions CRUD (permission management)
-  Email account management (multi-account per employee)
-  Keycloak SSO integration (auth.lab.taipei)
-  Redis session storage (10.1.0.254:6379)
  - Solves Cookie 4KB limitation
  - Cross-system session sharing
  - Sliding expiration (8 hours)
  - Automatic token refresh

Technical Stack:
Backend:
- FastAPI + SQLAlchemy
- PostgreSQL 16 (10.1.0.20:5433)
- Keycloak Admin API integration
- Docker Mailserver integration (SSH)
- Alembic migrations

Frontend:
- Next.js 14 (App Router)
- NextAuth 4 with Keycloak Provider
- Redis session storage (ioredis)
- Tailwind CSS

Infrastructure:
- Redis 7 (10.1.0.254:6379) - Session + Cache
- Keycloak 26.1.0 (auth.lab.taipei)
- Docker Mailserver (10.1.0.254)

Architecture Highlights:
- Session管理由 Keycloak + Redis 統一控制
- 支援多系統 (HR/WebMail/Calendar/Drive/Office) 共享 session
- Token 自動刷新,異質服務整合
- 未來可無縫遷移到雲端

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 20:12:43 +08:00
commit 360533393f
386 changed files with 70353 additions and 0 deletions

View File

124
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,124 @@
"""
pytest 共用 Fixtures
提供測試用的資料庫 Session、測試客戶端等
"""
import sys
import os
from pathlib import Path
# 確保 backend 根目錄在 Python path 中
sys.path.insert(0, str(Path(__file__).parent.parent))
# 在導入任何 app 模組前,先將 PostgreSQL JSONB 替換為 JSON (SQLite 相容)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import JSON
import sqlalchemy.dialects.postgresql as pg_dialect
pg_dialect.JSONB = JSON
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.base import Base
from app.db.session import get_db
from app.main import app
# ============================================================
# 測試資料庫 (使用 SQLite in-memory)
# ============================================================
SQLALCHEMY_DATABASE_URL = "sqlite://"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@pytest.fixture(scope="function")
def db():
"""提供測試用的資料庫 Session (每個測試獨立)"""
Base.metadata.create_all(bind=engine)
session = TestingSessionLocal()
try:
yield session
finally:
session.close()
Base.metadata.drop_all(bind=engine)
@pytest.fixture(scope="function")
def client(db):
"""提供測試用的 FastAPI TestClient"""
def override_get_db():
try:
yield db
finally:
pass
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
# ============================================================
# 測試資料 Fixtures
# ============================================================
@pytest.fixture
def sample_business_unit(db):
"""建立測試用事業部"""
from app.models.business_unit import BusinessUnit
bu = BusinessUnit(
tenant_id=1,
name="智能研發服務事業部",
code="SMART",
email_domain="lab.taipei",
description="測試事業部",
is_active=True,
)
db.add(bu)
db.commit()
db.refresh(bu)
return bu
@pytest.fixture
def sample_department(db, sample_business_unit):
"""建立測試用部門"""
from app.models.department import Department
dept = Department(
tenant_id=1,
business_unit_id=sample_business_unit.id,
name="資訊部",
code="IT",
is_active=True,
)
db.add(dept)
db.commit()
db.refresh(dept)
return dept
@pytest.fixture
def sample_employee(db, sample_business_unit):
"""建立測試用員工"""
from app.models.employee import Employee
import datetime
emp = Employee(
tenant_id=1,
employee_id="EMP001",
username_base="test.user",
legal_name="測試用戶",
english_name="Test User",
hire_date=datetime.date.today(),
status="active",
)
db.add(emp)
db.commit()
db.refresh(emp)
return emp

View File

@@ -0,0 +1,174 @@
"""
批次作業測試 (6.4)
測試各批次作業的邏輯
"""
import datetime
import pytest
from unittest.mock import patch, MagicMock
class TestBatchLog:
"""BatchLog model 測試"""
def test_create_batch_log(self, db):
"""成功建立批次日誌"""
from app.models.batch_log import BatchLog
log = BatchLog(
batch_name="test_batch",
status="success",
message="測試批次執行成功",
started_at=datetime.datetime.utcnow(),
finished_at=datetime.datetime.utcnow(),
duration_seconds=5,
)
db.add(log)
db.commit()
db.refresh(log)
assert log.id is not None
assert log.batch_name == "test_batch"
assert log.status == "success"
def test_batch_log_repr(self, db):
"""BatchLog repr 格式"""
from app.models.batch_log import BatchLog
log = BatchLog(
batch_name="daily_quota_check",
status="success",
started_at=datetime.datetime(2026, 2, 18, 2, 0, 0),
)
db.add(log)
db.commit()
assert "daily_quota_check" in repr(log)
assert "success" in repr(log)
class TestDailyQuotaCheck:
"""每日配額檢查批次測試"""
def test_run_with_empty_db(self, db):
"""空資料庫時應能正常執行"""
# side_effect 使每次呼叫 get_db() 都回傳新 iter避免 iterator 用盡問題
with patch("app.db.session.get_db", side_effect=lambda: iter([db])):
from app.batch.daily_quota_check import run_daily_quota_check
result = run_daily_quota_check()
assert result["status"] == "success"
assert result["summary"]["email_checked"] == 0
assert result["summary"]["drive_checked"] == 0
def test_run_with_email_accounts(self, db, sample_employee):
"""有郵件帳號時應正確計數"""
from app.models.email_account import EmailAccount
# 建立測試郵件帳號
account = EmailAccount(
tenant_id=1,
employee_id=sample_employee.id,
email_address="test.user@porscheworld.tw",
quota_mb=5120,
is_active=True,
)
db.add(account)
db.commit()
with patch("app.db.session.get_db", side_effect=lambda: iter([db])):
from app.batch.daily_quota_check import run_daily_quota_check
result = run_daily_quota_check()
assert result["status"] == "success"
assert result["summary"]["email_checked"] == 1
class TestSyncKeycloakUsers:
"""Keycloak 同步批次測試"""
def test_run_with_no_keycloak_connection(self, db, sample_employee):
"""Keycloak 無法連線時batch 應以 failed 狀態記錄"""
with patch("app.db.session.get_db", side_effect=lambda: iter([db])):
with patch("app.services.keycloak_admin_client.get_keycloak_admin_client") as mock_kc:
# 模擬 Keycloak 連線失敗
mock_client = MagicMock()
mock_client.get_user_by_username.return_value = None
mock_kc.return_value = mock_client
from app.batch.sync_keycloak_users import run_sync_keycloak_users
result = run_sync_keycloak_users()
# 員工在 Keycloak 中不存在 → 記為 not_found_in_keycloak
assert result["status"] == "success"
assert result["summary"]["not_found_in_keycloak"] >= 0
def test_sync_disabled_employee(self, db, sample_employee):
"""離職員工應同步停用 Keycloak"""
# 將員工設為離職
sample_employee.status = "terminated"
db.commit()
with patch("app.db.session.get_db", side_effect=lambda: iter([db])):
with patch("app.services.keycloak_admin_client.get_keycloak_admin_client") as mock_kc:
mock_client = MagicMock()
# 模擬 Keycloak 中帳號仍是 enabled
mock_client.get_user_by_username.return_value = {
"id": "kc-uuid-123",
"enabled": True,
}
mock_client.update_user.return_value = True
mock_kc.return_value = mock_client
from app.batch.sync_keycloak_users import run_sync_keycloak_users
result = run_sync_keycloak_users()
# 應同步停用
assert result["summary"]["synced"] == 1
mock_client.update_user.assert_called_once_with(
"kc-uuid-123", {"enabled": False}
)
class TestArchiveAuditLogs:
"""審計日誌歸檔批次測試"""
def test_archive_no_old_logs(self, db):
"""沒有舊日誌時應正常執行"""
with patch("app.db.session.get_db", side_effect=lambda: iter([db])):
from app.batch.archive_audit_logs import run_archive_audit_logs
result = run_archive_audit_logs(dry_run=True)
assert result["status"] == "success"
assert result["archived"] == 0
def test_archive_old_logs_dry_run(self, db, sample_employee):
"""dry_run 模式下不刪除資料"""
from app.models.audit_log import AuditLog
import json
# 建立 91 天前的舊日誌
old_date = datetime.datetime.utcnow() - datetime.timedelta(days=91)
for i in range(3):
log = AuditLog(
tenant_id=1,
action="test_action",
resource_type="employee",
resource_id=sample_employee.id,
performed_by="test_system",
performed_at=old_date,
details=json.dumps({"test": i}),
)
db.add(log)
db.commit()
# 確認有 3 筆舊日誌
count_before = db.query(AuditLog).count()
assert count_before == 3
with patch("app.db.session.get_db", side_effect=lambda: iter([db])):
with patch("os.makedirs"):
with patch("builtins.open", MagicMock()):
with patch("csv.DictWriter") as mock_writer:
mock_writer.return_value.__enter__ = MagicMock()
mock_writer.return_value.__exit__ = MagicMock()
from app.batch.archive_audit_logs import run_archive_audit_logs
result = run_archive_audit_logs(dry_run=True)
# dry_run 模式下不刪除
assert result["archived"] == 3
assert result["deleted"] == 0

View File

@@ -0,0 +1,98 @@
"""
Drive Service HTTP Client 單元測試 (6.3)
使用 mock 測試,不需要實際 Drive Service 上線
"""
import pytest
from unittest.mock import patch, MagicMock
from app.services.drive_service import DriveServiceClient, get_drive_quota_by_job_level
class TestGetDriveQuotaByJobLevel:
"""職級配額對應測試"""
def test_junior_quota(self):
assert get_drive_quota_by_job_level("Junior") == 50
def test_mid_quota(self):
assert get_drive_quota_by_job_level("Mid") == 100
def test_senior_quota(self):
assert get_drive_quota_by_job_level("Senior") == 200
def test_manager_quota(self):
assert get_drive_quota_by_job_level("Manager") == 500
def test_unknown_level_defaults_to_junior(self):
assert get_drive_quota_by_job_level("Unknown") == 50
def test_none_defaults_to_junior(self):
assert get_drive_quota_by_job_level(None) == 50
class TestDriveServiceClient:
"""Drive Service HTTP Client 測試"""
def setup_method(self):
self.client = DriveServiceClient(
base_url="https://drive-api.ease.taipei",
timeout=5,
)
def test_create_user_service_unavailable(self):
"""Drive Service 未上線時回傳 warning 結果"""
from requests.exceptions import ConnectionError as RequestsConnectionError
with patch.object(self.client.session, "post") as mock_post:
mock_post.side_effect = RequestsConnectionError("Connection refused")
result = self.client.create_user(
tenant_id=1,
keycloak_user_id="kc-uuid-123",
username="test.user",
email="test.user@porscheworld.tw",
display_name="Test User",
quota_gb=100,
)
assert result["created"] is False
assert result["error"] is not None
assert "test.user" in str(result)
def test_create_user_success(self):
"""Drive Service 正常回應時成功建立"""
with patch.object(self.client.session, "post") as mock_post:
mock_response = MagicMock()
mock_response.status_code = 201
mock_response.json.return_value = {
"id": 42,
"username": "test.user",
"quota_gb": 100,
"drive_url": "https://drive.ease.taipei/test.user",
}
mock_post.return_value = mock_response
result = self.client.create_user(
tenant_id=1,
keycloak_user_id="kc-uuid-123",
username="test.user",
email="test.user@porscheworld.tw",
display_name="Test User",
quota_gb=100,
)
assert result["created"] is True
assert result["error"] is None
assert result["quota_gb"] == 100
def test_get_quota_service_unavailable(self):
"""Drive Service 未上線時查詢配額回傳 None"""
from requests.exceptions import ConnectionError as RequestsConnectionError
with patch.object(self.client.session, "get") as mock_get:
mock_get.side_effect = RequestsConnectionError("Connection refused")
result = self.client.get_quota(drive_user_id=42)
assert result is None
def test_disable_user_service_unavailable(self):
"""Drive Service 未上線時停用帳號回傳 False"""
from requests.exceptions import ConnectionError as RequestsConnectionError
with patch.object(self.client.session, "delete") as mock_delete:
mock_delete.side_effect = RequestsConnectionError("Connection refused")
result = self.client.disable_user(drive_user_id=42)
assert result["disabled"] is False
assert result["error"] is not None

View File

@@ -0,0 +1,143 @@
"""
員工管理 API 測試 (6.2)
測試 /api/v1/employees/ 的 CRUD 操作
"""
import datetime
import pytest
class TestEmployeeListAPI:
"""員工列表 API 測試"""
def test_get_employees_empty(self, client):
"""空資料庫回傳空列表"""
response = client.get("/api/v1/employees/")
assert response.status_code == 200
data = response.json()
assert data["total"] == 0
assert data["items"] == []
def test_get_employees_with_data(self, client, sample_employee):
"""有資料時回傳員工列表"""
response = client.get("/api/v1/employees/")
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert len(data["items"]) == 1
assert data["items"][0]["employee_id"] == "EMP001"
def test_get_employees_pagination(self, client, db, sample_business_unit):
"""分頁功能測試"""
from app.models.employee import Employee
# 建立 5 個員工
for i in range(5):
emp = Employee(
tenant_id=1,
employee_id=f"EMP00{i+1}",
username_base=f"user{i+1}",
legal_name=f"員工{i+1}",
hire_date=datetime.date.today(),
status="active",
)
db.add(emp)
db.commit()
# 取第 1 頁,每頁 2 筆
response = client.get("/api/v1/employees/?page=1&page_size=2")
assert response.status_code == 200
data = response.json()
assert data["total"] == 5
assert len(data["items"]) == 2
assert data["total_pages"] == 3
class TestEmployeeCreateAPI:
"""員工建立 API 測試"""
def test_create_employee_success(self, client, sample_business_unit):
"""成功建立員工"""
payload = {
"username_base": "porsche.chen",
"legal_name": "陳保時",
"english_name": "Porsche Chen",
"hire_date": str(datetime.date.today()),
"business_unit_id": sample_business_unit.id,
"job_title": "軟體工程師",
"job_level": "Mid",
"email_quota_mb": 5120,
}
response = client.post("/api/v1/employees/", json=payload)
assert response.status_code == 201
data = response.json()
assert data["username_base"] == "porsche.chen"
assert data["legal_name"] == "陳保時"
assert data["status"] == "active"
assert "employee_id" in data # 自動產生
def test_create_employee_duplicate_username(self, client, sample_employee, sample_business_unit):
"""重複帳號名稱應回傳 400"""
payload = {
"username_base": "test.user", # 與 sample_employee 相同
"legal_name": "另一個用戶",
"hire_date": str(datetime.date.today()),
"business_unit_id": sample_business_unit.id,
"job_title": "工程師",
"job_level": "Junior",
"email_quota_mb": 1024,
}
response = client.post("/api/v1/employees/", json=payload)
assert response.status_code in [400, 409, 422]
def test_create_employee_missing_required_fields(self, client):
"""缺少必填欄位應回傳 422"""
response = client.post("/api/v1/employees/", json={"username_base": "only.this"})
assert response.status_code == 422
class TestEmployeeDetailAPI:
"""員工詳情 API 測試"""
def test_get_employee_success(self, client, sample_employee):
"""成功取得員工詳情"""
response = client.get(f"/api/v1/employees/{sample_employee.id}")
assert response.status_code == 200
data = response.json()
assert data["id"] == sample_employee.id
assert data["employee_id"] == "EMP001"
def test_get_employee_not_found(self, client):
"""不存在的員工 ID 應回傳 404"""
response = client.get("/api/v1/employees/99999")
assert response.status_code == 404
def test_update_employee_success(self, client, sample_employee):
"""成功更新員工資料"""
payload = {"legal_name": "更新後姓名", "phone": "02-12345678"}
response = client.put(f"/api/v1/employees/{sample_employee.id}", json=payload)
assert response.status_code == 200
data = response.json()
assert data["legal_name"] == "更新後姓名"
assert data["phone"] == "02-12345678"
def test_delete_employee_success(self, client, sample_employee):
"""成功刪除 (停用) 員工"""
response = client.delete(f"/api/v1/employees/{sample_employee.id}")
assert response.status_code == 200
class TestEmployeeSearchAPI:
"""員工搜尋 API 測試"""
def test_search_by_name(self, client, sample_employee):
"""依姓名搜尋"""
response = client.get("/api/v1/employees/?search=測試")
assert response.status_code == 200
data = response.json()
assert data["total"] >= 1
def test_filter_by_status(self, client, sample_employee):
"""依狀態篩選"""
response = client.get("/api/v1/employees/?status=active")
assert response.status_code == 200
data = response.json()
assert all(item["status"] == "active" for item in data["items"])

View File

@@ -0,0 +1,127 @@
"""
系統權限管理 API 測試 (6.2)
測試 /api/v1/permissions/ CRUD 操作
"""
import pytest
class TestPermissionsAPI:
"""系統權限 API 測試"""
def test_get_permissions_empty(self, client):
"""空資料庫回傳空列表"""
response = client.get("/api/v1/permissions/")
assert response.status_code == 200
data = response.json()
assert data["total"] == 0
def test_get_available_systems(self, client):
"""取得可授權系統清單"""
response = client.get("/api/v1/permissions/systems")
assert response.status_code == 200
data = response.json()
assert "systems" in data
assert "gitea" in data["systems"]
assert "keycloak" in data["systems"]
assert "access_levels" in data
assert "admin" in data["access_levels"]
def test_create_permission_success(self, client, sample_employee):
"""成功建立權限"""
payload = {
"employee_id": sample_employee.id,
"system_name": "gitea",
"access_level": "user",
}
response = client.post("/api/v1/permissions/", json=payload)
assert response.status_code == 201
data = response.json()
assert data["system_name"] == "gitea"
assert data["access_level"] == "user"
assert data["employee_id"] == sample_employee.id
def test_create_permission_duplicate(self, client, sample_employee):
"""重複建立同系統權限應回傳 400"""
payload = {
"employee_id": sample_employee.id,
"system_name": "gitea",
"access_level": "user",
}
# 第一次建立
client.post("/api/v1/permissions/", json=payload)
# 第二次建立同系統
response = client.post("/api/v1/permissions/", json=payload)
assert response.status_code == 400
def test_create_permission_invalid_system(self, client, sample_employee):
"""無效系統名稱應回傳 422"""
payload = {
"employee_id": sample_employee.id,
"system_name": "invalid_system",
"access_level": "user",
}
response = client.post("/api/v1/permissions/", json=payload)
assert response.status_code == 422
def test_get_employee_permissions(self, client, sample_employee):
"""取得員工所有權限"""
# 先建立兩個權限
for system in ["gitea", "portainer"]:
client.post("/api/v1/permissions/", json={
"employee_id": sample_employee.id,
"system_name": system,
"access_level": "user",
})
response = client.get(f"/api/v1/permissions/employees/{sample_employee.id}/permissions")
assert response.status_code == 200
data = response.json()
assert len(data) == 2
systems = [p["system_name"] for p in data]
assert "gitea" in systems
assert "portainer" in systems
def test_update_permission(self, client, sample_employee):
"""更新權限層級"""
# 建立權限
create_resp = client.post("/api/v1/permissions/", json={
"employee_id": sample_employee.id,
"system_name": "gitea",
"access_level": "user",
})
perm_id = create_resp.json()["id"]
# 更新為 admin
response = client.put(f"/api/v1/permissions/{perm_id}", json={"access_level": "admin"})
assert response.status_code == 200
assert response.json()["access_level"] == "admin"
def test_delete_permission(self, client, sample_employee):
"""刪除權限"""
create_resp = client.post("/api/v1/permissions/", json={
"employee_id": sample_employee.id,
"system_name": "gitea",
"access_level": "user",
})
perm_id = create_resp.json()["id"]
response = client.delete(f"/api/v1/permissions/{perm_id}")
assert response.status_code == 200
# 確認已刪除
get_resp = client.get(f"/api/v1/permissions/{perm_id}")
assert get_resp.status_code == 404
def test_batch_create_permissions(self, client, sample_employee):
"""批次建立權限"""
payload = {
"employee_id": sample_employee.id,
"permissions": [
{"system_name": "gitea", "access_level": "user"},
{"system_name": "portainer", "access_level": "readonly"},
],
}
response = client.post("/api/v1/permissions/batch", json=payload)
assert response.status_code == 201
data = response.json()
assert len(data) == 2