Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions frontend/src/__tests__/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,58 @@ describe('History Module', () => {
provider: undefined
});
});

// Migration 000089 (expand-contract rename cancelled -> canceled): during
// the rolling deploy window the backend returns BOTH spellings on
// /api/history. Each must render as the muted "Cancelled" badge, be
// counted under the Cancelled chip, and be visible when that chip is
// clicked. Pre-fix the FE only matched 'cancelled', so 'canceled' rows
// fell through to the green Completed badge and were silently bucketed
// into the Completed total -- a user-facing regression that defeats the
// purpose of the rename.
test('Cancelled badge + chip surface BOTH spellings during deploy window (migration 000089)', async () => {
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [
// Row written by new code post-deploy: US spelling.
{ purchase_id: 'cx-new', status: 'canceled', provider: 'aws', region: 'us-east-1' },
// Row written by old code mid-deploy: legacy British spelling.
{ purchase_id: 'cx-legacy', status: 'cancelled', provider: 'aws', region: 'us-east-1' },
// Sanity baseline: a real completed row -- must NOT bucket into Cancelled.
{ purchase_id: 'comp-1', status: 'completed', provider: 'aws', region: 'us-east-1' },
],
});

await loadHistory();

const list = document.getElementById('history-list');
const html = list?.innerHTML || '';

// Both rows must render the muted Cancelled badge, NOT the green
// Completed default. Two of the three rows are canceled, so we expect
// exactly two Cancelled badges.
const cancelledBadges = (html.match(/>Cancelled</g) || []).length;
expect(cancelledBadges).toBe(2);

// The Cancelled chip must count 2 (both spellings), and it must render
// at all (it only renders when its count > 0).
const cancelledChip = list?.querySelector<HTMLButtonElement>('[data-history-status="cancelled"]');
expect(cancelledChip).not.toBeNull();
expect(cancelledChip?.textContent).toContain('2');

// The Completed chip must count 1 (only the real completed row); the
// pre-fix bug bucketed canceled rows into completed, yielding 3.
const completedChip = list?.querySelector<HTMLButtonElement>('[data-history-status="completed"]');
expect(completedChip?.textContent).toContain('1');

// Clicking the Cancelled chip must reveal BOTH spellings, not just the
// British one (s === activeStatusFilter would only match 'cancelled').
cancelledChip?.click();
const filteredHtml = list?.innerHTML || '';
expect(filteredHtml).toContain('cx-new');
expect(filteredHtml).toContain('cx-legacy');
expect(filteredHtml).not.toContain('comp-1');
});
});

