Skip to content

Commit db36744

Browse files
committed
fix: preserve HTTP status codes for Excel validation errors
1 parent 368f9fd commit db36744

5 files changed

Lines changed: 212 additions & 7 deletions

File tree

‎backend/apps/data_training/api/data_training.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,10 @@
33
import io
44
import os
55
import uuid
6-
from http.client import HTTPException
76
from typing import Optional
87

98
import pandas as pd
10-
from fastapi import APIRouter, File, UploadFile, Query
9+
from fastapi import APIRouter, File, HTTPException, UploadFile, Query
1110
from fastapi.responses import StreamingResponse
1211

1312
from apps.chat.models.chat_model import AxisObj

‎backend/apps/settings/api/base.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import os
2-
from http.client import HTTPException
32

4-
from fastapi import APIRouter
3+
from fastapi import APIRouter, HTTPException
54
from fastapi.responses import FileResponse
65

76
from apps.swagger.i18n import PLACEHOLDER_PREFIX

‎backend/apps/system/crud/user_excel.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11

22

33
import asyncio
4-
from http.client import HTTPException
54
import io
65
import sys
76
import tempfile
87
import uuid
98
import atexit
109
import threading
10+
from fastapi import HTTPException
1111
from fastapi.responses import StreamingResponse, FileResponse
1212
import os
1313
from openai import BaseModel

‎backend/apps/terminology/api/terminology.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,10 @@
33
import io
44
import os
55
import uuid
6-
from http.client import HTTPException
76
from typing import Optional
87

98
import pandas as pd
10-
from fastapi import APIRouter, File, UploadFile, Query
9+
from fastapi import APIRouter, File, HTTPException, UploadFile, Query
1110
from fastapi.responses import StreamingResponse
1211

