fix: guard state field workflow transitions

This commit is contained in:
2026-08-22 23:41:35 +09:00
parent 2cfbb1caea
commit ae8a0316d8
+43 -15
View File
@@ -3920,12 +3920,18 @@ async fn scoped_close_ticket(
browser_ticket_detail(&api, &path.id) browser_ticket_detail(&api, &path.id)
} }
fn reject_unguarded_ticket_start(operation: &TicketBackendOperation) -> Result<()> { fn generic_ticket_state_change(operation: &TicketBackendOperation) -> Option<&TicketStateChange> {
if matches!( match operation {
operation,
TicketBackendOperation::SetWorkflowState { change, .. } TicketBackendOperation::SetWorkflowState { change, .. }
if change.to == "inprogress" && change.from != "inprogress" | TicketBackendOperation::SetStateField { change, .. } => Some(change),
) { _ => None,
}
}
fn reject_unguarded_ticket_start(operation: &TicketBackendOperation) -> Result<()> {
if generic_ticket_state_change(operation)
.is_some_and(|change| change.to == "inprogress" && change.from != "inprogress")
{
return Err(Error::TicketAssignmentConflict( return Err(Error::TicketAssignmentConflict(
"inprogress is guarded by Queue acceptance or atomic ready-state Coder assignment" "inprogress is guarded by Queue acceptance or atomic ready-state Coder assignment"
.to_string(), .to_string(),
@@ -3936,8 +3942,7 @@ fn reject_unguarded_ticket_start(operation: &TicketBackendOperation) -> Result<(
fn reject_unguarded_ticket_completion(operation: &TicketBackendOperation) -> Result<()> { fn reject_unguarded_ticket_completion(operation: &TicketBackendOperation) -> Result<()> {
reject_unguarded_ticket_start(operation)?; reject_unguarded_ticket_start(operation)?;
if matches!(operation, TicketBackendOperation::SetWorkflowState { change, .. } if change.to == "done") if generic_ticket_state_change(operation).is_some_and(|change| change.to == "done") {
{
return Err(Error::TicketAssignmentConflict( return Err(Error::TicketAssignmentConflict(
"done is guarded by MergeRequestComplete with an approved immutable revision and operation_id".to_string(), "done is guarded by MergeRequestComplete with an approved immutable revision and operation_id".to_string(),
)); ));
@@ -13329,15 +13334,26 @@ mod tests {
#[test] #[test]
fn generic_worker_state_change_cannot_enter_inprogress() { fn generic_worker_state_change_cannot_enter_inprogress() {
for from in ["planning", "ready", "queued"] { for from in ["planning", "ready", "queued"] {
let operation = TicketBackendOperation::SetWorkflowState { let change = || {
id: TicketIdOrSlug::Query("T1".to_string()), TicketStateChange::new(
change: TicketStateChange::new(
from, from,
"inprogress", "inprogress",
"worker-tool", "worker-tool",
"bypass assignment-aware start", "bypass assignment-aware start",
), )
}; };
let operations = [
TicketBackendOperation::SetWorkflowState {
id: TicketIdOrSlug::Query("T1".to_string()),
change: change(),
},
TicketBackendOperation::SetStateField {
id: TicketIdOrSlug::Query("T1".to_string()),
field: "state".to_string(),
change: change(),
},
];
for operation in operations {
let error = reject_unguarded_ticket_completion(&operation).unwrap_err(); let error = reject_unguarded_ticket_completion(&operation).unwrap_err();
assert!( assert!(
error error
@@ -13346,21 +13362,33 @@ mod tests {
); );
} }
} }
}
#[test] #[test]
fn flow_or_generic_worker_state_change_is_not_ticket_completion_authority() { fn flow_or_generic_worker_state_change_is_not_ticket_completion_authority() {
let operation = TicketBackendOperation::SetWorkflowState { let change = || {
id: TicketIdOrSlug::Query("T1".to_string()), TicketStateChange::new(
change: TicketStateChange::new(
"inprogress", "inprogress",
"done", "done",
"flow reached terminal state", "flow reached terminal state",
"terminal flow state", "terminal flow state",
), )
}; };
for operation in [
TicketBackendOperation::SetWorkflowState {
id: TicketIdOrSlug::Query("T1".to_string()),
change: change(),
},
TicketBackendOperation::SetStateField {
id: TicketIdOrSlug::Query("T1".to_string()),
field: "state".to_string(),
change: change(),
},
] {
let error = reject_unguarded_ticket_completion(&operation).unwrap_err(); let error = reject_unguarded_ticket_completion(&operation).unwrap_err();
assert!(error.to_string().contains("MergeRequestComplete")); assert!(error.to_string().contains("MergeRequestComplete"));
} }
}
#[test] #[test]
fn failed_api_log_is_structured_and_omits_query_values() { fn failed_api_log_is_structured_and_omits_query_values() {