// Issue #701: setupHistoryHandlers must subscribe to the global topbar
Expand Down
22 changes: 21 additions & 1 deletion frontend/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,15 @@ function statusBadgeHTML(status: string): string {
// In-flight (issue #621): not finished — never show the green Completed
// badge for these, or the user may think the purchase is done.
return '<span class="badge badge-warning">In Progress</span>';
case 'canceled':
case 'cancelled':
// Migration 000089 (expand-contract rename): the backend may return
// either the new US spelling ('canceled') or the legacy British
// spelling ('cancelled') during the rolling deploy window. Match both
// so a row written by EITHER old or new code renders the muted Cancelled
// badge instead of falling through to the green Completed default.
// The CONTRACT migration (#1278) will normalize the data once the deploy
// is stable; the British branch can be removed then.
return '<span class="badge badge-muted">Cancelled</span>';
case 'partially_completed':
// #642: some commitments succeeded, some failed. Not a clean success
Expand Down Expand Up @@ -414,7 +422,11 @@ function buildStatusChipRowHTML(purchases: HistoryPurchase[], active: StatusFilt
for (const p of purchases) {
const s = normalizeStatus(p).toLowerCase();
if (s === 'pending' || s === 'notified' || isInFlightStatus(s)) counts.pending++;
else if (s === 'cancelled') counts.cancelled++;
// Migration 000089 (expand-contract rename): the backend may return
// either spelling during the rolling deploy window. Counting only the
// British spelling would silently bucket new 'canceled' rows into the
// Completed total, hiding them from the user.
else if (s === 'canceled' || s === 'cancelled') counts.cancelled++;
else if (s === 'failed') counts.failed++;
else if (s === 'expired') counts.expired++;
else counts.completed++;
Expand Down Expand Up @@ -845,6 +857,11 @@ function renderHistoryList(purchases: HistoryPurchase[]): void {
const s = normalizeStatus(p).toLowerCase();
if (activeStatusFilter === 'pending') return s === 'pending' || s === 'notified' || isInFlightStatus(s);
if (activeStatusFilter === 'completed') return s === 'completed' || s === 'partially_completed' || !p.status;
// Migration 000089: the Cancelled chip key is 'cancelled' (British, kept
// stable for URL/state compatibility) but it must surface BOTH spellings
// during the expand-contract deploy window so new 'canceled' rows aren't
// hidden from the filter.
if (activeStatusFilter === 'cancelled') return s === 'cancelled' || s === 'canceled';
return s === activeStatusFilter;
})) {
activeStatusFilter = 'all';
Expand All @@ -860,6 +877,9 @@ function renderHistoryList(purchases: HistoryPurchase[]): void {
const s = normalizeStatus(p).toLowerCase();
if (activeStatusFilter === 'pending') return s === 'pending' || s === 'notified' || isInFlightStatus(s);
if (activeStatusFilter === 'completed') return s === 'completed' || s === 'partially_completed' || !p.status;
// Migration 000089: surface BOTH spellings under the Cancelled chip during
// the expand-contract deploy window (see the equivalent guard above).
if (activeStatusFilter === 'cancelled') return s === 'cancelled' || s === 'canceled';
return s === activeStatusFilter;
});

Expand Down
7 changes: 7 additions & 0 deletions frontend/src/riexchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2176,6 +2176,13 @@ function getStatusBadgeClass(status: string): string {
case 'pending': return 'status-badge pending';
case 'processing': return 'status-badge running';
case 'failed': return 'status-badge failed';
// Migration 000089 (expand-contract rename, ri_exchange_history.status):
// backend may return either spelling during the rolling deploy window.
// Match both so a row written by EITHER old or new code keeps the muted
// "disabled" visual treatment instead of falling through to the default
// class. The CONTRACT migration (#1278) normalizes the data; the British
// branch can be removed then.
case 'canceled':
case 'cancelled': return 'status-badge disabled';
default: return 'status-badge';
}
Expand Down
23 changes: 16 additions & 7 deletions internal/api/handler_history.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,12 @@ func (h *Handler) getHistory(ctx context.Context, req *events.LambdaFunctionURLR
// wave-2) appear in the History view with a Revoke button before the cloud SDK
// call fires. Without this entry the row is invisible to the History UI, making
// the Revoke button unreachable (issue #290, second-wave CR Finding E).
// historyExecutionStatuses includes both "canceled" (new canonical spelling) and
// "cancelled" (DB-stored value written by CancelExecutionAtomic until migration
// #1277 renames the column). Both spellings must be accepted until that migration lands.
var historyExecutionStatuses = []string{"pending", "notified", "scheduled", "approved", "running", "paused", "completed", "partially_completed", "failed", "expired", "canceled", "cancelled"}
// Both the US-spelling status (config.StatusCanceled) and the legacy British
// spelling (config.LegacyStatusCanceled) are listed: during the expand-contract
// rename (migration 000089) old code may still write the legacy value before
// the rolling deploy completes. The contract migration (#1278) normalizes the
// data once the deploy is verified stable; drop the legacy entry here then.
var historyExecutionStatuses = []string{"pending", "notified", "scheduled", "approved", "running", "paused", "completed", "partially_completed", "failed", "expired", config.StatusCanceled, config.LegacyStatusCanceled}

// approvalExpiryWindow is how long a pending approval stays actionable
// before the History view flips it to "expired". Aligns with the
Expand Down Expand Up @@ -332,7 +334,10 @@ func annotateHistoryRowByStatus(row *config.PurchaseHistoryRecord, exec config.P
row.StatusDescription = exec.Error
case "expired":
row.StatusDescription = "approval link expired (not approved within 7 days)"
case "canceled", "cancelled": // both spellings until migration #1277 renames the DB column
case config.StatusCanceled, config.LegacyStatusCanceled:
// The legacy British spelling is still matched during the
// expand-contract rename (migration 000089) until the contract
// migration (#1278) normalizes and drops it.
annotateCancelled(row, exec, approver)
default:
// In-flight (approved/running/scheduled/paused) and audit-gap
Expand Down Expand Up @@ -1009,10 +1014,14 @@ func summarizePurchaseHistory(purchases []config.PurchaseHistoryRecord) HistoryS
case "expired":
summary.TotalExpired++
continue
case "canceled", "cancelled": // both spellings until migration #1277 renames the DB column
case config.StatusCanceled, config.LegacyStatusCanceled:
// A canceled purchase represents zero committed spend and zero
// realized savings (issue #736). Exclude from all dollar KPIs and
// from TotalCompleted — the money was never committed.
// from TotalCompleted -- the money was never committed. The legacy
// British spelling is matched alongside the US one during the
// expand-contract rename (migration 000089) so legacy rows can't
// inflate KPIs mid-deploy; the contract migration (#1278) drops it
// once the deploy is stable.
continue
}
summary.TotalCompleted++
Expand Down
15 changes: 11 additions & 4 deletions internal/api/handler_history_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1708,10 +1708,17 @@ func TestSummarizePurchaseHistory_CancelledExcludedFromKPIs(t *testing.T) {
{Status: "", UpfrontCost: 50.0, EstimatedSavings: 5.0}, // legacy row, no status
// One pending row that should be counted as pending, not completed.
{Status: "pending", UpfrontCost: 999.0, EstimatedSavings: 99.0},
// Two canceled rows — the regression case from issue #736.
// Neither must appear in the dollar KPIs or TotalCompleted.
{Status: "cancelled", UpfrontCost: 500.0, EstimatedSavings: 50.0}, //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql
{Status: "cancelled", UpfrontCost: 750.0, EstimatedSavings: 75.0}, //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql
// Two canceled rows — the regression case from issue #736. Neither must
// appear in the dollar KPIs or TotalCompleted. One uses the new US
// spelling (config.StatusCanceled) and one the legacy British spelling
// (config.LegacyStatusCanceled): during the expand-contract rename
// (migration 000089) a mixed fleet still emits the legacy spelling, so
// the dual-spelling read path must exclude BOTH. The constant carries
// the legacy value without a literal the US-locale misspell linter would
// flag (and without a nolint). Drop the legacy fixture once the contract
// migration (#1278) normalizes the data.
{Status: config.StatusCanceled, UpfrontCost: 500.0, EstimatedSavings: 50.0},
{Status: config.LegacyStatusCanceled, UpfrontCost: 750.0, EstimatedSavings: 75.0},
}

summary := summarizePurchaseHistory(purchases)
Expand Down
63 changes: 52 additions & 11 deletions internal/api/handler_purchases.go
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,12 @@ func (h *Handler) cancelOrRecoverExecution(ctx context.Context, executionID stri
return canceled, nil
}
if !errors.Is(err, config.ErrExecutionNotInExpectedStatus) {
return nil, NewClientError(409, fmt.Sprintf("execution %s cannot be canceled: %v", executionID, err))
// Not a CAS conflict: a real server-side failure (DB error, transient
// fault). Surface it as a plain wrapped error so the router logs the
// raw detail and returns a generic 500 -- a 409 here would misclassify
// a retriable backend failure as the caller's fault and leak backend
// text (feedback_http_status_classification; same class as PR #1276).
return nil, fmt.Errorf("cancel execution %s: %w", executionID, err)
}
existing, getErr := h.config.GetExecutionByID(ctx, executionID)
if errors.Is(getErr, config.ErrNotFound) {
Expand All @@ -475,11 +480,27 @@ func (h *Handler) cancelOrRecoverExecution(ctx context.Context, executionID stri
if getErr != nil {
return nil, fmt.Errorf("disable plan: failed to get execution %s after conflict: %w", executionID, getErr)
}
if existing.Status != "canceled" {
// Accept both spellings: during the expand-contract rename (migration
// 000089) a concurrent legacy cancel may have written the legacy value
// before the rolling deploy completes. The contract migration (#1278)
// normalizes the data, after which LegacyStatusCanceled can be removed.
if existing.Status != config.StatusCanceled && existing.Status != config.LegacyStatusCanceled {
return nil, NewClientError(409, fmt.Sprintf(
"execution %s cannot be canceled (status=%s)",
executionID, existing.Status))
}
// Normalize the response Status so the idempotent recovery path returns the
// canonical US spelling even when the DB row still carries the legacy value.
// Without this, a caller that received an in-flight 200 from this branch
// would see status="canceled" while a caller that hit the happy-path
// transition above would see status="canceled" for the same execution_id
// during the rolling deploy. The legacy value is preserved in storage --
// only the in-memory copy returned to the handler is normalized -- so the
// contract migration's authoritative status backfill still observes every
// legacy row.
if existing.Status == config.LegacyStatusCanceled {
existing.Status = config.StatusCanceled
}
return existing, nil
}

Expand Down Expand Up @@ -1017,13 +1038,33 @@ func (h *Handler) cancelPurchase(ctx context.Context, req *events.LambdaFunction
return h.cancelPurchaseViaSession(ctx, req, execution)
}

// guardCancelableViaSession returns a ClientError if execution is not eligible
// for cancellation on the session path (pending|notified only):
// - "scheduled" executions are cancelable (IsCancelable returns true) but must
// go through the /revoke endpoint; routing them through the pending/notified
// CAS would fail with a misleading "concurrent operation" 409 (CodeRabbit
// finding #6, PR #1277).
// - all other non-IsCancelable statuses (approved, completed, etc.) are rejected
// with a generic "cannot be canceled" 409.
func guardCancelableViaSession(execution *config.PurchaseExecution) error {
if execution.Status == "scheduled" {
return NewClientError(409, fmt.Sprintf(
"execution %s is scheduled; use the revoke endpoint to cancel it before it fires",
execution.ExecutionID))
}
if !execution.IsCancelable() {
return NewClientError(409, fmt.Sprintf("execution %s cannot be canceled (status=%s)", execution.ExecutionID, execution.Status))
}
return nil
}

// cancelPurchaseViaSession is the session-authed branch of cancelPurchase.
// Enforces the cancel-any/cancel-own RBAC matrix, validates the execution
// is in a cancellable state (pending|notified), atomically flips the row
// is in a cancelable state (pending|notified), atomically flips the row
// to "canceled" AND drops its purchase_suppressions in the same
// transaction, and stamps session.Email onto CancelledBy. The History
// UI's annotateCancelled() helper renders CancelledBy as
// "canceled by <email>" at read time — see handler_history.go.
// transaction, and stamps session.Email onto CanceledBy. The History
// UI's annotateCanceled() helper renders CanceledBy as
// "canceled by <email>" at read time -- see handler_history.go.
//
// The atomic suppression cleanup mirrors purchase.Manager.CancelExecution
// on the email-token path: an executePurchase upfront writes
Expand All @@ -1044,8 +1085,8 @@ func (h *Handler) cancelPurchaseViaSession(ctx context.Context, req *events.Lamb
return nil, err
}

if !execution.IsCancelable() {
return nil, NewClientError(409, fmt.Sprintf("execution %s cannot be canceled (status=%s)", execution.ExecutionID, execution.Status))
if err := guardCancelableViaSession(execution); err != nil {
return nil, err
}

if err := h.authorizeSessionCancel(ctx, session, execution); err != nil {
Expand All @@ -1058,16 +1099,16 @@ func (h *Handler) cancelPurchaseViaSession(ctx context.Context, req *events.Lamb
// that has already transitioned the row to 'approved' causes zero rows
// to be affected and we return a 409 with the current status rather
// than silently overwriting an approved purchase.
var cancelledBy *string
var canceledBy *string
if session.Email != "" {
e := session.Email
cancelledBy = &e
canceledBy = &e
}
var canceled bool
var currentStatus string
if err := h.config.WithTx(ctx, func(tx pgx.Tx) error {
var err error
canceled, currentStatus, err = h.config.CancelExecutionAtomic(ctx, tx, execution.ExecutionID, cancelledBy)
canceled, currentStatus, err = h.config.CancelExecutionAtomic(ctx, tx, execution.ExecutionID, canceledBy)
if err != nil {
return err
}
Expand Down
Loading
Loading