1312
from apps.chat.models.chat_model import AxisObj
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
"""Offline HTTP regressions for TAPD 1078034 using real routers and handlers.
2+
3+
Identity, audit persistence and database sessions are test adapters. No application
4+
startup, external database, provider or deployed service is used.
5+
"""
6+
7+
import io
8+
from types import SimpleNamespace
9+
from unittest.mock import AsyncMock, Mock
10+
11+
import pandas as pd
12+
import pytest
13+
import sqlbot_xpack # noqa: F401 # Initialize the published wheel before API imports.
14+
from fastapi import FastAPI, HTTPException
15+
from fastapi.testclient import TestClient
16+
from starlette.exceptions import HTTPException as StarletteHTTPException
17+
18+
from apps.data_training.api import data_training
19+
from apps.settings.api import base
20+
from apps.system.api import user
21+
from apps.system.crud import user_excel
22+
from apps.system.schemas.permission import RequestContextMiddleware
23+
from apps.terminology.api import terminology
24+
from common.audit.schemas.logger_decorator import SystemLogger
25+
from common.audit.schemas.request_context import RequestContextMiddlewareCommon
26+
from common.core.db import get_session
27+
from common.core.deps import get_i18n
28+
from common.core.response_middleware import ResponseMiddleware, exception_handler
29+
30+
UPLOAD_URLS = [
31+
"/system/terminology/uploadExcel",
32+
"/system/data-training/uploadExcel",
33+
"/user/batchImport",
34+
]
35+
36+
37+
@pytest.fixture
38+
def api(monkeypatch, tmp_path):
39+
app = FastAPI()
40+
for module in (terminology, data_training, base, user):
41+
app.include_router(module.router)
42+
app.add_exception_handler(
43+
StarletteHTTPException, exception_handler.http_exception_handler
44+
)
45+
app.add_exception_handler(Exception, exception_handler.global_exception_handler)
46+
app.add_middleware(ResponseMiddleware)
47+
app.add_middleware(RequestContextMiddleware)
48+
app.add_middleware(RequestContextMiddlewareCommon)
49+
50+
@app.middleware("http")
51+
async def identity(request, call_next):
52+
request.state.current_user = SimpleNamespace(
53+
isAdmin=True, weight=1, id=1, oid=1, account="test", name="test"
54+
)
55+
return await call_next(request)
56+
57+
session = Mock()
58+
app.dependency_overrides[get_session] = lambda: session
59+
app.dependency_overrides[get_i18n] = lambda: lambda key: key
60+
monkeypatch.setattr(SystemLogger, "create_log_record", AsyncMock())
61+
for module in (terminology, data_training, base):
62+
monkeypatch.setattr(module, "path", str(tmp_path))
63+
monkeypatch.setattr(user_excel, "_TEMP_FILE_MAP", {})
64+
monkeypatch.setattr(user_excel.tempfile, "gettempdir", lambda: str(tmp_path))
65+
# ResponseMiddleware tracks live instances globally in the application.
66+
monkeypatch.setattr(ResponseMiddleware, "instances", [])
67+
with TestClient(app, raise_server_exceptions=False) as client:
68+
yield client, session
69+
70+
71+
def workbook(columns):
72+
buffer = io.BytesIO()
73+
pd.DataFrame(columns=columns).to_excel(buffer, index=False)
74+
return buffer.getvalue()
75+
76+
77+
@pytest.mark.parametrize("url", UPLOAD_URLS)
78+
@pytest.mark.parametrize("filename", ["bad.csv", "bad", "bad.xlsx.csv"])
79+
def test_unsupported_upload_returns_400(api, tmp_path, url, filename):
80+
client, session = api
81+
response = client.post(url, files={"file": (filename, b"invalid spreadsheet")})
82+
assert response.status_code == 400, response.text
83+
assert response.json() == "Only support .xlsx/.xls"
84+
assert list(tmp_path.iterdir()) == []
85+
session.add_all.assert_not_called()
86+
session.commit.assert_not_called()
87+
88+
89+
def test_invalid_user_excel_headers_return_400(api):
90+
client, session = api
91+
response = client.post(
92+
"/user/batchImport", files={"file": ("users.xlsx", workbook(["wrong"]))}
93+
)
94+
assert response.status_code == 400, response.text
95+
assert response.json() == "Excel header validation failed"
96+
session.add_all.assert_not_called()
97+
session.commit.assert_not_called()
98+
99+
100+
def test_valid_empty_user_workbook_is_accepted(api):
101+
client, session = api
102+
response = client.post(
103+
"/user/batchImport",
104+
files={"file": ("users.xlsx", workbook(user_excel.get_i18n_head_list()))},
105+
)
106+
assert response.status_code == 200, response.text
107+
assert response.json()["data"] == {
108+
"successCount": 0,
109+
"errorCount": 0,
110+
"dataKey": None,
111+
}
112+
session.commit.assert_not_called()
113+
114+
115+
@pytest.mark.parametrize(
116+
"module,batch_name,column_count",
117+
[
118+
(terminology, "batch_create_terminology", 6),
119+
(data_training, "batch_create_training", 4),
120+
],
121+
)
122+
@pytest.mark.parametrize("filename", ["valid.xlsx", "valid.XLSX"])
123+
def test_valid_knowledge_workbook_reaches_batch_import(
124+
api, monkeypatch, module, batch_name, column_count, filename
125+
):
126+
client, session = api
127+
monkeypatch.setattr(module, "session_maker", lambda: session)
128+
batch = Mock(
129+
return_value={
130+
"failed_records": [],
131+
"success_count": 0,
132+
"duplicate_count": 0,
133+
"original_count": 0,
134+
}
135+
)
136+
monkeypatch.setattr(module, batch_name, batch)
137+
response = client.post(
138+
module.router.prefix + "/uploadExcel",
139+
files={"file": (filename, workbook(range(column_count)))},
140+
)
141+
assert response.status_code == 200, response.text
142+
assert response.json()["data"]["failed_count"] == 0
143+
batch.assert_called_once()
144+
assert batch.call_args.args[:3] == (session, [], 1)
145+
146+
147+
@pytest.mark.parametrize(
148+
"filename,exists,status,message",
149+
[
150+
("missing_error.xlsx", False, 404, "File Not Exists"),
151+
("bad.csv", True, 400, "Only support _error.xlsx"),
152+
],
153+
)
154+
def test_knowledge_error_download_returns_client_error(
155+
api, tmp_path, filename, exists, status, message
156+
):
157+
client, _session = api
158+
if exists:
159+
(tmp_path / filename).write_bytes(b"test file")
160+
response = client.post("/system/download-fail-info", json={"file": filename})
161+
assert response.status_code == status, response.text
162+
assert response.json() == message
163+
164+
165+
def test_user_error_download_requires_file_id():
166+
with pytest.raises(HTTPException) as raised:
167+
user_excel.download_error_file("")
168+
assert raised.value.status_code == 400
169+
assert raised.value.detail == "file_id required"
170+
171+
172+
@pytest.mark.parametrize("registered", [False, True])
173+
def test_missing_user_error_file_returns_404(api, tmp_path, registered):
174+
client, _session = api
175+
if registered:
176+
user_excel._TEMP_FILE_MAP["missing"] = str(tmp_path / "missing.xlsx")
177+
response = client.get("/user/errorRecord/missing")
178+
assert response.status_code == 404, response.text
179+
assert response.json() == "File not found"
180+
181+
182+
def test_user_error_file_outside_temp_directory_returns_403(api, tmp_path, monkeypatch):
183+
client, _session = api
184+
monkeypatch.setattr(
185+
user_excel.tempfile, "gettempdir", lambda: str(tmp_path / "allowed")
186+
)
187+
outside = tmp_path / "outside.xlsx"
188+
outside.write_bytes(b"private data")
189+
user_excel._TEMP_FILE_MAP["outside"] = str(outside)
190+
response = client.get("/user/errorRecord/outside")
191+
assert response.status_code == 403, response.text
192+
assert response.json() == "Unauthorized file access"
193+
assert outside.read_bytes() == b"private data"
194+
195+
196+
@pytest.mark.parametrize("kind", ["knowledge", "user"])
197+
def test_valid_error_file_can_be_downloaded(api, tmp_path, kind):
198+
client, _session = api
199+
content = workbook(["error"])
200+
target = tmp_path / "valid_error.xlsx"
201+
target.write_bytes(content)
202+
if kind == "knowledge":
203+
response = client.post("/system/download-fail-info", json={"file": target.name})
204+
else:
205+
user_excel._TEMP_FILE_MAP["valid"] = str(target)
206+
response = client.get("/user/errorRecord/valid")
207+
assert response.status_code == 200, response.text
208+
assert response.content == content

0 commit comments

Comments
 (0)