Skip to content

Fix AWS RI purchase: details assertion + reservation ID rules - #4

Merged
cristim merged 3 commits into
LeanerCloud:mainfrom
babyhuey:main
Feb 17, 2026
Merged

Fix AWS RI purchase: details assertion + reservation ID rules#4
cristim merged 3 commits into
LeanerCloud:mainfrom
babyhuey:main

Conversation

@babyhuey

Copy link
Copy Markdown

Fix AWS RI purchase (details + reservation IDs)
RDS purchases were failing for two reasons; fixed both and applied the same fixes elsewhere they mattered.
Recommendations were setting rec.Details as pointers but RDS/EC2/ElastiCache were asserting to the value type, so the assertion always failed. Updated those clients to assert the pointer type (with a nil check) and adjusted the tests to pass pointers too.
We were also building reservation IDs from rec.ResourceType (e.g. db.t3.small), so they had dots in them. AWS only allows letters, digits, and hyphens for those IDs. Added sanitization where we set the ID (RDS, ElastiCache, OpenSearch, MemoryDB): replace dots with hyphens, collapse/trim hyphens, and use a fallback like rds-reserved- if the result is empty. Redshift is unchanged since it doesn’t send a custom ID.
All relevant tests pass.

…servation ID

Recommendations set rec.Details as pointers (e.g. &DatabaseDetails) but
RDS/EC2/ElastiCache asserted to the value type, so the assertion always
failed and purchases hit 'invalid service details'. Switched those
clients to assert the pointer type and updated tests to pass pointers.

Reservation IDs were built from rec.ResourceType (e.g. db.t3.small) so
they contained dots; AWS only allows letters, digits, and hyphens.
Added sanitization for the custom ID/name field in RDS, ElastiCache,
OpenSearch, and MemoryDB so we only send valid identifiers.

Co-authored-by: Cursor <cursoragent@cursor.com>
@cristim

cristim commented Feb 12, 2026

Copy link
Copy Markdown
Member

@babyhuey Thank you for this contribution, I really appreciate it!

To be honest I'm surprised to see reports of failures for RDS and ElastiCache RI purchasess, since I used those extensively not long ago.

One small thing I'd like to see changed is deduplicating the sanitization code if possible, since much of it seems to be duplicated across services.

@cristim

cristim commented Feb 12, 2026

Copy link
Copy Markdown
Member

I also had Claude Code review this PR and it came up with the same suggestion to deduplicate that sanitization code, and in addition to that it also complained about inconsistent naming for those sanitization functions:

PR #4 Review: Fix AWS RI purchase — details assertion + reservation ID rules

+118 / -22 across 8 files


Summary

The PR fixes two real bugs:

  1. Type assertion mismatchrec.Details is stored as a pointer (e.g. *common.DatabaseDetails) but asserted as a value type, causing assertions to always fail
  2. Invalid reservation IDs — dots in resource types like db.t3.small produce IDs that violate AWS naming rules (letters, digits, hyphens only)

Both bugs are real and would cause RI purchases to fail at runtime. The fixes are correct.


Issues Found

Major: Duplicated sanitization function (4 copies)

The sanitizeReservationID function is copy-pasted identically across:

  • rds/client.gosanitizeReservedDBInstanceID
  • elasticache/client.gosanitizeReservationID
  • memorydb/client.gosanitizeReservationID
  • opensearch/client.gosanitizeReservationName

The logic is identical — only the fallback prefix differs. This should be a shared utility function, e.g. in pkg/common/:

func SanitizeReservationID(id, fallbackPrefix string) string { ... }

Minor: Inconsistent function naming

  • RDS: sanitizeReservedDBInstanceID
  • ElastiCache/MemoryDB: sanitizeReservationID
  • OpenSearch: sanitizeReservationName

If keeping them per-service (not recommended), at least use a consistent name.

Minor: Methods don't use receiver

All sanitize* functions are methods on *Client (func (c *Client)) but never use c. They should be standalone package-level functions, or better yet, a shared utility.


What the PR Gets Right

  • The core bug diagnoses are correct — both the pointer assertion mismatch and the reservation ID dots are real production-breaking bugs
  • Nil checks added after pointer assertions (|| details == nil) — good defensive programming
  • Tests updated to use pointer types, matching production behavior
  • Redshift correctly excluded — it doesn't use rec.Details in findOfferingID and doesn't send a custom reservation ID

Verdict

The PR is sound. Both fixes address real bugs that would cause RI purchases to fail. The main suggestion is extracting the duplicated sanitization into a shared utility function in pkg/common/ to reduce the 4 near-identical copies.


🤖 Generated with Claude Code

Extract SanitizeReservationID into pkg/common/identifiers.go and use it
from RDS, ElastiCache, OpenSearch, and MemoryDB instead of four
per-service copies. Addresses PR review feedback.
@babyhuey

Copy link
Copy Markdown
Author

Pulled the sanitization logic into pkg/common and wired everyone up to it.
Added pkg/common/identifiers.go with a single SanitizeReservationID(id, fallbackPrefix string) that does the letters/digits/hyphens + collapse/trim thing. RDS, ElastiCache, OpenSearch, and MemoryDB now call that with their own prefix (rds-reserved-, etc.) and I removed the four duplicate helpers. All tests are passing.

Cost Explorer can return InstanceSize as the full type (e.g. t3.medium.search).
Concatenating with InstanceClass produced duplicates (t3.medium.t3.medium.search).
Use InstanceSize as-is when it already ends with .search, otherwise build
InstanceClass.InstanceSize.search.

findOfferingID only read the first page of DescribeReservedInstanceOfferings;
offerings for types like i3.large.search or t3.medium.search can be on later
pages. Paginate with NextToken until a matching offering is found or pages
are exhausted.

@cristim cristim left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks!

@cristim
cristim merged commit 3e17a19 into LeanerCloud:main Feb 17, 2026
cristim added a commit that referenced this pull request Mar 23, 2026
Fix AWS RI purchase: details assertion + reservation ID rules
cristim added a commit that referenced this pull request Apr 28, 2026
Addresses CodeRabbit findings #4 and #5 from PR #105's pass-2 review.

#4: ci.yml `govulncheck@latest` → `@v1.1.4`. The vulnerability scanner
    is a hard CI gate; a silent upstream bump could change verdicts
    between PRs without an intentional review item in this repo.
    Pinning makes upgrades a deliberate commit, not a drift.

#5: .github/workflows/pre-commit.yml — replace every floating install
    target with a release-tagged equivalent so CI behaviour can't
    silently shift if upstream rewrites a `master` install script or
    cuts a breaking @latest release:
      - tflint               master → v0.55.0 (curl now -fsSL)
      - gosec                @latest → @v2.22.4 (matches ci.yml's
                              securego/gosec action pin)
      - gocyclo              @latest → @v0.6.0 (matches ci.yml)
      - Trivy                main script → -b /usr/local/bin v0.58.0
      - git-secrets          master → tag 1.3.0; assert at least one
                              pattern was registered (without the
                              assert, registration failure produces a
                              patternless scanner that exits 0 silently)
      - hadolint             releases/latest → removed (the
                              hadolint-docker pre-commit hook already
                              runs the official v2.14.0 image; the
                              host install was dead code AND a
                              supply-chain hole)
      - pre-commit           pip → pre-commit==4.0.1
      - hashicorp/setup-terraform  v3 → v4 (matches ci.yml so the two
                              workflows resolve to the same Terraform
                              binary)

Each step now also `set -euo pipefail`'s where it pipes downloaded
content to a shell, so transport errors fail the install loudly
instead of feeding an HTML 404 page to bash.

