Repository navigation
fix(sales): owner check on the ticket-route deposit-notice draft - #1171
Merged
Merged
Conversation
POST /api/tickets/{id}/deposit-notice/draft reaches
DepositNoticeService.createDraftFromQuotation, which checks role and ticket
status but not ownership, unlike update/issue. These tests pin the refusal
(403, no deposit_notice row) and the owner positive control, against real
Postgres and in the mock. Red until the guard lands.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
createDraftFromQuotation now calls requireTicketOwner after requireApprovedTicket, the same owner-only guard update/issue use. Any sales user could previously draft on another rep's deal, read its quotation lines, prices, percent and customer, and plant the owner's working draft. The pricing-request route already ran requireOwner before delegating, so it behaves the same. The mock's depositNotices.createDraft mirrors requireRole(sales) + owner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…eal status; mock pins the sales-only role gate Review notes on #1171: a non-owning rep must get 403 on an unapproved deal too (not the 409 that leaks its status), and the mock's new role gate had no test. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review note on #1171: with the status check first, a non-owning rep learned whether another rep's deal was approved (409) before being refused (403). Owner first, like the pricing-request route and RemainingInvoiceService. Mock: drop the optional chaining that could never fire (findTicketRaw already 404s). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
waritctd
marked this pull request as ready for review
October 22, 2026 13:10
waritctd
added a commit
that referenced
this pull request
Oct 23, 2026
…p, before the status check Code-review item 1 on #1170. Same tests as #1171 (1e4ef01 + 4bec63d, already on develop), cherry-picked so this branch carries identical lines and merges into develop cleanly: a non-owning rep gets 403 on an unapproved deal and on a confirmed one, nothing is written, and the owner still drafts. Mock: the same pin plus the sales-only role gate. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXmTJDVga17Rn5az4YBjZ2
waritctd
added a commit
that referenced
this pull request
Oct 23, 2026
… deal status Code-review item 1 on #1170. createDraftFromQuotation now calls requireTicketOwner right after the role check, before requireApprovedTicket, so a non-owning rep gets 403 and never the 409 that tells them whether another rep's deal is approved. Any sales user could otherwise draft on another rep's deal and read its governing quotation's lines, prices, number and the customer's tax id and address. The pricing-request route already ran requireOwner; the second check there gives the same 403. Same lines as #1171 (416dc3b + 93a7ed4, on develop), cherry-picked so the merge is clean. Mock createDraft mirrors the role and owner gate. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXmTJDVga17Rn5az4YBjZ2
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
POST /api/tickets/{ticketId}/deposit-notice/draft→DepositNoticeService.createDraft→createDraftFromQuotationchecked the role (sales) and the ticket status, but not ownership.update()andissue()in the same class callrequireTicketOwner(requestRevision()inlines the samecreatedById != actor.id()comparison). So anysalesuser could:Authz change, stated explicitly:
createDraftFromQuotationnow callsrequireTicketOwner(ticketId, actor)afterrequireApprovedTicket. This is the same owner-only guard thatupdate()andissue()use. It is not widened tosales_managerorceo;requireRole(SALES_ROLES)already limits this method tosales. The guard sits in the shared method, so both callers get it:DepositNoticeController) is the bug.OrderConfirmationService.createDepositNoticeFromQuotation) already ranrequireOwnerbefore delegating, so its behaviour does not change. Its existing testcreateDepositNoticeFromQuotation_newEngine_nonOwnerSales_cannotReachstill passes.Files changed
backend/src/main/java/th/co/glr/hr/deposit/DepositNoticeService.javarequireTicketOwner(ticketId, actor)tocreateDraftFromQuotation(one line).frontend/src/api/mockApi.jsdepositNotices.createDraftnow doeshasRole('sales')plusticket.createdById !== user.id→ 403ไม่มีสิทธิ์เข้าถึงรายการนี้. This mirrorsrequireRole(SALES_ROLES)+requireTicketOwner, the same way the mock's pricing-request route already does.backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationOutcomeIntegrationTest.javacreateDraft_ticketRoute_byNonOwningSales_refusedAndWritesNothing.frontend/src/api/mockApi.depositNotices.test.jsticket-level createDraft refuses a sales rep who does not own the deal and creates nothing.There are two commits: the tests first (
1e4ef01b), then the fix (416dc3b1).Commands run
Tests / build results
Integration tests ran against real Postgres through Testcontainers.
DealQuotationOutcomeIntegrationTestcreateDraft(DepositNoticeServiceTest48,DepositNoticeControllerTest8,PaymentTrackIntegrationTest14,FinanceDealIntegrationTest23,OrderConfirmationIntegrationTest13)mockApi.depositNotices.test.jscreateDraftNot run locally: the full
mvnw verify, the fullnpm test, andnpm run build. CI gates those.Authz evidence
Real-service, real-DB.
createDraft_ticketRoute_byNonOwningSales_refusedAndWritesNothingdrives the realDepositNoticeServiceand repository against real Postgres (AbstractPostgresIntegrationTest). The test is written wrong-way-round: a non-owningsalesrep gets 403, andSELECT COUNT(*) FROM sales.deposit_notice WHERE ticket_id = :idis 0.Mutation-checked: with only the guard line removed (after
mvnw clean), exactly this one test went red (34 tests, 1 failure, "Expecting code to raise a throwable."). Restoring the line left an empty diff, and the class was green again.The mock test is drift protection only. It is not evidence about production authz.
Known risks
requireApprovedTicketbefore the 403. This matches the ordering inupdate()andissue(), which check state before the owner. The mock checks the owner before status, so it returns 403 in that case. This is a status-only divergence, and the mock is not more permissive.createDraftnow refuses every role exceptsales. Before, any logged-in role could draft in mock mode. Java'sSALES_ROLESis alsosalesonly. One known divergence, not new here: Java remaps a quotation-grant holder outside ฝ่ายขาย to rolesales(fix(sales): grant holder outside ฝ่ายขาย acts as sales; ผู้ติดต่อ Phone/Email print on the quotation #1144) while the mock keeps their own role, so the mock refuses a grant holder on their own deal where Java accepts (mock stricter than Java, the safe direction; the mock's pricing-request route has the same gap). No existing mock-driven test relied on the old behaviour: the 61 tests above pass, andDepositNoticePage.test.jsxstubs the api.depositNotices.updateandissuestill have no owner check, although the Java versions do. This is out of scope here and left as a follow-up.fix/deposit-null-percent-matches-pdf) adds tests to the same two test files.git merge-treeagainst its head shows a clean merge.Test coverage (test-first)
DealQuotationOutcomeIntegrationTest#createDraft_ticketRoute_byNonOwningSales_refusedAndWritesNothingconfirmOrderas the owner. A secondsalesrep callscreateDrafton the ticket route:ApiExceptionwith statusFORBIDDENand "ไม่มีสิทธิ์เข้าถึงรายการนี้", and 0deposit_noticerows. Positive control: the owner then drafts on the same deal, and the count is 1.mockApi.depositNotices.test.js›ticket-level createDraft refuses a sales rep who does not own the deal and creates nothingsales2@glr.co.thgets 403 andlistByTicketis empty. The owner still drafts. Also pins the mock's sales-only role gate: aceoon a ticket that does not exist gets 403, not 404 (role runs before the lookup, as in Java).Red before green (tests committed before the fix):
createDraft_ticketRoute_byNonOwningSales_refusedAndWritesNothing,java.lang.AssertionError: Expecting code to raise a throwable.atDealQuotationOutcomeIntegrationTest.java:338(34 tests, 1 failure).AssertionError: promise resolved "{ depositNotice: { id: 11, …(25) } }" instead of rejecting(1 failed, 9 passed).Expecting 403 FORBIDDEN … 409 CONFLICT(34 run, 1 failure), green after the reorder; mock role-gate pin red under the mutantrequireSession()withexpected Error: ไม่พบดีลนี้ { status: 404 } to match { status: 403 }, green restored. After the reorder: DealQuotationOutcome 34, DepositNoticeServiceTest 48, DepositNoticeControllerTest 8, OrderConfirmationIntegrationTest 13, DealQuotationConfirmOrder 31, DepositNoticeIssueGuard 3, all green (real Postgres); vitest depositNotices + contract 16/16; eslint clean.Both failures are on the missing behaviour, not compile or fixture errors.
Deliberately not covered:
sales_manager,ceo,importandaccounton this route.requireRole(SALES_ROLES)already refuses them and this PR does not change that.e2e-real) case.🤖 Generated with Claude Code