diff --git a/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/ApprovalWorkflowE2ETest.java b/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/ApprovalWorkflowE2ETest.java new file mode 100644 index 0000000..bd725d1 --- /dev/null +++ b/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/ApprovalWorkflowE2ETest.java @@ -0,0 +1,382 @@ +package cloud.nstart.terravault.ordinis.app.e2e; + +import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEventRepository; +import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Approval Workflow v2 — Phase 3 e2e coverage. + * + *

Полный maker-checker lifecycle на реальном Postgres + Spring Security + * (JWT через {@link JwtTestSupport}). Maker и reviewer = разные subjects → проверяем + * self-approve guard, exclusion constraint, regression на dict без approval_required, + * outbox event после approve. + * + *

Coverage matrix (соответствует Phase 3 чеклисту в design doc): + *

    + *
  1. {@link #happyFlow_submitApprove_recordLiveAndOutboxEvent} — full maker→checker
  2. + *
  3. {@link #rejectFlow_keepsDraft_noLiveRecord} — reject path
  4. + *
  5. {@link #selfApproveBlocked_409} — no_self_approve constraint
  6. + *
  7. {@link #twoPendingDraftsForSameBusinessKey_blocked} — one_pending_per_key
  8. + *
  9. {@link #directCrudBlockedWhenApprovalRequired} — D2=A gate (409 draft_required)
  10. + *
  11. {@link #regression_approvalNotRequired_directCrudStillWorks} — non-regression
  12. + *
  13. {@link #withdrawByMaker_keepsDraftStatus} — maker withdraw
  14. + *
  15. {@link #rejectWithoutComment_400} — reject_reason_required
  16. + *
+ */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class ApprovalWorkflowE2ETest { + + @DynamicPropertySource + static void props(DynamicPropertyRegistry registry) { + E2ESupport.registerProperties(registry); + // JWT auth ON — для self-approve guard нужны разные subjects. + registry.add("ordinis.auth.jwk-set-uri", JwtTestSupport::jwksUrl); + registry.add("ordinis.auth.require-authentication", () -> "true"); + registry.add("ordinis.auth.allow-query-scope", () -> "false"); + registry.add("ordinis.auth.issuer-uri", () -> ""); + } + + @Autowired MockMvc mvc; + @Autowired ObjectMapper om; + @Autowired DictionaryRecordRepository recordRepo; + @Autowired OutboxEventRepository outboxRepo; + + // === Helpers === + + /** Internal-scope JWT для конкретного юзера. */ + private static String tokenFor(String subject) { + return JwtTestSupport.signJwt(List.of("ordinis:client:internal"), subject); + } + + private static final String SCHEMA_JSON = """ + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "x-id-source": "code", + "required": ["code", "label"], + "properties": { + "code": { "type": "string", "x-unique": true }, + "label": { "type": "string", "minLength": 1 } + } + } + """; + + private String createDict(String token, String dictName, boolean approvalRequired) throws Exception { + JsonNode schema = om.readTree(SCHEMA_JSON); + var body = om.createObjectNode() + .put("name", dictName) + .put("scope", "INTERNAL") + .put("schemaVersion", "1.0.0") + .put("bundle", "test") + .put("approvalRequired", approvalRequired); + body.set("schemaJson", schema); + mvc.perform(post("/api/v1/dictionaries") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(body))) + .andExpect(status().is2xxSuccessful()); + return dictName; + } + + /** + * Создать dict без approval, затем через PUT включить approval_required. + * Тестируем path "включили approval после создания". + */ + private String createDictThenEnableApproval(String token, String dictName) throws Exception { + createDict(token, dictName, false); + JsonNode schema = om.readTree(SCHEMA_JSON); + var body = om.createObjectNode() + .put("name", dictName) + .put("scope", "INTERNAL") + .put("schemaVersion", "1.0.0") + .put("bundle", "test") + .put("approvalRequired", true); + body.set("schemaJson", schema); + mvc.perform(put("/api/v1/dictionaries/{name}", dictName) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(body))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.approvalRequired").value(true)); + return dictName; + } + + private static String randDictName(String prefix) { + return prefix + "_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8); + } + + private static String randBk() { + return "BK_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase(); + } + + private JsonNode submitDraft(String token, String dictName, String bk, String label) throws Exception { + var data = om.createObjectNode().put("code", bk).put("label", label); + var body = om.createObjectNode().put("businessKey", bk).put("operation", "CREATE"); + body.set("data", data); + MvcResult res = mvc.perform(post("/api/v1/dictionaries/{n}/records/drafts", dictName) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(body))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.status").value("PENDING")) + .andReturn(); + return om.readTree(res.getResponse().getContentAsString()); + } + + // === Tests === + + @Test + void happyFlow_submitApprove_recordLiveAndOutboxEvent() throws Exception { + String makerToken = tokenFor("alice-maker"); + String reviewerToken = tokenFor("bob-reviewer"); + + String dictName = createDictThenEnableApproval(makerToken, randDictName("appr_h")); + String bk = randBk(); + long outboxBefore = outboxRepo.count(); + + // 1. Maker submits CREATE draft. + JsonNode draft = submitDraft(makerToken, dictName, bk, "Alpha"); + String draftId = draft.get("id").asText(); + assertThat(draft.get("makerId").asText()).isEqualTo("alice-maker"); + + // 2. Until approved — НЕТ live записи. + long liveBefore = recordRepo.findAll().stream() + .filter(r -> bk.equals(r.getBusinessKey())).count(); + assertThat(liveBefore).isZero(); + + // 3. Reviewer approves. + mvc.perform(post("/api/v1/drafts/{id}/approve", draftId) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + reviewerToken) + .param("comment", "LGTM")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("APPROVED")) + .andExpect(jsonPath("$.reviewerId").value("bob-reviewer")) + .andExpect(jsonPath("$.reviewComment").value("LGTM")); + + // 4. Live record persisted. + var liveRecs = recordRepo.findAll().stream() + .filter(r -> bk.equals(r.getBusinessKey())) + .toList(); + assertThat(liveRecs) + .as("approved CREATE → 1 live record") + .hasSize(1); + + // 5. Outbox event записан (RecordCreated). countAll вырос. + assertThat(outboxRepo.count()) + .as("approve должен породить outbox event") + .isGreaterThan(outboxBefore); + + // 6. Reader endpoint видит запись (sanity — reviewer scope INTERNAL). + mvc.perform(get("/api/v1/dictionaries/{n}/records/{k}", dictName, bk) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + reviewerToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.businessKey").value(bk)) + .andExpect(jsonPath("$.data.label").value("Alpha")); + } + + @Test + void rejectFlow_keepsDraft_noLiveRecord() throws Exception { + String makerToken = tokenFor("carol-maker"); + String reviewerToken = tokenFor("dave-reviewer"); + + String dictName = createDictThenEnableApproval(makerToken, randDictName("appr_rej")); + String bk = randBk(); + + JsonNode draft = submitDraft(makerToken, dictName, bk, "Beta"); + String draftId = draft.get("id").asText(); + + // Reject с reason — должен пройти. + mvc.perform(post("/api/v1/drafts/{id}/reject", draftId) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + reviewerToken) + .param("comment", "Не соответствует регламенту")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("REJECTED")) + .andExpect(jsonPath("$.reviewComment").value("Не соответствует регламенту")); + + // Live record НЕТ. + var liveRecs = recordRepo.findAll().stream() + .filter(r -> bk.equals(r.getBusinessKey())) + .toList(); + assertThat(liveRecs).as("rejected draft не публикует live record").isEmpty(); + + // Re-approve того же draft → 409 draft_not_pending (status !=PENDING). + mvc.perform(post("/api/v1/drafts/{id}/approve", draftId) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + reviewerToken)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("draft_not_pending")); + } + + @Test + void selfApproveBlocked_409() throws Exception { + String token = tokenFor("eve-solo"); + String dictName = createDictThenEnableApproval(token, randDictName("appr_self")); + String bk = randBk(); + + JsonNode draft = submitDraft(token, dictName, bk, "Gamma"); + String draftId = draft.get("id").asText(); + + // Тот же subject пробует approve свой draft. + mvc.perform(post("/api/v1/drafts/{id}/approve", draftId) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("self_approve_forbidden")); + + // Reject себя — также блок. + mvc.perform(post("/api/v1/drafts/{id}/reject", draftId) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .param("comment", "self-reject test")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("self_reject_forbidden")); + } + + @Test + void twoPendingDraftsForSameBusinessKey_blocked() throws Exception { + String makerToken = tokenFor("frank-maker"); + String dictName = createDictThenEnableApproval(makerToken, randDictName("appr_excl")); + String bk = randBk(); + + submitDraft(makerToken, dictName, bk, "First proposal"); + + // Второй submit на тот же business_key — exclusion constraint → 409. + var data2 = om.createObjectNode().put("code", bk).put("label", "Second"); + var body2 = om.createObjectNode().put("businessKey", bk).put("operation", "CREATE"); + body2.set("data", data2); + mvc.perform(post("/api/v1/dictionaries/{n}/records/drafts", dictName) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + makerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(body2))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("draft_already_pending")); + } + + @Test + void directCrudBlockedWhenApprovalRequired() throws Exception { + String token = tokenFor("grace-admin"); + String dictName = createDictThenEnableApproval(token, randDictName("appr_gate")); + String bk = randBk(); + + var recBody = om.createObjectNode().put("businessKey", bk); + recBody.set("data", om.createObjectNode().put("code", bk).put("label", "X")); + + // Direct POST → 409 draft_required (D2=A). + mvc.perform(post("/api/v1/dictionaries/{n}/records", dictName) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(recBody))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("draft_required")); + + // Direct PUT тоже блокируется. Сначала надо создать запись через approval + // path — но проще: PUT по несуществующему ключу вернёт draft_required + // ДО проверки existence (gate стоит первым в service). + mvc.perform(put("/api/v1/dictionaries/{n}/records/{k}", dictName, bk) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(recBody))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("draft_required")); + + // DELETE тоже блокируется approval gate'ом. + mvc.perform(delete("/api/v1/dictionaries/{n}/records/{k}", dictName, bk) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("draft_required")); + } + + @Test + void regression_approvalNotRequired_directCrudStillWorks() throws Exception { + String token = tokenFor("henry-admin"); + String dictName = createDict(token, randDictName("appr_reg"), false); + String bk = randBk(); + + // Direct POST на dict БЕЗ approval — работает как до Approval v2. + var recBody = om.createObjectNode().put("businessKey", bk); + recBody.set("data", om.createObjectNode().put("code", bk).put("label", "Regression OK")); + mvc.perform(post("/api/v1/dictionaries/{n}/records", dictName) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(recBody))) + .andExpect(status().is2xxSuccessful()) + .andExpect(jsonPath("$.businessKey").value(bk)); + + // GET возвращает запись. + mvc.perform(get("/api/v1/dictionaries/{n}/records/{k}", dictName, bk) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.label").value("Regression OK")); + } + + @Test + void withdrawByMaker_keepsDraftStatus() throws Exception { + String makerToken = tokenFor("ivan-maker"); + String reviewerToken = tokenFor("julia-reviewer"); + + String dictName = createDictThenEnableApproval(makerToken, randDictName("appr_wd")); + String bk = randBk(); + + JsonNode draft = submitDraft(makerToken, dictName, bk, "Delta"); + String draftId = draft.get("id").asText(); + + // Reviewer пробует withdraw — 403 not_draft_maker. + mvc.perform(delete("/api/v1/drafts/{id}", draftId) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + reviewerToken)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("not_draft_maker")); + + // Maker withdraws — 200 + status=WITHDRAWN. + mvc.perform(delete("/api/v1/drafts/{id}", draftId) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + makerToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("WITHDRAWN")); + + // После withdraw — re-submit на тот же business_key РАБОТАЕТ (exclusion + // снят, status более не PENDING). + submitDraft(makerToken, dictName, bk, "Delta v2"); + } + + @Test + void rejectWithoutComment_400() throws Exception { + String makerToken = tokenFor("kate-maker"); + String reviewerToken = tokenFor("leo-reviewer"); + + String dictName = createDictThenEnableApproval(makerToken, randDictName("appr_rc")); + String bk = randBk(); + + JsonNode draft = submitDraft(makerToken, dictName, bk, "Epsilon"); + String draftId = draft.get("id").asText(); + + // Reject без comment — 400 reject_reason_required. + mvc.perform(post("/api/v1/drafts/{id}/reject", draftId) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + reviewerToken) + .param("comment", "")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("reject_reason_required")); + } +} diff --git a/ordinis-migrations/src/main/resources/db/changelog/changes/0020-fix-draft-status-case.xml b/ordinis-migrations/src/main/resources/db/changelog/changes/0020-fix-draft-status-case.xml new file mode 100644 index 0000000..e946d3e --- /dev/null +++ b/ordinis-migrations/src/main/resources/db/changelog/changes/0020-fix-draft-status-case.xml @@ -0,0 +1,50 @@ + + + + + + Normalize any prior lowercase status values to UPPERCASE before recreate. + UPDATE record_drafts SET status = UPPER(status) WHERE status != UPPER(status); + + + + Recreate status check constraint with UPPERCASE values matching enum. + ALTER TABLE record_drafts DROP CONSTRAINT IF EXISTS record_drafts_status_check; + + ALTER TABLE record_drafts + ADD CONSTRAINT record_drafts_status_check + CHECK (status IN ('PENDING', 'APPROVED', 'REJECTED', 'WITHDRAWN')); + + + + + Recreate exclusion WHERE clause to use 'PENDING' (matches enum serialization). + ALTER TABLE record_drafts DROP CONSTRAINT IF EXISTS record_drafts_one_pending_per_key; + + ALTER TABLE record_drafts + ADD CONSTRAINT record_drafts_one_pending_per_key + EXCLUDE USING btree ( + dictionary_id WITH =, + business_key WITH = + ) WHERE (status = 'PENDING'); + + + + diff --git a/ordinis-migrations/src/main/resources/db/changelog/master.xml b/ordinis-migrations/src/main/resources/db/changelog/master.xml index 7b96e57..d8f77cf 100644 --- a/ordinis-migrations/src/main/resources/db/changelog/master.xml +++ b/ordinis-migrations/src/main/resources/db/changelog/master.xml @@ -29,5 +29,6 @@ + diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftService.java index 574cdf9..146fb3f 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftService.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftService.java @@ -110,13 +110,31 @@ public class DraftService { dictionaryName, req.businessKey(), req.operation(), makerId); return saved; } catch (DataIntegrityViolationException ex) { - // record_drafts_one_pending_per_key — exclusion violation. - throw OrdinisException.conflict( - "draft_already_pending", - "На запись business_key=" + req.businessKey() + " уже есть pending draft."); + // SQLSTATE 23P01 = exclusion_violation (record_drafts_one_pending_per_key). + // Все остальные DataIntegrity (check, FK, NOT NULL) пробрасываем как-есть — + // иначе скрываем баги схемы (Phase 3 lessons learned: enum case mismatch + // прятался в маске draft_already_pending). + if (isExclusionViolation(ex)) { + throw OrdinisException.conflict( + "draft_already_pending", + "На запись business_key=" + req.businessKey() + " уже есть pending draft."); + } + throw ex; } } + /** Distinguish exclusion-constraint violation (23P01) from other DataIntegrity. */ + private static boolean isExclusionViolation(DataIntegrityViolationException ex) { + Throwable t = ex; + while (t != null) { + if (t instanceof java.sql.SQLException sql && "23P01".equals(sql.getSQLState())) { + return true; + } + t = t.getCause(); + } + return false; + } + @Transactional(readOnly = true) public RecordDraft findById(UUID id) { return draftRepo.findById(id) diff --git a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftServiceTest.java b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftServiceTest.java index f99bfd6..9131135 100644 --- a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftServiceTest.java +++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftServiceTest.java @@ -311,8 +311,14 @@ class DraftServiceTest { && d.getBusinessKey().equals(draft.getBusinessKey()) && !d.getId().equals(draft.getId())); if (conflict) { + // Wrap a SQLException with SQLState 23P01 — DraftService.submit + // distinguishes exclusion violations from other DataIntegrity errors + // by walking the cause chain (Phase 3 fix: don't blanket-mask check + // constraint failures as draft_already_pending). + java.sql.SQLException sql = new java.sql.SQLException( + "exclusion violation", "23P01"); throw new DataIntegrityViolationException( - "record_drafts_one_pending_per_key"); + "record_drafts_one_pending_per_key", sql); } store.put(draft.getId(), draft); return draft;