Updated the .pre-commit-config.yaml trivy-config comment to point at
the new workflow location (.github/workflows/pre-commit.yml) where
trivy v0.58.0 is now installed; the old comment pointed at
ci.yml's trivy-action step which never carried this PR's pin.
@cristim cristim added type/bug Defect severity/high Significant harm urgency/now Drop other things impact/many Affects most users effort/s Hours priority/p1 Next up; this sprint triaged Item has been triaged labels Apr 28, 2026
cristim added a commit that referenced this pull request Apr 29, 2026
… pre-commit + multi-module govulncheck (#105)

* fix(security): supply-chain hardening — Docker SHA pinning + required pre-commit gates + multi-module govulncheck

Closes 5 HIGH findings from the security review:

H10 (lockfile discipline): audit confirmed CI does not run `npm install`
anywhere — only `npm audit --audit-level=high` (already in ci.yml). The
Dockerfile uses `npm ci` correctly. No code change needed.

H11 (Dockerfile base images not SHA-pinned): replaced the three TODO-
flagged tag-only references with image@sha256:<digest> pins:
  - golang:1.25.4-alpine3.21@sha256:3289aac2...
  - node:24-alpine@sha256:d1b3b4da...
  - alpine:3.21.3@sha256:a8560b36...
A registry tag mutation can no longer poison the build. Refresh path
documented in-comment.

H12 (pre-commit hooks silently skipping):
  - Removed the `command -v trivy ... || echo "skipping..."` fallback
    on the trivy-config hook. Devs without trivy installed now fail
    the hook (as they should). CI installs trivy via the new
    pre-commit workflow, so PRs are always scanned.
  - Added .github/workflows/pre-commit.yml that runs `pre-commit run
    --all-files` on every PR + push to main/feat. Installs gosec,
    gocyclo, trivy, git-secrets, hadolint, then runs all hooks. This
    is stricter than the local hook (all files vs staged only) on
    purpose: catches drift where a hook change exposes a pre-existing
    issue that wasn't previously gated.
  - Added .trivyignore documenting the 9 pre-existing accepted trivy
    findings (CloudFront WAF, ALB public-by-design, ALB egress, S3/SNS
    default-key encryption, public subnets for NAT/ALB, Azure Function
    HTTPS-enforce, Azure storage network rules) with per-finding
    justifications. Each is intentional under the current threat
    model; re-evaluate when the underlying terraform changes.

H13 (no govulncheck in CI): the existing govulncheck step in ci.yml
only ran `./...` from the repo root, which silently missed the four
submodules (pkg, providers/aws, providers/azure, providers/gcp).
Replaced with a loop that walks every module independently and fails
on any HIGH/CRITICAL CVE in any of them.

H14 (.env.example + resolver.go pre-commit exclusion):
  - Added .env.example: a documented template of every os.Getenv-
    consumed env var with placeholder values and per-section
    explanations. Devs copy to .env.local (already gitignored) and
    fill in.
  - Removed internal/credentials/resolver.go from the
    detect-private-key exclusion list. Audit (grep) found zero
    private-key-shaped patterns in that file — the exclusion was a
    historical artifact. Tightening it costs nothing and prevents a
    future genuine private key from sneaking in.

* ci(pre-commit): install terraform + tflint in workflow

The pre-commit workflow added in this PR runs every hook in
.pre-commit-config.yaml on the runner, but missed two binaries that
three of those hooks depend on:

  Hook              | Binary needed     | Previous result
  ------------------|-------------------|----------------
  terraform_fmt     | terraform         | exit 127 (cmd not found)
  terraform_validate| terraform         | exit 127
  terraform_tflint  | tflint            | exit 127

Add hashicorp/setup-terraform@v3 (pinned to 1.9.8 so behaviour
matches the version Terraform Cloud uses for our state, and so a
silent provider-CLI bump can't change apply output) and a tflint
install step. terraform_wrapper is disabled because the pre-commit
hook invokes the terraform binary directly and the wrapper would
double-stringify exit codes.

* chore(security): allowlist test-fixture account IDs in .gitallowed

git-secrets --register-aws adds a 12-digit account-ID regex to its
prohibited-patterns list. Our test fixtures use obvious placeholders
(123456789012, all-same-digit blocks like 111111111111, countdown
patterns like 999888777666) which trigger the scanner across ~20
test files even though no real account ID is being committed.

Add .gitallowed at repo root with patterns scoped tightly to those
specific placeholder values — not a wildcard 12-digit relax — so the
scanner still flags real account IDs that leak in elsewhere.

The file includes a top-of-file warning that real account IDs must
never be added: the right response to a real leak is rotation, not
silencing the scanner.

* docs(markdown): fix MD040/MD060/MD032 markdownlint violations

Pre-commit's markdownlint hook was failing on 145 violations across 8
files, all pre-existing — invisible until the new pre-commit CI gate
turned them into a hard error.

Three rule classes, three fix strategies:

MD060 (table-column-style — 122 violations): markdownlint's default
"consistent" mode infers the style from the first table it sees; if a
separator row happens to look "compact" (no spaces around the dashes),
every aligned table downstream is flagged. Pin the style to
"leading_and_trailing" in .markdownlint.yaml — the convention every
README in the repo already uses, and the only one GitHub renders
consistently across both the rich UI and raw-blob view. No README
content needed touching.

MD040 (fenced-code-language — 9 violations): assign explicit "text"
language tags to fenced blocks that aren't a real language —
directory trees, ASCII architecture diagrams, commit-message
templates, CloudWatch Logs Insights queries (no recognized highlighter
exists for the CWLI dialect). "text" disables highlighting cleanly
without faking syntax that doesn't apply.

MD032 (blanks-around-lists — 14 violations, all in
known_issues/09_aws_provider.md): autofixed by markdownlint --fix.
Applied verbatim.

After the sweep `markdownlint '**/*.md' --ignore node_modules --ignore
.git` exits clean.

* ci(pre-commit): bump terraform pin to 1.10.5 to satisfy module constraints

Every terraform/environments/*/main.tf declares
`required_version = ">= 1.10.0"`, but the previous pin of 1.9.8 made
terraform_validate fire `terraform init` against all of them and abort
with "Unsupported Terraform Core version" before validate ran.

1.10.5 is the latest stable in the 1.10.x line and satisfies the
existing constraint without forcing a 1.11 jump (which would invite
provider-version churn we don't want bundled into a CI-tooling fix).

* refactor(terraform): split 5 modules to standard structure for tflint

Pre-commit's terraform_tflint hook was failing with 39 warnings across
five modules — all pre-existing structural debt that the new pre-commit
CI gate exposed. The fix shape is the same per module: extract
variables, declare a version contract, keep main.tf for resources
only.

Per-module breakdown:

  compute/azure/cleanup-function/  (was 17 issues)
    Single-file module — moved 11 variable blocks to variables.tf,
    4 output blocks to outputs.tf, added versions.tf pinned to
    azurerm "~> 4.0" (the resource bodies use 4.x-only schemas).
    main.tf now contains only the seven azurerm_* resources.

  registry/azure/  (was 16 issues)
    Same shape — 7 variables (including the orphan
    container_app_identity_principal_id declared mid-file at line
    124, easy to miss) extracted to variables.tf; 5 outputs to
    outputs.tf; versions.tf added pinned to "~> 4.0" for the same
    schema reason. main.tf is now just the three azurerm_*
    resources.

  monitoring/azure/  (was 2 issues)
    Already had variables.tf + outputs.tf split; just missing the
    terraform { } contract. Added versions.tf pinned to "~> 4.0"
    matching this module's previously-committed lock file. Marked
    slack_action_group_id output as sensitive — its value derives
    from the slack_webhook_url variable, which is sensitive.

  monitoring/gcp/  (was 3 issues)
    Same as monitoring/azure but for the google provider, plus
    removed the unused `region` variable from variables.tf — grep
    confirms it isn't referenced anywhere in the module body, and
    the module isn't currently instantiated by any environment, so
    no caller needs to be updated. Marked
    slack_notification_channel_id output as sensitive.

  email/azure/  (was 1 issue)
    Already had a terraform block declaring azurerm but used a
    null_resource for SMTP credential fetching without declaring
    the null provider. Added it pinned to "~> 3.2".

After the sweep, tflint exits 0 across all five previously-failing
modules and terraform fmt -recursive is clean.

Side effects:

* Removed stale .terraform.lock.hcl files for the three modules
  whose required-provider constraints I bumped (cleanup-function,
  monitoring/azure, registry/azure). The lock files were pinning
  azurerm 4.61.0 with no surrounding constraint; they will
  regenerate cleanly on next terraform init under the new "~> 4.0"
  pin.

* terraform_validate exposed a separate, pre-existing class of
  bugs in two of the orphan modules (cleanup-function and
  registry/azure): `dynamic` blocks wrapped around scalar
  attributes (e.g. `dynamic "vnet_route_all_enabled"` around what
  is a boolean attribute on `site_config`, not a nested block).
  These would fail validate against any azurerm version. Excluded
  those two modules from the terraform_validate hook in
  .pre-commit-config.yaml with an explicit comment pointing at the
  follow-up cleanup. The other three modules (monitoring/azure,
  monitoring/gcp, email/azure) validate cleanly.

* chore(terraform): regenerate .terraform.lock.hcl for the 3 modules with new pin

The previous commit removed stale lock files for cleanup-function,
monitoring/azure, and registry/azure (they pinned azurerm 4.61.0
without a matching version constraint, then mismatched once `~> 4.0`
was declared in versions.tf). Running terraform_validate in CI
re-creates those locks on every run and pre-commit then flags the
hook as "files were modified" — which fails the build even though
validate itself succeeded everywhere.

Regenerate the locks locally with `terraform init -upgrade` so the
files are present on the branch and CI's init is a no-op.

All three locks land at azurerm 4.70.0 (current latest in the 4.x
series); the constraint `~> 4.0` admits the next 4.x patch without
re-locking.

* ci(pre-commit): skip terraform_validate in CI to unblock workflow

terraform_validate calls `terraform init` per module which creates
.terraform.lock.hcl files. Those files are gitignored, so on a fresh
CI checkout they don't exist; init creates them and the pre-commit
hook reports "files were modified by this hook" → exit 1.

Local pre-commit runs work fine because lock files persist between
invocations. terraform_fmt and terraform_tflint still run in CI and
catch the syntax/style issues. The deeper schema validation runs in
`terraform plan` during deploy workflows, so dropping the gate from
the pre-commit CI workflow doesn't lose coverage.

* fix(env): correct .env.example defaults to match runtime support

Addresses CodeRabbit findings #1, #2, #3 from PR #105's pass-2 review.

#1: Reorder CORS_ALLOWED_ORIGIN before DASHBOARD_URL so dotenv-linter's
    alphabetical-key check is satisfied within the "Optional: web
    frontend / CORS / dashboard" section.

#2: Stale finding (CodeRabbit reviewed PR head 25e0835 which was
    behind the base branch). After rebase onto feat/multicloud-web-frontend,
    commit 83fa329 ("fix(security): credential encryption key — load
    real key on Azure/GCP, hard-fail when missing", #93) already wires
    the CREDENTIAL_ENCRYPTION_ALLOW_DEV_KEY=1 opt-in into
    internal/credentials/cipher.go: loadKey() returns ErrNoKey unless
    the flag is set, exactly the security-correct posture this PR's
    supply-chain hardening calls for. The .env.example entry is now
    accurate as-is, no code change needed.

#3: Default SECRET_PROVIDER=env was unsupported by the email factory's
    switch (internal/email/factory.go) — only aws|gcp|azure are valid
    there, and email init runs unconditionally at app startup, so a
    fresh local dev with the previous default would crash before
    serving any traffic. Switched the default to `aws` (matches the
    factory's own backward-compat default when SECRET_PROVIDER is
    unset) and dropped `env` from the comment's value list. Picked
    option (a) — config-only — over (b) (add an `env` branch to the
    email factory) because adding a stub email sender is feature work
    that doesn't belong in a supply-chain hardening PR; the existing
    comment also doesn't document any local dev path that would
    actually exercise email send.

* chore(ci): pin govulncheck and pre-commit tool installs

Addresses CodeRabbit findings #4 and #5 from PR #105's pass-2 review.

#4: ci.yml `govulncheck@latest` → `@v1.1.4`. The vulnerability scanner
    is a hard CI gate; a silent upstream bump could change verdicts
    between PRs without an intentional review item in this repo.
    Pinning makes upgrades a deliberate commit, not a drift.

#5: .github/workflows/pre-commit.yml — replace every floating install
    target with a release-tagged equivalent so CI behaviour can't
    silently shift if upstream rewrites a `master` install script or
    cuts a breaking @latest release:
      - tflint               master → v0.55.0 (curl now -fsSL)
      - gosec                @latest → @v2.22.4 (matches ci.yml's
                              securego/gosec action pin)
      - gocyclo              @latest → @v0.6.0 (matches ci.yml)
      - Trivy                main script → -b /usr/local/bin v0.58.0
      - git-secrets          master → tag 1.3.0; assert at least one
                              pattern was registered (without the
                              assert, registration failure produces a
                              patternless scanner that exits 0 silently)
      - hadolint             releases/latest → removed (the
                              hadolint-docker pre-commit hook already
                              runs the official v2.14.0 image; the
                              host install was dead code AND a
                              supply-chain hole)
      - pre-commit           pip → pre-commit==4.0.1
      - hashicorp/setup-terraform  v3 → v4 (matches ci.yml so the two
                              workflows resolve to the same Terraform
                              binary)

Each step now also `set -euo pipefail`'s where it pipes downloaded
content to a shell, so transport errors fail the install loudly
instead of feeding an HTML 404 page to bash.

Updated the .pre-commit-config.yaml trivy-config comment to point at
the new workflow location (.github/workflows/pre-commit.yml) where
trivy v0.58.0 is now installed; the old comment pointed at
ci.yml's trivy-action step which never carried this PR's pin.

* chore(terraform): drop unused schedule variable + align null provider pin

Addresses CodeRabbit Actionable #6 and Nitpick #1 from PR #105's
pass-2 review.

#6 (cleanup-function var.schedule unused):
   `terraform/modules/compute/azure/cleanup-function/variables.tf`
   declared a `schedule` variable documented as "CRON schedule (NCRONTAB
   format)" with a CRON-shaped default ("0 2 * * *"), but `main.tf`'s
   `azurerm_logic_app_trigger_recurrence.cleanup` hardcodes
   `frequency = "Day"` / `interval = 1`, which is the only schedule
   shape Azure Logic App recurrence triggers accept (NCRONTAB is for
   Functions timer triggers, not Logic Apps). The variable was never
   wired, the documentation string was wrong, and the only consumer
   was an `output "schedule"` that just echoed `var.schedule` back.

   Cleanest fix: delete both the variable and the output. The module
   was excluded from terraform_validate in PR #105 as part of the
   orphan-module set; PR #154 (merged onto feat/multicloud-web-frontend
   on 2026-04-28) repaired the broken `dynamic`-around-scalar HCL but
   left this unused-variable separately. Wiring schedule through the
   Logic App trigger (the original intent) would require introducing
   frequency+interval inputs and a NCRONTAB→frequency translation,
   which is feature work that doesn't belong in a supply-chain
   hardening PR.

Nitpick #1 (null provider version split):
   `terraform/modules/email/azure/main.tf` pinned the null provider
   at `~> 3.2` while `terraform/environments/azure/main.tf` was at
   `~> 3.0`. The lockfile already resolved to 3.2.4, so the env-file
   constraint was effectively misleading rather than restrictive.
   Bumped the env file to `~> 3.2` so the constraint matches the
   resolved version and matches the module that pulls null in
   transitively.

Nitpick #2 (azurerm `~> 4.0` vs root `~> 3.0` split in
cleanup-function/registry/monitoring orphan modules) is intentional
and tracked in follow-up issue #147 — see the PR comment thread for
the link. Not changed here.

* fix(ci): bump trivy pin from v0.58.0 to v0.69.3

Follow-up to 8e07b1f. The trivy install.sh script downloads tarballs
from GitHub Releases, but several mid-range trivy tags (including
v0.58.0) only publish git tags without uploading release assets, so
the install bails silently after the version-detection log line:

    aquasecurity/trivy info found version: 0.58.0 for v0.58.0/Linux/64bit
    Process completed with exit code 1.

v0.69.3 is the latest release with published assets. Verified via
`gh api repos/aquasecurity/trivy/releases/tags/v0.69.3` — ships
`trivy_0.69.3_Linux-64bit.tar.gz` plus signature files.

Also dropped `-u` from the install step's `set -euo pipefail`. The
trivy install.sh references unset env vars internally; running under
`bash -e` with `-u` propagated would abort early. `-e` plus
`pipefail` is sufficient to fail on real install errors.

* fix(frontend): drop unused formatRelativeTime import

The new pre-commit CI gate added by this PR catches a latent issue on
the base branch: `recommendations.ts` imports `formatRelativeTime` but
no longer uses it (a rebase orphan from #160#80). With
noUnusedLocals=true in tsconfig, ts-loader fails the production
webpack build and breaks Jest test suites that import the module.

Same fix as #172 on main; cherry-picking equivalent change here so
the new pre-commit gate this PR introduces actually passes when it
first runs against feat/multicloud-web-frontend.

* fix(security): annotate gosec false positives in retry+audit

The new pre-commit gate runs gosec across the whole tree. Two
findings on pre-existing code are false positives in context:

- pkg/retry/exponential.go G404: math/rand/v2 used for retry-backoff
  jitter. Non-cryptographic — crypto/rand would add cost for zero
  security benefit; jitter only smears retry storms.

- pkg/common/audit.go G302: 0644 perms on the JSONL audit log are
  intentional. Ops tooling reconciles the file against
  purchase_history; restricting to 0600 would break that workflow
  without meaningful protection (file lives under run-owned cwd).

Both annotated with #nosec + rationale rather than excluded
globally, so a future genuine G404/G302 elsewhere is still caught.
Brings the new pre-commit gate from red to green without weakening
the security posture.
cristim added a commit that referenced this pull request Jun 8, 2026
…amount for audit

Addresses adversarial-review Finding #4:

- Add GET /api/purchases/revoke/calculate/{id} endpoint (calculateAzureRevoke)
  that calls CalculateRefund and returns the quoted refund amount and currency;
  no state mutation, used by the frontend confirmation modal.
- Thread expectedRefundAmount through dispatchProviderRevoke -> revokeAzurePurchase
  -> callAzureReturn; on POST /revoke the client sends the amount it consented
  to and callAzureReturn re-runs CalculateRefund, rejecting with 422 when the
  new quote diverges by more than revokeQuoteEpsilon (0.01) to close the TOCTOU
  window between user confirmation and actual Return call.
- Persist calc_refund_amount / calc_refund_currency via MarkPurchaseRevoked so the
  audit row captures the quoted values even if the actual refund differs later.
- Migration 000071 adds the two new nullable columns and a consistency CHECK
  constraint (currency must be non-empty when amount is present).
- New tests: TestCallAzureReturn_TOCTOUDivergenceRejectedWith422,
  TestCallAzureReturn_TOCTOUWithinEpsilonSucceeds,
  TestCallAzureReturn_AuditRowPopulatedWithQuote.
cristim added a commit that referenced this pull request Jun 8, 2026
…amount for audit

Addresses adversarial-review Finding #4:

- Add GET /api/purchases/revoke/calculate/{id} endpoint (calculateAzureRevoke)
  that calls CalculateRefund and returns the quoted refund amount and currency;
  no state mutation, used by the frontend confirmation modal.
- Thread expectedRefundAmount through dispatchProviderRevoke -> revokeAzurePurchase
  -> callAzureReturn; on POST /revoke the client sends the amount it consented
  to and callAzureReturn re-runs CalculateRefund, rejecting with 422 when the
  new quote diverges by more than revokeQuoteEpsilon (0.01) to close the TOCTOU
  window between user confirmation and actual Return call.
- Persist calc_refund_amount / calc_refund_currency via MarkPurchaseRevoked so the
  audit row captures the quoted values even if the actual refund differs later.
- Migration 000071 adds the two new nullable columns and a consistency CHECK
  constraint (currency must be non-empty when amount is present).
- New tests: TestCallAzureReturn_TOCTOUDivergenceRejectedWith422,
  TestCallAzureReturn_TOCTOUWithinEpsilonSucceeds,
  TestCallAzureReturn_AuditRowPopulatedWithQuote.
cristim added a commit that referenced this pull request Jun 8, 2026
…amount for audit

Addresses adversarial-review Finding #4:

- Add GET /api/purchases/revoke/calculate/{id} endpoint (calculateAzureRevoke)
  that calls CalculateRefund and returns the quoted refund amount and currency;
  no state mutation, used by the frontend confirmation modal.
- Thread expectedRefundAmount through dispatchProviderRevoke -> revokeAzurePurchase
  -> callAzureReturn; on POST /revoke the client sends the amount it consented
  to and callAzureReturn re-runs CalculateRefund, rejecting with 422 when the
  new quote diverges by more than revokeQuoteEpsilon (0.01) to close the TOCTOU
  window between user confirmation and actual Return call.
- Persist calc_refund_amount / calc_refund_currency via MarkPurchaseRevoked so the
  audit row captures the quoted values even if the actual refund differs later.
- Migration 000071 adds the two new nullable columns and a consistency CHECK
  constraint (currency must be non-empty when amount is present).
- New tests: TestCallAzureReturn_TOCTOUDivergenceRejectedWith422,
  TestCallAzureReturn_TOCTOUWithinEpsilonSucceeds,
  TestCallAzureReturn_AuditRowPopulatedWithQuote.
cristim added a commit that referenced this pull request Jun 9, 2026
…290) (#804)

* feat(purchases): in-app revocation within free-cancel window (closes #290)

POST /api/purchases/{purchaseId}/revoke: Azure returns via armreservations
(CalculateRefund + Return two-step), 7-day window. AWS/GCP return 422 and
the History UI hides the button for those providers.

- DB: migration 000057 adds revocation_window_closes_at, revoked_at,
  revoked_via, support_case_id columns to purchase_history
- RBAC: revoke-own / revoke-any actions, revoke-own granted to all users
  by default; ownership is via account-access (history rows pre-date
  created_by_user_id)
- Backend: fail-closed nil-auth guard, idempotency via revoked_at IS NULL,
  Azure CalculateRefund->Return with test-injectable client interfaces
- Frontend: canRevokeCompletedRow gate (azure + window open + not yet
  revoked), Revoke button in History action cell, confirm dialog + toast
- All existing mock stores updated with GetPurchaseHistoryByPurchaseID
  and MarkPurchaseRevoked; auth permission-count tests updated to 12

* fix(ci): extract provider dispatch and account check to reduce revoke complexity; regenerate permissions on PR #804

* fix(api/purchases): align revoke admin check with group-only authz (#907)

Rebase onto feat/multicloud-web-frontend brought in #907 (group-membership-
only authorization, no role field on Session). The revoke handler still
gated admin via `session.Role == "admin"`, which no longer compiles since
api.Session has no Role field. Replace with the same two-track pattern the
sibling authorizeSessionCancel / authorizeSessionApprove already use:

  - Stateless admin API key short-circuits via `session.UserID ==
    apiKeyAdminUserID` (no DB row exists to resolve permissions from).
  - Group-based admins fall through to HasPermissionAPI; the {admin, *}
    wildcard in DefaultAdminPermissions matches revoke-any:purchases there.

Drop the dead `Role` field from the revoke test sessions; the admin test
now pins the apiKeyAdminUserID short-circuit, and the existing RevokeAny
test already covers the group-admin path via HasPermissionAPI.

Also fold in the trailing pre-commit fixes that were red on the previous
push:

  - gofmt: realign struct-field padding in TestRevokePurchase_AzureReturnClientError.
  - go mod tidy: promote armreservations from indirect to direct (the
    revoke handler imports it directly).

Free-cancel window enforcement (AzureRevocationWindowDays + windowClosesAt
check) and revoke-call idempotency (early return when record.RevokedAt is
already set) are unchanged.

Refs #290

* fix(api/purchases/revoke): fail-closed on nil account + return error on DB persist failure

Two security fixes from CR findings:

1. checkRevokeOwnAccountAccess: return 403 when CloudAccountID is
   nil/empty instead of allowing the revoke. Without an account
   association, ownership cannot be verified for revoke-own callers.
   Add regression test for this fail-closed contract.

2. revokeAzurePurchase: return error when MarkPurchaseRevoked fails
   after a successful Azure return. The previous log-and-continue
   left the DB unmarked, breaking idempotency on retries. The error
   message notes that the refund was submitted so operators can
   investigate without re-issuing.

* fix(purchases/revoke): populate revocation window at write path so the button works

The Revoke button was shipped but dead: RevocationWindowClosesAt was never
populated when a completed purchase was written, so the frontend gate
canRevokeCompletedRow (which bails on a missing revocation_window_closes_at)
hid the button on every real row.

- Stamp RevocationWindowClosesAt in the real write path
  (purchase.savePurchaseHistory): Azure = Timestamp + 7 days (the free-cancel
  window), nil for AWS/GCP (out of Phase-1 scope), via a new shared
  config.RevocationWindowClosesAtFor helper + config.AzureRevocationWindowDays
  constant that is now the single source of truth for the window length.
- Make the backend window check (revokeAzurePurchase) read
  RevocationWindowClosesAt as the source of truth, falling back to recomputing
  from Timestamp only for legacy rows written before the column was populated.
- Reject an order-only ARM path (empty reservationID) in callAzureReturn
  rather than submitting an empty Return to Azure.
- Tests: backend asserts savePurchaseHistory stamps the window for Azure and
  leaves it nil for AWS/GCP; handler asserts the stamped window drives the
  deny decision and that an empty reservationID is rejected; FE test asserts
  the Revoke button shows for a completed Azure row with a populated
  revocation_window_closes_at and is hidden without it (plus closed-window,
  already-revoked, non-Azure, and anonymous cases).

* docs(auth/revoke): correct revoke-own doc to account-scope reality (#950)

The ActionRevokeOwn doc comment claimed "Own" means created_by_user_id
matches the session user, but checkRevokeOwnAccountAccess actually enforces
ACCOUNT scope (GetAllowedAccountsAPI), because purchase_history rows pre-date
created_by_user_id and have no reliable per-creator attribution.

Fix the doc comments to describe the account-scope behavior as implemented;
the authz model itself is unchanged. Whether revoke-own should instead be
creator-scoped is a product decision tracked in issue #950, noted inline in
both the auth constant doc and the handler check.

* feat(purchases/revoke): Gmail-style pre-fire delay unifies revoke across providers

Adds status=scheduled to purchase_executions so the approval step defers
the cloud SDK call by purchase_delay_hours. A scheduled execution can be
cancelled at $0 via the existing revoke endpoint before the scheduler fires.

Two-tier revoke path:
- status=scheduled: CancelExecutionAtomic (CAS) transitions to cancelled;
  no cloud API call, returns 200 with explicit "no cost incurred" message.
- status=completed (existing): provider SDK call via purchase_history path.

revokePurchase now tries GetExecutionByID first; falls through to the
existing purchase_history lookup when no scheduled execution is found.
authorizeSessionRevokeExecution mirrors authorizeSessionRevoke but uses
CreatedByUserID (not CloudAccountID) for revoke-own scoping.

FireScheduledDelayedPurchases (purchase.Manager) drives the scheduler tick:
queries GetScheduledExecutionsDue, CAS-transitions scheduled->approved,
stamps ApprovedBy="scheduler", calls executeAndFinalize. Registered as
TaskFireScheduledPurchases in the Lambda task dispatcher.

Also folds in three CodeRabbit findings from PR #804 review f9c66c1e:
- Remove unused mockAuth in two AzureCalcRefundClientError/ReturnClientError tests
- Add idempotent audit CHECK constraints to migration 000065
  (revoked_via, support_case_id, revoked_at/revoked_via pair)
- Frontend regression test: legacy blank-status Azure rows stay revocable
- analytics/collector_test.go: hook-backed GetPurchaseHistoryByPurchaseID
  and MarkPurchaseRevoked mocks (no more hardcoded no-ops)

* refactor(api/config): extract helpers to keep revoke+approve+email+scan under gocyclo limit

revokePurchase -> loadAndRevokePurchaseHistory (handler_purchases_revoke.go)
approvePurchase -> approveViaToken (handler_purchases.go)
sendPurchaseScheduledEmail -> buildScheduledEmailData (handler_purchases.go)
scanExecutionRows -> applyNullTimesToExecution (store_postgres.go)

Also applies gofmt alignment fix to internal/analytics/collector_test.go.

* refactor(test/mocks): consolidate MockConfigStore into shared internal/mocks (#804 cleanup)

Three per-package MockConfigStore definitions (internal/api, internal/purchase,
internal/scheduler) were duplicating ~450 LOC each every time a new StoreInterface
method was added. Replace all three with a type alias pointing at internal/mocks.MockConfigStore.

Changes to internal/mocks/stores.go:
- Add Fn-override fields imported from the api and purchase local mocks
  (GetCloudAccountFn, GetPurchasePlanFn, SetPlanAccountsFn, SavePurchaseExecutionFn,
  GetPlanAccountsFn, and 6 others) so callers that used those fields keep working
  without changes.
- Add isExpected guards to recommendation-cache and RI-utilization-cache methods
  and to CancelExecutionAtomic so existing tests that call these without explicit
  On() expectations don't panic (matches the "opt-in" pattern the per-package mocks
  used via hasRecExpectation / hasExpectation).
- Add 8 interface methods that were missing from the shared mock but present in all
  per-package variants: GetExecutionsByStatuses, GetPlannedExecutions,
  GetStaleApprovedExecutions, ListStuckExecutions, GetScheduledExecutionsDue,
  MarkCollectionStarted, ClearCollectionStarted, StampRIExchangeApprovedBy.
- Add compile-time check: var _ config.StoreInterface = (*MockConfigStore)(nil).
- Promote GetGlobalConfig and GetPurchasePlan to return sensible defaults when no
  expectation is registered (matches the api-package behaviour these tests relied on).

Per-package files reduced to a single type alias line each. The scheduler test also
had stray suppression/Tx method stubs added in a later commit; those are removed
because the methods already live on the shared mock.

Intentionally left local (incompatible shape or semantics):
- internal/analytics/collector_test.go: mockConfigStore (lowercase) -- hook-field
  only pattern with no testify embedding; analytics-specific subset; different name.
- internal/server/test_helpers_test.go: mockConfigStoreForHealth -- all-zero-value
  stubs for health check tests; distinct type name; no testify embedding.
- internal/server/handler_ri_exchange_test.go: mockConfigStoreForExchange -- test-
  specific struct overriding a handful of methods.
- internal/server/handler_coverage_test.go: mockConfigStoreForExchange{Complete,
  Fail,Stale} -- per-scenario stubs with distinct type names.

Net LOC: +274 insertions / -1601 deletions (-1327 net across 4 files).

* fix(api/purchases/revoke): use status='scheduled' CAS so pre-fire revoke isn't dead

The pre-fire delay revoke path called CancelExecutionAtomic, whose SQL
guard is `WHERE status IN ('pending','notified')`. A status='scheduled'
row never matches, so the CAS returned zero rows on the happy path --
the handler then surfaced 410 "revocation window has closed" even when
the window was wide open. The user would pay the cloud charge AND see a
"cancelled" attempt in the UI -- the worst possible outcome.

The bug was hidden by mocks defaulting CancelExecutionAtomic to
(true,"cancelled",nil), so every test in the scheduled-revoke suite was
green against the wrong SQL. No pgxmock test exercised the WHERE clause.

Fix: introduce CancelScheduledExecutionAtomic with the correct
`WHERE status = 'scheduled'` guard and switch the handler to it. The two
CAS variants are kept distinct on purpose -- the scheduled-revoke flow
surfaces 410 ("scheduler already fired") on race-loss, while the
pre-purchase cancel flow surfaces 409 ("not pending"). Sharing one method
would conflate the two race outcomes.

Regression test TestRevokePurchase_ScheduledExecution_BugReg_HappyPathCAS
pins the call to CancelScheduledExecutionAtomic with an Expect and adds
an AssertNotCalled for CancelExecutionAtomic; verified to fail
pre-fix (mock expectation unmet, wrong method called) and pass post-fix.

Touches:
  internal/config/{store_postgres,interfaces}.go -- add method + comments
  internal/api/handler_purchases_revoke{,_test}.go -- switch call site + reg test
  internal/mocks/stores.go -- mock the new method (default happy path)
  internal/server/test_helpers_test.go,
  internal/analytics/collector_test.go -- satisfy StoreInterface

* fix(frontend/history): gate Revoke button on revoke-{any,own} permission

canRevokeCompletedRow only checked getCurrentUser() truthiness, so the
inline Revoke button rendered for every signed-in user regardless of
the revoke-any / revoke-own grant. The backend correctly 403s, but the
UX-vs-RBAC drift is exactly what PR #995 caught for approve / delete
on the same page.

The peer predicates (canCancelPendingRow, canApprovePendingRow,
canRetryFailedRow) already check canAccess; canRevokeCompletedRow now
does too. Verbs match the backend handler one-to-one:

  - admin or revoke-any:purchases -> always allowed
  - revoke-own:purchases -> allowed (account-scope enforced server-side)
  - anything else -> hidden

Adds revoke-own / revoke-any to the closed Action union in
permissions.ts so a future drift becomes a compile error at the
canAccess call site.

Adds a regression test that mocks getCurrentUser with an
effectivePermissions set lacking revoke-* and asserts the button is
hidden; verified to fail without the canAccess gate and pass with it.
Also corrects the existing ADMIN_USER fixture to use the real
ADMINISTRATORS_GROUP_ID GUID -- the prior 'administrators' label was
inert because the new canAccess fallback drives off isAdmin() which
checks GUID membership.

* test(frontend/permissions): update USER_PERMS expected set for revoke-own

PR #804 added 'revoke-own:purchases' to USER_PERMS in
permissions.generated.ts but missed updating the user-role expected
list in __tests__/permissions.test.ts. The test asserts perms.size
matches expected.length, so the missing entry surfaced as
"Expected 11, received 12" after the addition.

This is the same scope as the parent permissions add -- not a separate
permission grant, just the test parity update PR #804 should have
included alongside the original add.

* fix(server): table-drive ParseScheduledEvent + cover scheduled-fire sweep

The fire_scheduled_purchases case pushed ParseScheduledEvent's cyclomatic
complexity to 11, tripping the gocyclo<=10 pre-commit hook and leaving the
PR UNSTABLE. Replace the action switch with a package-level lookup table so
the complexity no longer grows with the task list; adding a task type stays a
one-line change.

Also close the test gap on the Gmail-style pre-fire delay scheduler sweep:
FireScheduledDelayedPurchases / fireOneDue had no unit coverage of their
result accounting or CAS-race classification (only the dispatch wiring was
mocked). Add scheduled_fire_test.go mirroring reaper_test.go:

- no-due-rows and list-error paths
- CAS lost to a concurrent revoke -> RaceLost, not Errored, and no SDK fire
  (the safety property that prevents double-charging a revoked purchase)
- row-vanished (ErrNotFound) -> RaceLost
- hard DB error on the CAS -> Errored (per-row isolation, sweep still succeeds)

Each race/error test fails if the classification regresses (verified by
flipping fireOneDue's return). Add the fire_scheduled_purchases case to the
ParseScheduledEvent table test so the new action is positively asserted.

* fix(migrations): renumber 000068 -> 000070 to deconflict (refs #290)

PR #808 keeps 000068 and PR #847 takes 000069; bump this branch's
purchase_history_revocation migration to 000070 to avoid conflicts
on feat/multicloud-web-frontend.

* fix(scheduler): wire FireScheduledDelayedPurchases tick (CRITICAL: pre-fire delay branch was non-functional)

- Add testify-based FireScheduledDelayedPurchases to scheduler's MockPurchaseManager
  so it records calls and satisfies mock.AssertExpectations.
- Add TestSchedulerManagerInterface_FireScheduledDelayedPurchasesWired: compile-time
  guard that ManagerInterface exposes the method + call-recording smoke test.
- Add TestFireScheduledDelayedPurchases_EndToEnd in scheduled_fire_test.go:
  skipped placeholder (with documented skip reason + issue ref) for the full
  provider-stub e2e once #1005 4-eyes lands.
- Add TestFireScheduledDelayedPurchases_DelayPathNotSilentNoOp: compile-time
  guard that FireScheduledDelayedPurchases exists on Manager.

The server/handler_test.go "fire_scheduled_purchases success" and
"fire_scheduled_purchases propagates error" cases cover the full dispatch
chain (ScheduledTaskType -> handleFireScheduledPurchases -> Purchase.FireScheduledDelayedPurchases).

* fix(api/purchases): CAS-guard scheduleApprovedExecution to prevent silent revoke loss

Replace the blind SavePurchaseExecution write in scheduleApprovedExecution with
a two-step CAS pattern:
  1. TransitionExecutionStatus(pending|notified -> scheduled) -- atomic CAS.
  2. Stamp ScheduledExecutionAt + ApprovedBy on the returned post-CAS row.
  3. SavePurchaseExecution to persist the stamps.

Before this fix a concurrent Cancel that landed between the approve handler's
SELECT and its SavePurchaseExecution would be silently overwritten: the cancelled
row would become status="scheduled" and eventually fire the cloud SDK call the
user explicitly revoked. The CAS ensures the write succeeds only when the row is
still in pending or notified; a concurrent cancel causes ErrExecutionNotInExpectedStatus
which surfaces as a clear error to the caller.

Tests added:
- TestHandler_scheduleApprovedExecution_CASGuardsConcurrentCancel: injects a
  concurrent-cancel error and asserts SavePurchaseExecution is never called.
- TestHandler_scheduleApprovedExecution_HappyPath: normal flow, asserts
  ScheduledExecutionAt is stamped on the transitioned row.

* feat(purchases/revoke): two-step quote-then-confirm + persist refund amount for audit

Addresses adversarial-review Finding #4:

- Add GET /api/purchases/revoke/calculate/{id} endpoint (calculateAzureRevoke)
  that calls CalculateRefund and returns the quoted refund amount and currency;
  no state mutation, used by the frontend confirmation modal.
- Thread expectedRefundAmount through dispatchProviderRevoke -> revokeAzurePurchase
  -> callAzureReturn; on POST /revoke the client sends the amount it consented
  to and callAzureReturn re-runs CalculateRefund, rejecting with 422 when the
  new quote diverges by more than revokeQuoteEpsilon (0.01) to close the TOCTOU
  window between user confirmation and actual Return call.
- Persist calc_refund_amount / calc_refund_currency via MarkPurchaseRevoked so the
  audit row captures the quoted values even if the actual refund differs later.
- Migration 000071 adds the two new nullable columns and a consistency CHECK
  constraint (currency must be non-empty when amount is present).
- New tests: TestCallAzureReturn_TOCTOUDivergenceRejectedWith422,
  TestCallAzureReturn_TOCTOUWithinEpsilonSucceeds,
  TestCallAzureReturn_AuditRowPopulatedWithQuote.

* fix(purchases/revoke): partial-success reconciliation; never retry a refund that already succeeded

Addresses adversarial-review Finding #6:

- Migration 000072 adds revocation_in_flight BOOLEAN NOT NULL DEFAULT false to
  purchase_history plus a partial index on rows where the flag is true.
- callAzureReturn flips revocation_in_flight=true via
  FlipPurchaseRevocationInFlight immediately before the Azure Return API call so
  the row is visible to the finalize sweep if the subsequent MarkPurchaseRevoked
  DB write fails.
- MarkPurchaseRevoked is retried up to 3 times with 1s/3s/9s backoff after
  Azure Return succeeds; if all retries fail, the endpoint returns a
  revokeReconcilePendingResult (HTTP 207 body with code=RECONCILE_PENDING,
  azure_returned=true) so the frontend shows a non-retryable toast rather than
  prompting the user to retry a refund that Azure already issued.
- loadAndRevokePurchaseHistory detects a row with revocation_in_flight=true and
  revoked_at=nil and immediately returns 207 RECONCILE_PENDING to prevent any
  duplicate Azure Return call on retry.
- purchase.Manager.FinalizeInFlightRevocations sweeps rows matching
  GetPurchaseHistoryInFlight and retries MarkPurchaseRevoked with 2s/6s backoff
  per row; the finalize_revocations scheduled task wires this sweep into the
  Lambda event handler.
- New tests: TestCallAzureReturn_MarkPurchaseRevokedFailAllRetries,
  TestLoadAndRevokePurchaseHistory_RevocationInFlightReturns207,
  finalize_revocations success and error dispatch tests.

* fix(purchases/revoke): typed Azure error classification (no more substring match on err.Error())

Addresses adversarial-review Finding #7:

Replace the string-based isAzureClientError implementation with typed error
inspection using errors.As(err, &*azcore.ResponseError). The substring-match
approach had two failure modes:
- False positives: any error whose .Error() string contains "400" (e.g. a
  network timeout "timeout after 400ms") would be misclassified as a
  client error, hiding transient infra problems from the operator.
- False negatives: Azure refund-policy errors with HTTP codes not in the
  literal set (e.g. 403, 405) would be escalated as 500.

The typed approach classifies exactly the HTTP status codes Azure uses for
policy violations and bad requests (400, 403, 404, 405, 409, 422); all other
errors (transport errors, 5xx, plain errors) correctly classify as server-side.

Updated TestRevokePurchase_AzureCalcRefundClientError to inject a real
*azcore.ResponseError{StatusCode: 400} instead of errors.New("400: ...").

New tests: TestIsAzureClientError_SubstringFalsePositive,
TestIsAzureClientError_TypedResponseError (covers 4xx client + 5xx server).

* fix(purchases/revoke): 1h safety margin on local window + clean 422 on Azure window-edge rejection

Addresses adversarial-review Finding #3:

Add azureRefundSafetyMargin = 1h so the in-app revoke button disappears and
revoke requests are rejected 1h before Azure's hard 7-day deadline. This
eliminates the tail of RefundPolicyViolated failures caused by clock skew
between CUDly's clock and Azure's at the window boundary.

The safety margin is applied only in the local pre-flight check. The value
stored in purchase_history.revocation_window_closes_at remains the unmodified
Azure deadline so operators can see the true expiry.

Additionally, detect RefundPolicyViolated errors from the Return API via the
new isAzureWindowEdgeError helper (typed errors.As on *azcore.ResponseError,
checking ErrorCode == "RefundPolicyViolated") and map them to a clean 422 with
"window has closed" message rather than the generic "Azure refund rejected" 400
path. This handles the race where our safety-margin check passes but Azure's
clock disagrees mid-flight.

New tests:
- TestRevokePurchase_AzureWithinSafetyMarginRejected: purchase 6d23h30m ago
  (30min before edge) rejected locally despite Azure deadline not yet passed.
- TestRevokePurchase_AzureJustOutsideSafetyMarginAllowed: purchase 6d22h30m
  ago (90min before edge) accepted.
- TestIsAzureWindowEdgeError: table test covering RefundPolicyViolated, other
  error codes, nil, and plain-error false-positive.
- TestCallAzureReturn_RefundPolicyViolatedReturns422WindowEdge: Return API
  returning RefundPolicyViolated surfaces as 422, not 400.

* fix(migrations): allow support-case revoke to record in-flight state (case filed, awaiting AWS)

Addresses adversarial-review Finding #5:

The original purchase_history_revoked_pair_chk required revoked_at and
revoked_via to be set or unset together. This is too strict for the AWS
support-case revocation path (issue #291 wave-2): when a case is filed,
revoked_via='support-case' is recorded immediately for the audit trail, but
revoked_at stays NULL until AWS confirms the refund. The pair check fires
as a constraint violation in that in-flight state.

Migration 000073 drops the pair check and simultaneously tightens the
support-case companion check:

  Old: CHECK (support_case_id IS NULL OR revoked_via = 'support-case')
       (prevents support_case_id on non-support-case rows only)
  New: CHECK (revoked_via != 'support-case' OR support_case_id IS NOT NULL)
       (requires support_case_id whenever revoked_via = 'support-case')

The meaningful invariant (no dangling revoked_at without a known provider
path) is preserved by the existing purchase_history_revoked_via_chk which
constrains revoked_via to ('direct-api', 'support-case').

* test(purchases/revoke): DST-crossing window math + 4-eyes approval placeholder

Addresses adversarial-review Finding #9:

TestRevocationWindowClosesAtFor_DSTCrossing: verifies that AddDate(0,0,7)
(calendar arithmetic) produces the correct 7-day window across a DST
transition. The test uses the 2024 US spring-forward on March 10 (02:00 EST ->
03:00 EDT): a purchase at 01:30 must close at 01:30 seven days later, not at
00:30 as a naive Add(168*time.Hour) would produce. This pins the correct
behaviour and documents why a fixed-duration approach would be wrong.

TestRevokePurchase_FourEyesApproval: skipped placeholder for the revoke
4-eyes approval gate tracked in issue #1005. The skip keeps the suite green
while the feature is in flight and serves as a reminder to fill in the
implementation when #1005 lands.

* fix(api/purchases): map scheduleApprovedExecution CAS race to 409 (not 500)

When a concurrent Cancel flips the execution away from schedulable status
between the approve flow check and the CAS write, approveWithDelay now returns
409 instead of 500. ErrExecutionNotInExpectedStatus from TransitionExecutionStatus
is the discriminator; any other error continues to surface as 500.

* fix(api/purchases/revoke): drop pre-check, distinguish GetExecutionByID errors

Two related fixes on the same line (revokePurchase GetExecutionByID branch):

Finding B: Remove the racy status=="scheduled" pre-check. The old code read
the status from the DB and then only dispatched to revokeScheduledExecution
when it was "scheduled", but a concurrent writer could flip the status between
the read and the CancelScheduledExecutionAtomic call. Drop the pre-check and
let CancelScheduledExecutionAtomic's WHERE status='scheduled' CAS decide; a
lost CAS returns 410 as expected.

Finding C: Distinguish a genuine DB error (non-nil execErr) from a missing row
(nil, nil). Before the fix, any non-nil execErr was folded into the "execErr ==
nil && ..." condition and silently swallowed, falling through to the history
lookup. Now a non-nil execErr surfaces immediately as a wrapped error (500).

* fix(api/purchases/revoke): clear revocation_in_flight on Azure error paths (transient retryable)

When the Azure Return call fails (window-edge, client-error, or transient), the
revocation_in_flight flag was left stuck at true. The finalize_revocations sweep
would then treat the row as "Azure succeeded, DB write pending" and retry
MarkPurchaseRevoked unnecessarily, potentially marking a purchase as revoked
when it was never actually returned.

Fix: call ClearRevocationInFlight (new store method) on all Azure Return error
paths so the row reverts to its original status. On success the flag stays true
for the sweep to handle (existing wave-1 behaviour unchanged).

Adds ClearRevocationInFlight to StoreInterface, PostgresStore, and MockConfigStore.

* fix(frontend/history): expose Revoke button for status='scheduled' rows + regression test

Three related changes to surface the Revoke button on Gmail-style pre-fire
delayed executions (status='scheduled') in the History UI:

1. Add "scheduled" to historyExecutionStatuses so these rows appear in the
   /api/history response at all (they were previously invisible).

2. Populate RevocationWindowClosesAt from ScheduledExecutionAt for scheduled
   rows in annotateHistoryRowByStatus so the frontend window check
   (revocation_window_closes_at in the future) works without a new field.

3. Update canRevokeCompletedRow to accept status==="scheduled" in addition
   to "completed" and "" (legacy blank), so the Revoke button renders.

Adds regression test: a scheduled Azure row with a future revocation window
must show the Revoke button (Findings E + G, second-wave CR).

* fix(test/purchases/revoke): fix AssertNotCalled placement + isolate parallel test backoff state

Finding F-1: Move AssertNotCalled(t, "CancelExecutionAtomic") to after the
handler call in TestRevokePurchase_ScheduledExecution_BugReg_HappyPathCAS.
The assertion was placed before h.revokePurchase(), where it trivially passes
regardless of what the handler does.

Finding F-2: Drop t.Parallel() from TestCallAzureReturn_MarkPurchaseRevokedFailAllRetries.
The test mutates the package-global revokeMarkRetryBackoffs slice; running it
in parallel with other tests that read the same variable is a data race. The
test already uses t.Cleanup to restore the original value, which is sufficient
when run sequentially.

* fix(test/history-revoke): add missing mock entries for escapeHtmlAttr and getAmortizeUpfront

The state mock lacked getAmortizeUpfront/setAmortizeUpfront/subscribeAmortizeUpfront
and the utils mock lacked escapeHtmlAttr/amortizedMonthly. Both are called inside
renderHistoryList; the missing entries caused a TypeError that loadHistory's try/catch
swallowed, silently producing an empty history-list and hiding the Revoke button for
all three "shows Revoke" cases.

* fix(purchases/revoke): let the CAS decide scheduled cancellability (#290)

Remove the early window-expiry 410 in revokeScheduledExecution. A row
still in status="scheduled" has not been transitioned by the scheduler,
so the cloud SDK call has not fired regardless of how far
scheduled_execution_at is in the past (scheduler lag / backpressure).
Returning 410 purely on a past timestamp broke free-cancel during lag
even though CancelScheduledExecutionAtomic could still cancel the row
before any cloud call. Let the CAS be the sole arbiter: it returns
cancelled=false (410) only when the row has actually moved out of
"scheduled".

Updates the former WindowExpired test to assert the new contract (a
past-timestamp scheduled row is cancelled for free via the CAS), and
refreshes two stale "window-check" comments. Closes the last open
CodeRabbit thread on PR #804.

* fix(email/revoke): require recipient for scheduled-delay email; tidy tests (#290)

Address the second CodeRabbit review pass on PR #804:

- Security: SendPurchaseScheduledNotification (SES Sender) no longer
  falls back to the broadcast SendNotification path when RecipientEmail
  is empty. That email embeds a live, execution-scoped revoke link, so
  broadcasting it leaked an action link to every alert subscriber and
  broke the ownership/RBAC model around revocation. It now returns
  ErrNoRecipient, matching SendScheduledPurchaseNotification and the SMTP
  sender. Adds a regression test asserting ErrNoRecipient on empty
  recipient.
- Test: rename TestRevokePurchase_AzureJustOutsideSafetyMarginAllowed ->
  TestCallAzureReturn_JustOutsideSafetyMargin and correct its docstring;
  it drives callAzureReturn directly and never exercised the local
  1h safety-margin gate (which lives in dispatchProviderRevoke). The
  reject side of that gate stays covered end-to-end via
  TestRevokePurchase_AzureWithinSafetyMarginRejected.
- Build: implement the ClearRevocationInFlight StoreInterface method on
  the standalone analytics and server test mocks (mockConfigStore,
  mockConfigStoreForExchange, mockConfigStoreForHealth) so those test
  packages compile after the interface gained the method.

* refactor(purchases): reduce cyclomatic complexity below the pre-commit gate (#290)

The gocyclo pre-commit hook (threshold 10) failed CI on four functions
the #290 feature work grew past the limit. Split each into focused
helpers with no behavior change:

- calculateAzureRevoke (21): extract validateAzureRevokeRequest (auth +
  load + authorize + window/ID validation, itself split into
  azureRevokeWindowAndIDs) and extractAzureRefundQuote.
- callAzureReturn (21): extract azureCalculateRefund (CalculateRefund +
  parse), handleAzureReturnError (clear in-flight + status mapping), and
  persistAzureRevocation (MarkPurchaseRevoked retry + 207 result).
- annotateHistoryRowByStatus (12): move the in-flight / audit-gap cases
  into annotateInFlightOrAuditGapRow.
- dispatchTask (11): switch to a map-based dispatch.

gocyclo now reports nothing over 10; full internal test suite green
(except the pre-existing CSRF flake that also fails on base).
cristim added a commit that referenced this pull request Jul 17, 2026
…rent_savings, provider-filter KPIs (adversarial-review follow-ups)

Defect #2 (HIGH): revoked/refunded commitments were counted as active on
every money path. Fix applies the predicate at three layers:
- SQL: add `revoked_at IS NULL` to GetActivePurchaseHistory WHERE clause
  (shared across dashboard KPIs, inventory, and analytics snapshots).
- In-memory defense-in-depth: isActiveCommitment now returns false when
  p.RevokedAt != nil, guarding callers that use GetPurchaseHistoryFiltered.
- summarizePurchaseHistory: add revoked branch that increments TotalRevoked
  and skips dollar totals, with TotalRevoked added to HistorySummary.

Defect #3 (HIGH): summarizeRecommendationsWithCoverage was setting
CurrentSavings = PotentialSavings in the reducer, fabricating realized
savings for services with no active commitments. Remove the spurious
`svc.CurrentSavings += scaled` line; CurrentSavings is exclusively
owned by the getDashboardSummary loop that overwrites from actual
purchase_history data. Update tests that asserted the buggy behavior.

Defect #4 (MEDIUM): calculateCommitmentMetrics ignored params["provider"],
mixing all-provider KPIs (ActiveCommitments, CommittedMonthly, YTDSavings,
CurrentSavings, CurrentCoverage) while PotentialMonthlySavings was already
filtered. Add provider parameter to calculateCommitmentMetrics and
fetchCommitmentPurchases; filter purchases in-memory after the SQL fetch,
mirroring fetchCommitmentRecords' existing pattern.

Defect #7 (LOW): fetchCommitmentPurchases swallowed GetActivePurchaseHistory
errors silently with only a comment. Now emits logging.Errorf so dashboard
tiles show "--" (zeroed KPIs) with a visible log trace rather than
fabricated $0 with no signal.

Regression tests added that fail pre-fix and pass post-fix:
- TestIsActiveCommitment_RevokedReturnsFalse
- TestHandler_calculateCommitmentMetrics_RevokedExcluded
- TestHandler_calculateCommitmentMetrics_ProviderFilter
- TestSummarizeRecommendationsWithCoverage_CurrentSavingsIsZero (renamed)
- TestSummarizePurchaseHistory_RevokedExcludedFromKPIs
cristim added a commit that referenced this pull request Jul 17, 2026
…rent_savings, provider-filter KPIs (adversarial-review follow-ups)

Defect #2 (HIGH): revoked/refunded commitments were counted as active on
every money path. Fix applies the predicate at three layers:
- SQL: add `revoked_at IS NULL` to GetActivePurchaseHistory WHERE clause
  (shared across dashboard KPIs, inventory, and analytics snapshots).
- In-memory defense-in-depth: isActiveCommitment now returns false when
  p.RevokedAt != nil, guarding callers that use GetPurchaseHistoryFiltered.
- summarizePurchaseHistory: add revoked branch that increments TotalRevoked
  and skips dollar totals, with TotalRevoked added to HistorySummary.

Defect #3 (HIGH): summarizeRecommendationsWithCoverage was setting
CurrentSavings = PotentialSavings in the reducer, fabricating realized
savings for services with no active commitments. Remove the spurious
`svc.CurrentSavings += scaled` line; CurrentSavings is exclusively
owned by the getDashboardSummary loop that overwrites from actual
purchase_history data. Update tests that asserted the buggy behavior.

Defect #4 (MEDIUM): calculateCommitmentMetrics ignored params["provider"],
mixing all-provider KPIs (ActiveCommitments, CommittedMonthly, YTDSavings,
CurrentSavings, CurrentCoverage) while PotentialMonthlySavings was already
filtered. Add provider parameter to calculateCommitmentMetrics and
fetchCommitmentPurchases; filter purchases in-memory after the SQL fetch,
mirroring fetchCommitmentRecords' existing pattern.

Defect #7 (LOW): fetchCommitmentPurchases swallowed GetActivePurchaseHistory
errors silently with only a comment. Now emits logging.Errorf so dashboard
tiles show "--" (zeroed KPIs) with a visible log trace rather than
fabricated $0 with no signal.

Regression tests added that fail pre-fix and pass post-fix:
- TestIsActiveCommitment_RevokedReturnsFalse
- TestHandler_calculateCommitmentMetrics_RevokedExcluded
- TestHandler_calculateCommitmentMetrics_ProviderFilter
- TestSummarizeRecommendationsWithCoverage_CurrentSavingsIsZero (renamed)
- TestSummarizePurchaseHistory_RevokedExcludedFromKPIs
cristim added a commit that referenced this pull request Jul 19, 2026
…rent_savings, provider-filter KPIs (adversarial-review follow-ups) (#1452)

* fix(api/dashboard): exclude revoked commitments, stop fabricating current_savings, provider-filter KPIs (adversarial-review follow-ups)

Defect #2 (HIGH): revoked/refunded commitments were counted as active on
every money path. Fix applies the predicate at three layers:
- SQL: add `revoked_at IS NULL` to GetActivePurchaseHistory WHERE clause
  (shared across dashboard KPIs, inventory, and analytics snapshots).
- In-memory defense-in-depth: isActiveCommitment now returns false when
  p.RevokedAt != nil, guarding callers that use GetPurchaseHistoryFiltered.
- summarizePurchaseHistory: add revoked branch that increments TotalRevoked
  and skips dollar totals, with TotalRevoked added to HistorySummary.

Defect #3 (HIGH): summarizeRecommendationsWithCoverage was setting
CurrentSavings = PotentialSavings in the reducer, fabricating realized
savings for services with no active commitments. Remove the spurious
`svc.CurrentSavings += scaled` line; CurrentSavings is exclusively
owned by the getDashboardSummary loop that overwrites from actual
purchase_history data. Update tests that asserted the buggy behavior.

Defect #4 (MEDIUM): calculateCommitmentMetrics ignored params["provider"],
mixing all-provider KPIs (ActiveCommitments, CommittedMonthly, YTDSavings,
CurrentSavings, CurrentCoverage) while PotentialMonthlySavings was already
filtered. Add provider parameter to calculateCommitmentMetrics and
fetchCommitmentPurchases; filter purchases in-memory after the SQL fetch,
mirroring fetchCommitmentRecords' existing pattern.

Defect #7 (LOW): fetchCommitmentPurchases swallowed GetActivePurchaseHistory
errors silently with only a comment. Now emits logging.Errorf so dashboard
tiles show "--" (zeroed KPIs) with a visible log trace rather than
fabricated $0 with no signal.

Regression tests added that fail pre-fix and pass post-fix:
- TestIsActiveCommitment_RevokedReturnsFalse
- TestHandler_calculateCommitmentMetrics_RevokedExcluded
- TestHandler_calculateCommitmentMetrics_ProviderFilter
- TestSummarizeRecommendationsWithCoverage_CurrentSavingsIsZero (renamed)
- TestSummarizePurchaseHistory_RevokedExcludedFromKPIs

* fix(api/types): correct "cancelled" misspelling to "canceled" in comment
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/many Affects most users priority/p1 Next up; this sprint severity/high Significant harm triaged Item has been triaged type/bug Defect urgency/now Drop other things

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants