From 3a42d01072f8dc436a22431ff32426e99aa72e16 Mon Sep 17 00:00:00 2001 From: cjs1102 <180498659+cjs1102@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:15:30 +0800 Subject: [PATCH 1/5] fix(job): return an empty recommended page for new users A user with no profile and no skills is a normal new-account state, not an invalid request. Return 200 with an empty page, echoing the requested page/page_size, and cover it at the service and HTTP layers. Co-Authored-By: Claude Code --- service/src/job/job_ranking_service.cpp | 10 ++++- service/tests/test_job_http.cpp | 46 ++++++++++++++++++++++ service/tests/test_job_ranking_service.cpp | 15 +++++-- 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/service/src/job/job_ranking_service.cpp b/service/src/job/job_ranking_service.cpp index 97442b9..8090d04 100644 --- a/service/src/job/job_ranking_service.cpp +++ b/service/src/job/job_ranking_service.cpp @@ -92,9 +92,15 @@ namespace jobpilot::service if (!profile.has_value() && skills.empty()) { + // A new account without a profile or skills is a normal + // state, not a bad request. Return an empty page so the UI can + // render its onboarding state without a spurious 400. + RankedJobPage empty; + empty.page = page; + empty.pageSize = pageSize; return ServiceResult{ - ErrorCode::kInvalidParam, - {}}; + ErrorCode::kSuccess, + empty}; } RankedJobPage result; diff --git a/service/tests/test_job_http.cpp b/service/tests/test_job_http.cpp index 1e8bccd..24c79fa 100644 --- a/service/tests/test_job_http.cpp +++ b/service/tests/test_job_http.cpp @@ -363,6 +363,21 @@ namespace jobpilot::service "job-http-token"); sessions->Set(token, userId); + emptyEmail = + jobpilot::test::UniqueEmail( + "job-http-empty-user"); + const std::optional createdEmptyUser = + userDao->CreateUser( + "job-http-empty-user", + emptyEmail, + "hash"); + ASSERT_TRUE(createdEmptyUser.has_value()); + emptyUserId = createdEmptyUser.value(); + emptyToken = + jobpilot::test::UniqueJobToken( + "job-http-empty-token"); + sessions->Set(emptyToken, emptyUserId); + UserProfile profile; profile.userId = userId; profile.targetPosition = "C++"; @@ -482,9 +497,13 @@ namespace jobpilot::service pool.get(), queuedId); sessions->Remove(token); + sessions->Remove(emptyToken); jobpilot::test::CleanupUser( pool.get(), email); + jobpilot::test::CleanupUser( + pool.get(), + emptyEmail); } ServerState state; @@ -515,6 +534,9 @@ namespace jobpilot::service std::string email; std::string token; std::uint64_t userId = 0; + std::string emptyEmail; + std::string emptyToken; + std::uint64_t emptyUserId = 0; }; TEST_F(JobHttpTest, SearchesWithPaginationAndPercentDecoding) @@ -989,6 +1011,30 @@ namespace jobpilot::service EXPECT_EQ(badStrict->statusCode, 400); } + TEST_F(JobHttpTest, ReturnsEmptyRecommendationsForNewUser) + { + const std::optional response = + SendRequest( + "/api/v1/jobs/recommended?page=1&page_size=5", + "Bearer " + emptyToken); + + ASSERT_TRUE(response.has_value()); + ASSERT_TRUE(response->hasJson); + EXPECT_EQ(response->statusCode, 200); + EXPECT_EQ(response->jsonBody["code"], 0); + EXPECT_TRUE( + response->jsonBody["data"]["list"].empty()); + EXPECT_EQ( + response->jsonBody["data"]["total"], + 0); + EXPECT_EQ( + response->jsonBody["data"]["page"], + 1); + EXPECT_EQ( + response->jsonBody["data"]["page_size"], + 5); + } + TEST_F(JobHttpTest, ReturnsDeterministicRecommendations) { const std::optional unauthenticated = diff --git a/service/tests/test_job_ranking_service.cpp b/service/tests/test_job_ranking_service.cpp index 1179f7d..4c7ca6b 100644 --- a/service/tests/test_job_ranking_service.cpp +++ b/service/tests/test_job_ranking_service.cpp @@ -234,7 +234,7 @@ namespace jobpilot::service 0.0); } - TEST(JobRankingServiceTest, RequiresUsableProfileOrSkills) + TEST(JobRankingServiceTest, ReturnsEmptyPageWithoutProfileOrSkills) { FakeJobRankingDao dao; FakeProfileReader reader; @@ -244,9 +244,16 @@ namespace jobpilot::service reader, authenticator); - EXPECT_EQ( - service.RankJobs(1, {}, 1, 20).code, - ErrorCode::kInvalidParam); + const ServiceResult result = + service.RankJobs(1, {}, 3, 5); + + ASSERT_EQ(result.code, ErrorCode::kSuccess); + EXPECT_TRUE(result.value.list.empty()); + EXPECT_EQ(result.value.total, 0U); + EXPECT_EQ(result.value.rankedTotal, 0U); + EXPECT_FALSE(result.value.truncated); + EXPECT_EQ(result.value.page, 3U); + EXPECT_EQ(result.value.pageSize, 5U); } } // namespace jobpilot::service From 5390e9030ee11cc0332a2c385df66e1a0bc26ce7 Mon Sep 17 00:00:00 2001 From: cjs1102 <180498659+cjs1102@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:15:36 +0800 Subject: [PATCH 2/5] fix(web): render match reasons without duplicating skills The card rendered matched/missing skills explicitly and then rendered the same information again through reasons[]. Keep the reason list (it carries code/text/evidence) and add a Firefox DOM assertion that each label appears at most once per card. Co-Authored-By: Claude Code --- tools/dev/web_browser_smoke.py | 40 +++++++++++++++++++++++++++++----- web/js/jobs.js | 13 ----------- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/tools/dev/web_browser_smoke.py b/tools/dev/web_browser_smoke.py index 21be6ac..7d06c78 100644 --- a/tools/dev/web_browser_smoke.py +++ b/tools/dev/web_browser_smoke.py @@ -153,6 +153,12 @@ def screenshot(self, path): with open(path, "wb") as handle: handle.write(base64.b64decode(value)) + def scroll_into_view(self, element_id): + self._request("POST", self._session("/execute/sync"), { + "script": "arguments[0].scrollIntoView({block: 'center'});", + "args": [{"element-6066-11e4-a52e-4f735466cecf": element_id}], + }) + def wait_for(predicate, description, timeout=20.0): deadline = time.time() + timeout @@ -199,9 +205,34 @@ def upload_resume(driver, web_url, resume_file): wait_text(driver, "当前简历版本 id", timeout=30) -def search_and_open(driver, web_url, keyword, expected_title): +def capture_missing_skills_card(driver, screenshot_dir=None): + card = wait_for( + lambda: driver.find_xpath( + "//article[contains(@class,'job-card')][contains(., '缺少技能')]" + ), + "a job card with missing skills", + timeout=25, + ) + if screenshot_dir: + os.makedirs(screenshot_dir, exist_ok=True) + driver.scroll_into_view(card) + time.sleep(0.5) + driver.screenshot(os.path.join(screenshot_dir, "web-jobs.png")) + card_text = driver.text(card) + for label in ("缺少技能", "命中技能"): + count = card_text.count(label) + if count > 1: + raise WebDriverError( + "%s appears %d times in one job card; expected at most once" + % (label, count) + ) + return card + + +def search_and_open(driver, web_url, keyword, expected_title, screenshot_dir=None): driver.navigate(web_url + "/#/jobs") wait_text(driver, "岗位列表") + capture_missing_skills_card(driver, screenshot_dir) keyword_input = driver.find("#keyword") driver.send_keys(keyword_input, keyword) driver.click(driver.find_xpath("//form[contains(@class,'filters')]//button[@type='submit']")) @@ -210,9 +241,6 @@ def search_and_open(driver, web_url, keyword, expected_title): "job row %r" % expected_title, timeout=25, ) - card = driver.find_xpath( - "//article[contains(@class,'job-card')][contains(., '%s')]" % expected_title - ) driver.click(driver.find_xpath( "//article[contains(@class,'job-card')][contains(., '%s')]" "//a[contains(@class,'job-title')]" % expected_title @@ -222,7 +250,7 @@ def search_and_open(driver, web_url, keyword, expected_title): body = driver.body_text() if "未提供链接" not in body: raise WebDriverError("expected the audit sample to have no apply_url") - return card + return None def favourite(driver, web_url, expected_title): @@ -276,7 +304,7 @@ def main(argv=None): try: login(driver, args.web_url, args.email, args.password) upload_resume(driver, args.web_url, resume_file) - search_and_open(driver, args.web_url, args.keyword, args.expected_title) + search_and_open(driver, args.web_url, args.keyword, args.expected_title, args.screenshot_dir) if args.screenshot_dir: os.makedirs(args.screenshot_dir, exist_ok=True) driver.screenshot(os.path.join(args.screenshot_dir, "web-detail.png")) diff --git a/web/js/jobs.js b/web/js/jobs.js index a22c399..8fb7c95 100644 --- a/web/js/jobs.js +++ b/web/js/jobs.js @@ -84,8 +84,6 @@ function textField(id, label, value, attrs = {}) { function matchesBlock(job) { const hasScore = typeof job.score === "number"; const reasons = Array.isArray(job.reasons) ? job.reasons : []; - const matched = Array.isArray(job.matched_skills) ? job.matched_skills : []; - const missing = Array.isArray(job.missing_skills) ? job.missing_skills : []; const breakers = Array.isArray(job.deal_breakers) ? job.deal_breakers : []; if (!hasScore && reasons.length === 0 && breakers.length === 0) { @@ -111,17 +109,6 @@ function matchesBlock(job) { block.appendChild(badges); } - if (matched.length > 0) { - block.appendChild(el("div", { class: "small" }, - el("span", { class: "muted" }, "命中技能:"), - matched.join("、"))); - } - if (missing.length > 0) { - block.appendChild(el("div", { class: "small" }, - el("span", { class: "muted" }, "缺少技能:"), - missing.join("、"))); - } - if (reasons.length > 0) { const list = el("ul", { class: "match-reasons" }); for (const reason of reasons) { From a1510b1f7afa100388036a89def94af0510c4dac Mon Sep 17 00:00:00 2001 From: cjs1102 <180498659+cjs1102@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:15:43 +0800 Subject: [PATCH 3/5] fix(resume): reject non-UTF-8 persisted strings at the boundary Add a strict core UTF-8 validator and validate multipart title/filename, txt/md resume bodies, and public JSON write paths before storage or a transaction. Invalid input returns 400/10001 with no row or file written. The resume upload error paths also name title/file in data.field. Co-Authored-By: Claude Code --- backend/CMakeLists.txt | 13 ++ backend/include/jobpilot/core/utf8.h | 11 ++ backend/src/core/utf8.cpp | 134 +++++++++++++ backend/tests/test_utf8.cpp | 55 ++++++ .../jobpilot/service/request_validation.h | 86 +++++++++ service/src/resume/resume_controller.cpp | 73 ++++++- service/src/resume/resume_service.cpp | 14 ++ service/src/user/user_controller.cpp | 28 ++- service/tests/test_resume_http.cpp | 178 ++++++++++++++++++ service/tests/test_user_http.cpp | 33 ++++ 10 files changed, 617 insertions(+), 8 deletions(-) create mode 100644 backend/include/jobpilot/core/utf8.h create mode 100644 backend/src/core/utf8.cpp create mode 100644 backend/tests/test_utf8.cpp create mode 100644 service/include/jobpilot/service/request_validation.h diff --git a/backend/CMakeLists.txt b/backend/CMakeLists.txt index 3aac3ac..a3b2f44 100644 --- a/backend/CMakeLists.txt +++ b/backend/CMakeLists.txt @@ -105,6 +105,7 @@ add_library(jobpilot_core STATIC src/core/core.cpp src/core/logger.cpp src/core/thread_pool.cpp + src/core/utf8.cpp src/network/channel.cpp src/network/epoll_poller.cpp src/network/event_loop.cpp @@ -154,6 +155,16 @@ target_link_libraries(test_config GTest::gtest_main ) +add_executable(test_utf8 + tests/test_utf8.cpp +) + +target_link_libraries(test_utf8 + PRIVATE + jobpilot_core + GTest::gtest_main +) + add_executable(test_logger tests/test_logger.cpp ) @@ -518,6 +529,7 @@ if(JP_ENABLE_TSAN AND CMAKE_SYSTEM_NAME STREQUAL "Linux") set_target_properties( test_config + test_utf8 test_logger test_http_correctness test_schema @@ -552,6 +564,7 @@ endif() include(GoogleTest) gtest_discover_tests(test_config) +gtest_discover_tests(test_utf8) gtest_discover_tests(test_logger) gtest_discover_tests(test_sql_result) gtest_discover_tests(test_http_correctness) diff --git a/backend/include/jobpilot/core/utf8.h b/backend/include/jobpilot/core/utf8.h new file mode 100644 index 0000000..50c5d6a --- /dev/null +++ b/backend/include/jobpilot/core/utf8.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +namespace jobpilot::core +{ + // Strict UTF-8 check used at request boundaries before a value can reach + // storage or MySQL. Rejects overlong forms, surrogate code points, values + // above U+10FFFF, truncated sequences, and stray continuation bytes. + bool IsValidUtf8(std::string_view bytes) noexcept; +} // namespace jobpilot::core diff --git a/backend/src/core/utf8.cpp b/backend/src/core/utf8.cpp new file mode 100644 index 0000000..cc92a0b --- /dev/null +++ b/backend/src/core/utf8.cpp @@ -0,0 +1,134 @@ +#include "jobpilot/core/utf8.h" + +#include + +namespace jobpilot::core +{ + namespace + { + bool IsContinuation(unsigned char value) noexcept + { + return (value & 0xC0U) == 0x80U; + } + } // namespace + + bool IsValidUtf8(std::string_view bytes) noexcept + { + std::size_t index = 0; + + while (index < bytes.size()) + { + const unsigned char first = + static_cast(bytes[index]); + + if (first <= 0x7FU) + { + ++index; + continue; + } + + if (first >= 0xC2U && first <= 0xDFU) + { + if (index + 1 >= bytes.size() || + !IsContinuation( + static_cast(bytes[index + 1]))) + { + return false; + } + + index += 2; + continue; + } + + if (first >= 0xE0U && first <= 0xEFU) + { + if (index + 2 >= bytes.size()) + { + return false; + } + + const unsigned char second = + static_cast(bytes[index + 1]); + const unsigned char third = + static_cast(bytes[index + 2]); + + if (!IsContinuation(third)) + { + return false; + } + + if (first == 0xE0U) + { + // E0 requires A0..BF to avoid an overlong 3-byte form. + if (second < 0xA0U || second > 0xBFU) + { + return false; + } + } + else if (first == 0xEDU) + { + // ED 80..9F only; A0..BF would encode a surrogate. + if (second < 0x80U || second > 0x9FU) + { + return false; + } + } + else if (!IsContinuation(second)) + { + return false; + } + + index += 3; + continue; + } + + if (first >= 0xF0U && first <= 0xF4U) + { + if (index + 3 >= bytes.size()) + { + return false; + } + + const unsigned char second = + static_cast(bytes[index + 1]); + const unsigned char third = + static_cast(bytes[index + 2]); + const unsigned char fourth = + static_cast(bytes[index + 3]); + + if (!IsContinuation(third) || !IsContinuation(fourth)) + { + return false; + } + + if (first == 0xF0U) + { + // F0 requires 90..BF to avoid an overlong 4-byte form. + if (second < 0x90U || second > 0xBFU) + { + return false; + } + } + else if (first == 0xF4U) + { + // F4 80..8F only; 90..BF would exceed U+10FFFF. + if (second < 0x80U || second > 0x8FU) + { + return false; + } + } + else if (!IsContinuation(second)) + { + return false; + } + + index += 4; + continue; + } + + return false; + } + + return true; + } +} // namespace jobpilot::core diff --git a/backend/tests/test_utf8.cpp b/backend/tests/test_utf8.cpp new file mode 100644 index 0000000..731caa4 --- /dev/null +++ b/backend/tests/test_utf8.cpp @@ -0,0 +1,55 @@ +#include "jobpilot/core/utf8.h" + +#include + +#include + +namespace jobpilot::core +{ + namespace + { + TEST(Utf8Test, AcceptsValidAsciiAndUnicode) + { + EXPECT_TRUE(IsValidUtf8("")); + EXPECT_TRUE(IsValidUtf8("plain ASCII")); + EXPECT_TRUE(IsValidUtf8("\xE4\xB8\xAD\xE6\x96\x87")); + EXPECT_TRUE(IsValidUtf8("\xF0\x9F\x98\x80")); + EXPECT_TRUE(IsValidUtf8("\xEF\xBF\xBF")); + EXPECT_TRUE(IsValidUtf8("\xF4\x8F\xBF\xBF")); + } + + TEST(Utf8Test, RejectsOverlongForms) + { + EXPECT_FALSE(IsValidUtf8(std::string("\xC0\xAF", 2))); + EXPECT_FALSE(IsValidUtf8(std::string("\xE0\x80\xAF", 3))); + EXPECT_FALSE(IsValidUtf8(std::string("\xF0\x80\x80\xAF", 4))); + } + + TEST(Utf8Test, RejectsSurrogates) + { + EXPECT_FALSE(IsValidUtf8(std::string("\xED\xA0\x80", 3))); + EXPECT_FALSE(IsValidUtf8(std::string("\xED\xBF\xBF", 3))); + } + + TEST(Utf8Test, RejectsTruncatedSequences) + { + EXPECT_FALSE(IsValidUtf8(std::string("\xE2", 1))); + EXPECT_FALSE(IsValidUtf8(std::string("\xE2\x82", 2))); + EXPECT_FALSE(IsValidUtf8(std::string("\xF0\x9F\x98", 3))); + } + + TEST(Utf8Test, RejectsStrayContinuationAndInvalidLeads) + { + EXPECT_FALSE(IsValidUtf8(std::string("\x80", 1))); + EXPECT_FALSE(IsValidUtf8(std::string("\xBF", 1))); + EXPECT_FALSE(IsValidUtf8(std::string("\xC0", 1))); + EXPECT_FALSE(IsValidUtf8(std::string("\xFE", 1))); + EXPECT_FALSE(IsValidUtf8(std::string("\xFF", 1))); + } + + TEST(Utf8Test, RejectsValuesAboveUnicodeMaximum) + { + EXPECT_FALSE(IsValidUtf8(std::string("\xF4\x90\x80\x80", 4))); + } + } // namespace +} // namespace jobpilot::core diff --git a/service/include/jobpilot/service/request_validation.h b/service/include/jobpilot/service/request_validation.h new file mode 100644 index 0000000..8868e00 --- /dev/null +++ b/service/include/jobpilot/service/request_validation.h @@ -0,0 +1,86 @@ +#pragma once + +#include +#include + +#include + +#include "jobpilot/core/utf8.h" + +namespace jobpilot::service +{ + // Additive parameter-error payload. The code/message/status stay unchanged; + // clients that only inspect code/message can ignore data. + inline nlohmann::json ParamError(std::string_view field) + { + return nlohmann::json{{"field", std::string(field)}}; + } + + // Reject any invalid UTF-8 in JSON keys or string values before a request + // reaches storage/MySQL. `field` receives the last object key (or "body" + // for a top-level string), which is enough for the public write paths. + inline bool ValidateJsonUtf8( + const nlohmann::json &value, + std::string *field, + std::string_view current = {}) + { + if (value.is_string()) + { + if (!jobpilot::core::IsValidUtf8( + value.get_ref())) + { + if (field != nullptr) + { + *field = current.empty() + ? "body" + : std::string(current); + } + + return false; + } + + return true; + } + + if (value.is_object()) + { + for (auto iterator = value.begin(); + iterator != value.end(); + ++iterator) + { + if (!jobpilot::core::IsValidUtf8(iterator.key())) + { + if (field != nullptr) + { + *field = iterator.key(); + } + + return false; + } + + if (!ValidateJsonUtf8( + iterator.value(), + field, + iterator.key())) + { + return false; + } + } + + return true; + } + + if (value.is_array()) + { + for (const auto &item : value) + { + if (!ValidateJsonUtf8(item, field, current)) + { + return false; + } + } + } + + return true; + } +} // namespace jobpilot::service diff --git a/service/src/resume/resume_controller.cpp b/service/src/resume/resume_controller.cpp index dfa5d91..4f786d1 100644 --- a/service/src/resume/resume_controller.cpp +++ b/service/src/resume/resume_controller.cpp @@ -11,6 +11,7 @@ #include #include "jobpilot/error.h" +#include "jobpilot/service/request_validation.h" #include "jobpilot/http/multipart_form_data_parser.h" namespace jobpilot::service @@ -449,6 +450,18 @@ namespace jobpilot::service jobpilot::core::HttpResponse *response, Json *body) { + if (!jobpilot::core::IsValidUtf8( + request.Body())) + { + WriteResponse( + response, + request.KeepAlive(), + ErrorCode::kInvalidParam, + ParamError("body")); + + return false; + } + try { *body = Json::parse( @@ -471,7 +484,20 @@ namespace jobpilot::service response, request.KeepAlive(), ErrorCode::kInvalidParam, - nullptr); + ParamError("body")); + + return false; + } + + std::string invalidField; + + if (!ValidateJsonUtf8(*body, &invalidField)) + { + WriteResponse( + response, + request.KeepAlive(), + ErrorCode::kInvalidParam, + ParamError(invalidField)); return false; } @@ -521,7 +547,7 @@ namespace jobpilot::service response, request.KeepAlive(), ErrorCode::kInvalidParam, - nullptr); + ParamError("title")); return std::nullopt; } @@ -536,7 +562,7 @@ namespace jobpilot::service response, request.KeepAlive(), ErrorCode::kInvalidParam, - nullptr); + ParamError("file")); return std::nullopt; } @@ -545,15 +571,48 @@ namespace jobpilot::service } } - if (title == nullptr || - file == nullptr || + if (title == nullptr) + { + WriteResponse( + response, + request.KeepAlive(), + ErrorCode::kInvalidParam, + ParamError("title")); + + return std::nullopt; + } + + if (file == nullptr || !file->filename.has_value()) { WriteResponse( response, request.KeepAlive(), ErrorCode::kInvalidParam, - nullptr); + ParamError("file")); + + return std::nullopt; + } + + if (!jobpilot::core::IsValidUtf8(title->body)) + { + WriteResponse( + response, + request.KeepAlive(), + ErrorCode::kInvalidParam, + ParamError("title")); + + return std::nullopt; + } + + if (!jobpilot::core::IsValidUtf8( + file->filename.value())) + { + WriteResponse( + response, + request.KeepAlive(), + ErrorCode::kInvalidParam, + ParamError("file")); return std::nullopt; } @@ -756,7 +815,7 @@ namespace jobpilot::service &response, request.KeepAlive(), ErrorCode::kInvalidParam, - nullptr); + ParamError("content")); return; } diff --git a/service/src/resume/resume_service.cpp b/service/src/resume/resume_service.cpp index 7d18f70..76805a0 100644 --- a/service/src/resume/resume_service.cpp +++ b/service/src/resume/resume_service.cpp @@ -8,6 +8,7 @@ #include #include "jobpilot/core/logger.h" +#include "jobpilot/core/utf8.h" #include "jobpilot/database/mysql_pool.h" #include "jobpilot/storage/file_storage.h" @@ -133,6 +134,8 @@ namespace jobpilot::service if (upload.title.empty() || upload.title.size() > kMaxTitleBytes || + !jobpilot::core::IsValidUtf8(upload.title) || + !jobpilot::core::IsValidUtf8(upload.filename) || !extension.has_value() || !IsSupportedExtension(extension.value()) || upload.bytes.empty() || @@ -151,6 +154,17 @@ namespace jobpilot::service : std::optional( std::string(upload.bytes)); + // Text resume bytes are persisted as version content. Validate them + // before the temp file or transaction is created so an invalid client + // value cannot surface as a database error. + if (content.has_value() && + !jobpilot::core::IsValidUtf8(content.value())) + { + return ServiceResult{ + ErrorCode::kInvalidParam, + {}}; + } + UploadStage stage = UploadStage::kStorage; bool tempCreated = false; bool promoted = false; diff --git a/service/src/user/user_controller.cpp b/service/src/user/user_controller.cpp index 0f0d5c6..19a6521 100644 --- a/service/src/user/user_controller.cpp +++ b/service/src/user/user_controller.cpp @@ -9,6 +9,7 @@ #include #include "jobpilot/error.h" +#include "jobpilot/service/request_validation.h" namespace jobpilot::service { @@ -216,6 +217,18 @@ namespace jobpilot::service jobpilot::core::HttpResponse *response, Json *body) { + if (!jobpilot::core::IsValidUtf8( + request.Body())) + { + WriteResponse( + response, + request.KeepAlive(), + ErrorCode::kInvalidParam, + ParamError("body")); + + return false; + } + try { *body = Json::parse( @@ -238,7 +251,20 @@ namespace jobpilot::service response, request.KeepAlive(), ErrorCode::kInvalidParam, - nullptr); + ParamError("body")); + + return false; + } + + std::string invalidField; + + if (!ValidateJsonUtf8(*body, &invalidField)) + { + WriteResponse( + response, + request.KeepAlive(), + ErrorCode::kInvalidParam, + ParamError(invalidField)); return false; } diff --git a/service/tests/test_resume_http.cpp b/service/tests/test_resume_http.cpp index 563bc53..67ef18b 100644 --- a/service/tests/test_resume_http.cpp +++ b/service/tests/test_resume_http.cpp @@ -791,6 +791,184 @@ namespace jobpilot::service EXPECT_EQ(unsupported->jsonBody["code"], 10001); } + TEST_F(ResumeHttpTest, RejectsInvalidUtf8BeforeWriting) + { + const auto user = + RegisterAndLogin("resume-http-utf8"); + const std::string authorization = + "Bearer " + user.second; + + const auto countResumes = [&]() + { + const jobpilot::core::Result result = + pool->Query( + "SELECT COUNT(*) FROM resumes WHERE user_id = ?", + {user.first}); + return result.value(0, 0).value(); + }; + + const auto countVersions = [&]() + { + const jobpilot::core::Result result = + pool->Query( + "SELECT COUNT(*) FROM resume_versions " + "WHERE resume_id IN (SELECT id FROM resumes WHERE user_id = ?)", + {user.first}); + return result.value(0, 0).value(); + }; + + const std::string beforeResumes = countResumes(); + const std::string beforeVersions = countVersions(); + + const std::string invalidTitle( + "\xBA\xF3\xBB\xD8\xB0\xE6", + 6); + const std::optional badTitle = + SendRequest( + "POST", + "/api/v1/resumes/upload", + MakeMultipart( + invalidTitle, + "resume.txt", + "text/plain", + "valid body"), + authorization, + "multipart/form-data; boundary=" + + std::string(kBoundary)); + + ASSERT_TRUE(badTitle.has_value()); + ASSERT_TRUE(badTitle->hasJson); + EXPECT_EQ(badTitle->statusCode, 400); + EXPECT_EQ(badTitle->jsonBody["code"], 10001); + EXPECT_EQ( + badTitle->jsonBody["data"]["field"], + "title"); + EXPECT_EQ(countResumes(), beforeResumes); + EXPECT_EQ(countVersions(), beforeVersions); + + const std::optional badFile = + SendRequest( + "POST", + "/api/v1/resumes/upload", + MakeMultipart( + "valid title", + "resume.txt", + "text/plain", + std::string(1, static_cast(0xFF))), + authorization, + "multipart/form-data; boundary=" + + std::string(kBoundary)); + + ASSERT_TRUE(badFile.has_value()); + ASSERT_TRUE(badFile->hasJson); + EXPECT_EQ(badFile->statusCode, 400); + EXPECT_EQ(badFile->jsonBody["code"], 10001); + EXPECT_EQ(countResumes(), beforeResumes); + EXPECT_EQ(countVersions(), beforeVersions); + + const std::string chineseTitle = u8"中文简历"; + const std::optional goodUpload = + SendRequest( + "POST", + "/api/v1/resumes/upload", + MakeMultipart( + chineseTitle, + "resume.txt", + "text/plain", + "hello"), + authorization, + "multipart/form-data; boundary=" + + std::string(kBoundary)); + + ASSERT_TRUE(goodUpload.has_value()); + ASSERT_TRUE(goodUpload->hasJson); + ASSERT_EQ(goodUpload->statusCode, 200); + const std::uint64_t resumeId = + goodUpload->jsonBody["data"]["resume_id"] + .get(); + + const std::string beforeBadContent = countVersions(); + std::string invalidJson = "{\"content\":\""; + invalidJson.push_back(static_cast(0xFF)); + invalidJson += "\"}"; + + const std::optional badContent = + SendRequest( + "POST", + "/api/v1/resumes/" + + std::to_string(resumeId) + + "/versions", + invalidJson, + authorization, + "application/json"); + + ASSERT_TRUE(badContent.has_value()); + ASSERT_TRUE(badContent->hasJson); + EXPECT_EQ(badContent->statusCode, 400); + EXPECT_EQ(badContent->jsonBody["code"], 10001); + EXPECT_EQ(countVersions(), beforeBadContent); + } + + TEST_F(ResumeHttpTest, NamesMissingMultipartFields) + { + const auto user = + RegisterAndLogin("resume-http-fields"); + const std::string authorization = + "Bearer " + user.second; + + std::string missingTitle = + "--" + std::string(kBoundary) + + "\r\n" + "Content-Disposition: form-data; name=\"file\"; " + "filename=\"resume.txt\"\r\n" + "Content-Type: text/plain\r\n" + "\r\n" + "hello" + "\r\n--" + std::string(kBoundary) + "--\r\n"; + + const std::optional noTitle = + SendRequest( + "POST", + "/api/v1/resumes/upload", + missingTitle, + authorization, + "multipart/form-data; boundary=" + + std::string(kBoundary)); + + ASSERT_TRUE(noTitle.has_value()); + ASSERT_TRUE(noTitle->hasJson); + EXPECT_EQ(noTitle->statusCode, 400); + EXPECT_EQ(noTitle->jsonBody["code"], 10001); + EXPECT_EQ( + noTitle->jsonBody["data"]["field"], + "title"); + + std::string missingFile = + "--" + std::string(kBoundary) + + "\r\n" + "Content-Disposition: form-data; name=\"title\"\r\n" + "\r\n" + "C++ resume" + "\r\n--" + std::string(kBoundary) + "--\r\n"; + + const std::optional noFile = + SendRequest( + "POST", + "/api/v1/resumes/upload", + missingFile, + authorization, + "multipart/form-data; boundary=" + + std::string(kBoundary)); + + ASSERT_TRUE(noFile.has_value()); + ASSERT_TRUE(noFile->hasJson); + EXPECT_EQ(noFile->statusCode, 400); + EXPECT_EQ(noFile->jsonBody["code"], 10001); + EXPECT_EQ( + noFile->jsonBody["data"]["field"], + "file"); + } + TEST_F(ResumeHttpTest, EnforcesCrossUserIsolation) { const auto firstUser = diff --git a/service/tests/test_user_http.cpp b/service/tests/test_user_http.cpp index 12d8ca0..5da1964 100644 --- a/service/tests/test_user_http.cpp +++ b/service/tests/test_user_http.cpp @@ -868,6 +868,39 @@ namespace jobpilot::service EXPECT_EQ(profileResult->statusCode, 400); EXPECT_EQ(profileResult->jsonBody["code"], 10001); + // Invalid UTF-8 is rejected before the JSON parser or any write. + std::string invalidUtf8 = "{\"target_position\":\""; + invalidUtf8.push_back(static_cast(0xFF)); + invalidUtf8 += "\"}"; + + const jobpilot::core::Result profilesBefore = + pool->Query( + "SELECT COUNT(*) FROM profiles WHERE user_id = ?", + {user.first}); + + const std::optional invalidUtf8Result = + SendRequest( + "PUT", + "/api/v1/users/profile", + invalidUtf8, + authorization); + + ASSERT_TRUE(invalidUtf8Result.has_value()); + ASSERT_TRUE(invalidUtf8Result->hasJson); + EXPECT_EQ(invalidUtf8Result->statusCode, 400); + EXPECT_EQ(invalidUtf8Result->jsonBody["code"], 10001); + EXPECT_EQ( + invalidUtf8Result->jsonBody["data"]["field"], + "body"); + + const jobpilot::core::Result profilesAfter = + pool->Query( + "SELECT COUNT(*) FROM profiles WHERE user_id = ?", + {user.first}); + EXPECT_EQ( + profilesAfter.value(0, 0).value(), + profilesBefore.value(0, 0).value()); + // 0015 A2: enum/range bounds for the candidate keys -> 400. const nlohmann::json candidateCases[] = { nlohmann::json{{"education_level", 6}}, From f4bf0161f933ee56407ce0657825dcb83813fbeb Mon Sep 17 00:00:00 2001 From: cjs1102 <180498659+cjs1102@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:15:55 +0800 Subject: [PATCH 4/5] fix(api): include the field name in parameter errors Add an additive data: {field: ...} payload for missing application and AI match fields, document the shape, and update the API contract for the new user recommendation and UTF-8 boundaries. Co-Authored-By: Claude Code --- docs/04-api-design.md | 26 ++++++++++++++++--- service/src/ai/ai_controller.cpp | 16 +++++++++--- .../application/application_controller.cpp | 16 +++++++++--- service/tests/test_ai_http.cpp | 3 +++ service/tests/test_application_http.cpp | 20 ++++++++++++++ 5 files changed, 71 insertions(+), 10 deletions(-) diff --git a/docs/04-api-design.md b/docs/04-api-design.md index b226f7b..d4c34bd 100644 --- a/docs/04-api-design.md +++ b/docs/04-api-design.md @@ -99,6 +99,12 @@ AI 接口均为异步:发起返回 `task_id`,客户端轮询 `GET /api/v1/ai | 60001 | internal error | 服务端内部错误 | | 60002 | database error | 数据库错误 | +**Field-aware parameter errors (2026-10-22).** `10001` may carry an additive +`data` object naming the rejected field, for example +`{"code":10001,"message":"invalid parameter","data":{"field":"title"}}`. +`code`, `message`, and the HTTP status are unchanged; existing clients may +ignore `data`. Raw JSON bodies that are not valid UTF-8 use `"field":"body"`. + --- ## 3. 用户接口 @@ -221,8 +227,13 @@ POST /api/v1/resumes/upload | 字段 | 说明 | |---|---| -| file | 文件(PDF/TXT/MD) | -| title | 简历名称,如 "C++后端版" | +| file | 文件(PDF/TXT/MD),必填 | +| title | 简历名称,如 "C++后端版",必填;UTF-8 | + +`title`/`filename` and the text body of `.txt`/`.md` files must be valid UTF-8. +Invalid input returns `400` with `code=10001` and, for these fields, +`data.field` set to `title` or `file`; no file or database row is written. +PDF bytes remain binary and are not UTF-8-validated. ```json { @@ -296,7 +307,8 @@ POST /api/v1/resumes/{id}/versions ``` `content` 为空或全空白 → `400`(`content` 为空串)/`text_status = "text_empty"`(全空白); -不带 `content` 键 → `400`。语义见 §4.1 的状态表。 +不带 `content` 键 → `400`(`data.field=content`)。`content` 必须是合法 UTF-8;否则 +返回 `400`/`10001` 且不写入任何版本。语义见 §4.1 的状态表。 ### 4.4 简历版本列表 @@ -609,6 +621,8 @@ Authorization: Bearer `posted_within_days`,响应条目同样带 `freshness` 等 0012 字段。 - 推荐条目与 5.1 同形,因此也包含 `source_url` 与 0020 的 `apply_url` (string|null,始终存在)。 +- **新用户没有画像且没有技能时不是错误**:返回 `200` + + `data.list=[]`、`data.total=0`(并回显 `page`/`page_size`),不再返回 `10001`。 ### 5.4 岗位内部导入 @@ -978,7 +992,8 @@ POST /api/v1/applications ``` `resume_version_id` 为必填项,且对应版本必须属于当前登录用户;`job_id` -必须对应启用中的岗位。重复投递返回 `40004`。 +必须对应启用中的岗位。重复投递返回 `40004`。缺少或类型错误时返回 +`400`/`10001`,并在 `data.field` 指明 `job_id` 或 `resume_version_id`。 ### 6.2 投递列表 @@ -1065,6 +1080,9 @@ POST /api/v1/ai/match } ``` +`job_id` 与 `resume_id` 都是必填的正整数。缺少或类型错误时返回 +`400`/`10001`,并在 `data.field` 指明 `job_id` 或 `resume_id`。 + ### 7.2 简历优化 / 面试题生成 ``` diff --git a/service/src/ai/ai_controller.cpp b/service/src/ai/ai_controller.cpp index 39c3ee9..575e332 100644 --- a/service/src/ai/ai_controller.cpp +++ b/service/src/ai/ai_controller.cpp @@ -8,6 +8,7 @@ #include #include "jobpilot/error.h" +#include "jobpilot/service/request_validation.h" #include "jobpilot/service/auth/session_authenticator.h" namespace jobpilot::service @@ -334,14 +335,23 @@ namespace jobpilot::service std::uint64_t jobId = 0; std::uint64_t resumeId = 0; - if (!ReadPositiveUnsigned(body, "job_id", &jobId) || - !ReadPositiveUnsigned(body, "resume_id", &resumeId)) + if (!ReadPositiveUnsigned(body, "job_id", &jobId)) { WriteResponse( &response, request.KeepAlive(), ErrorCode::kInvalidParam, - nullptr); + ParamError("job_id")); + return; + } + + if (!ReadPositiveUnsigned(body, "resume_id", &resumeId)) + { + WriteResponse( + &response, + request.KeepAlive(), + ErrorCode::kInvalidParam, + ParamError("resume_id")); return; } diff --git a/service/src/application/application_controller.cpp b/service/src/application/application_controller.cpp index 88e1786..c425f87 100644 --- a/service/src/application/application_controller.cpp +++ b/service/src/application/application_controller.cpp @@ -10,6 +10,7 @@ #include #include "jobpilot/error.h" +#include "jobpilot/service/request_validation.h" namespace jobpilot::service { @@ -509,8 +510,17 @@ namespace jobpilot::service std::uint64_t jobId = 0; std::uint64_t resumeVersionId = 0; - if (!TryGetUint64(body, "job_id", &jobId) || - !TryGetUint64( + if (!TryGetUint64(body, "job_id", &jobId)) + { + WriteResponse( + &response, + request.KeepAlive(), + ErrorCode::kInvalidParam, + ParamError("job_id")); + return; + } + + if (!TryGetUint64( body, "resume_version_id", &resumeVersionId)) @@ -519,7 +529,7 @@ namespace jobpilot::service &response, request.KeepAlive(), ErrorCode::kInvalidParam, - nullptr); + ParamError("resume_version_id")); return; } diff --git a/service/tests/test_ai_http.cpp b/service/tests/test_ai_http.cpp index 67d4178..cb99fc0 100644 --- a/service/tests/test_ai_http.cpp +++ b/service/tests/test_ai_http.cpp @@ -642,6 +642,9 @@ namespace jobpilot::service ASSERT_TRUE(missingResume->hasJson); EXPECT_EQ(missingResume->statusCode, 400); EXPECT_EQ(missingResume->jsonBody["code"], 10001); + EXPECT_EQ( + missingResume->jsonBody["data"]["field"], + "resume_id"); // Missing / inactive job -> 40001 with the 404 mapping. const auto unknownJob = diff --git a/service/tests/test_application_http.cpp b/service/tests/test_application_http.cpp index 8851361..56cb0cd 100644 --- a/service/tests/test_application_http.cpp +++ b/service/tests/test_application_http.cpp @@ -666,6 +666,26 @@ namespace jobpilot::service // JOB_PUBLIC_INCLUDE_NEEDS_REVIEW=1 the same request is accepted. That is // asserted in the service-level test, which can inject the flag. + const nlohmann::json missingResumeField{ + {"job_id", jobId}}; + const std::optional missingResumeFieldError = + SendRequest( + "POST", + "/api/v1/applications", + missingResumeField.dump(), + authorization); + + ASSERT_TRUE(missingResumeFieldError.has_value()); + ASSERT_TRUE(missingResumeFieldError->hasJson); + EXPECT_EQ(missingResumeFieldError->statusCode, 400); + EXPECT_EQ( + missingResumeFieldError->jsonBody["code"], + 10001); + EXPECT_EQ( + missingResumeFieldError + ->jsonBody["data"]["field"], + "resume_version_id"); + const nlohmann::json missingResume{ {"job_id", jobId}, {"resume_version_id", 999999999ULL}}; From 8307ca047e5a90bc633879af71b0f59406153492 Mon Sep 17 00:00:00 2001 From: cjs1102 <180498659+cjs1102@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:05:30 +0800 Subject: [PATCH 5/5] test(quality): update the coverage map for the new cases The 0028 tests add six UTF-8 cases and three HTTP cases (312 -> 321) and rename one ranking case. Update the map/report counts and the kept-candidate name so the CI coverage guard matches the live CTest list. Co-Authored-By: Claude Code --- docs/quality/test-merge-map.json | 8 ++++---- docs/quality/test-overlap-audit.md | 9 +++++++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/quality/test-merge-map.json b/docs/quality/test-merge-map.json index 1288d95..77c908c 100644 --- a/docs/quality/test-merge-map.json +++ b/docs/quality/test-merge-map.json @@ -8,8 +8,8 @@ "removed": [], "survivors": [], "counts": { - "before": 312, - "after": 312, + "before": 321, + "after": 321, "removed": 0, "survivors": 0, "candidates_evaluated": 66, @@ -593,7 +593,7 @@ "evidence": "A-only assertion not present in B: EXPECT_EQ( service.BuildTaskInput(\"\", 1).code, ErrorCode::kInvalidParam)" }, { - "removed": "JobRankingServiceTest.RequiresUsableProfileOrSkills", + "removed": "JobRankingServiceTest.ReturnsEmptyPageWithoutProfileOrSkills", "survivor": "JobRankingServiceTest.SortsPaginatesAndReportsTruncation", "binary": "test_job_ranking_service", "file": "service/tests/test_job_ranking_service.cpp", @@ -605,7 +605,7 @@ ], "rule_failure": "assertions_not_contained", "reason": "the removed case asserts at least one target the survivor never asserts", - "evidence": "A-only assertion not present in B: EXPECT_EQ( service.RankJobs(1, {}, 1, 20).code, ErrorCode::kInvalidParam)" + "evidence": "A-only assertions not present in B: EXPECT_EQ(result.code, ErrorCode::kSuccess) and EXPECT_TRUE(result.value.list.empty())" }, { "removed": "MatchResultJsonTest.IsDeterministicForTheSameInput", diff --git a/docs/quality/test-overlap-audit.md b/docs/quality/test-overlap-audit.md index 3e9338b..d072604 100644 --- a/docs/quality/test-overlap-audit.md +++ b/docs/quality/test-overlap-audit.md @@ -14,7 +14,7 @@ Companion artifacts: [`test-merge-map.json`](test-merge-map.json) (machine-reada checker). The map cannot drift from this report: the checker reads the counts block below and the live `ctest -N` list and fails when any of the three disagree. - + ## 1. Verdict @@ -22,7 +22,7 @@ the live `ctest -N` list and fails when any of the three disagree. |---|---| | CTest cases before the audit | **312** (309 at audit time; see the 2026-10-11 note) | | CTest cases removed by this audit (k) | **0** | -| CTest cases after the audit | **312** (nothing removed) | +| CTest cases after the audit | **321** (312 audit-time + 9 tests added by 0028; nothing removed) | > **2026-10-11:** three cases were added by the `needs_review` visibility fix > (`JobDaoTest.PublicDetailHidesQueuedRowsWhileReviewStillReadsThem`, @@ -31,6 +31,11 @@ the live `ctest -N` list and fails when any of the three disagree. > `ctest -N` count moved 309 → 312 and this block follows it. The audit itself is > unchanged: k = 0 removals, the same 66 evaluated pairs, the same rule. The > transcript below is the audit-time snapshot and is deliberately left as it was. +> +> **2026-10-23:** 0028 added 9 tests (6 UTF-8 unit cases plus 3 HTTP cases), +> moving the live count 312 → 321 with no removals. The map/report counts are +> updated to 321; the original 66 evaluated pairs and k = 0 conclusion remain +> unchanged. | Candidates mechanically/manually evaluated | 66 directed (50 unordered pairs) | | Candidates passing all clauses of the hard merge rule | 0 | | Candidates reported as NOT PROVEN | 1 (§4, loop-iteration equivalence) |