Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
d8e52a2
test(cmd): move region and instance-type ApplyFilters tests to own file
cristim Oct 20, 2026
ce39eb1
test(cmd): move engine, account and combined ApplyFilters tests
cristim Oct 20, 2026
a16a035
test(cmd): move multi-service CSV report tests to own file
cristim Oct 20, 2026
24db797
test(cmd): move purchase loop failure and cancellation tests to own file
cristim Oct 20, 2026
bcb6009
test(cmd): move purchase loop dry-run, real and confirmation tests
cristim Oct 20, 2026
75541e9
test(cmd): move filter-and-adjust recommendations test to own file
cristim Oct 20, 2026
59250ae
test(cmd): move RunToolFromCSV basic tests to own file
cristim Oct 20, 2026
0ffaac9
test(cmd): move RunToolFromCSV max-instances, override and audit dry-…
cristim Oct 20, 2026
e904408
test(cmd): move audit log and confirmation CSV purchase tests
cristim Oct 20, 2026
6fd886d
test(cmd): move RunToolMultiService validation and ProcessService tests
cristim Oct 20, 2026
0a63227
test(cmd): move target-coverage RI tests to own file
cristim Oct 20, 2026
ab230e2
test(cmd): move target-coverage existing-coverage and SP tests
cristim Oct 20, 2026
3e2426f
test(cmd): move sizing and target-coverage guard tests
cristim Oct 20, 2026
dd82ae1
test(cmd): move account alias tests to own file
cristim Oct 20, 2026
1f8be32
test(cmd): move AdjustRecommendationsForExisting tests to own file
cristim Oct 20, 2026
05336f7
test(cmd): move duplicate-window and instance-limit tests out of help…
cristim Oct 20, 2026
0f34936
test(cmd): move GeneratePurchaseID tests to own file
cristim Oct 20, 2026
33699e4
test(cmd): copy GeneratePurchaseIDComprehensive test into own file
cristim Oct 20, 2026
2f4709d
test(cmd): remove original GeneratePurchaseIDComprehensive test from …
cristim Oct 20, 2026
2d8332b
test(cmd): move flag validation tests to own file
cristim Oct 20, 2026
bd10d01
test(cmd): move ParseServices tests to own file
cristim Oct 20, 2026
6858939
test(cmd): move service client and instance-type validation tests
cristim Oct 20, 2026
475fc66
test(cmd): move ShouldInclude region, instance-type and account tests
cristim Oct 20, 2026
93bd432
test(cmd): move ShouldInclude engine, pool-size and account match tests
cristim Oct 20, 2026
97eb1b4
test(cmd): move Savings Plans region filter tests to own file
cristim Oct 20, 2026
138a67f
test(cmd): move min-pool-size filter tests to own file
cristim Oct 20, 2026
7c60158
test(cmd): move ExtractMajorVersion comprehensive test to own file
cristim Oct 20, 2026
6fdcf60
test(cmd): move AdjustRecommendationForExcludedVersions variant tests
cristim Oct 20, 2026
02382cb
test(cmd): move base AdjustRecommendationForExcludedVersions test
cristim Oct 20, 2026
51a7edc
test(cmd): move CSV coverage column test to own file
cristim Oct 20, 2026
c3b35e9
test(cmd): move CSV sort, total row tests to own file
cristim Oct 20, 2026
8c6a226
test(cmd): move CSV formatter and extractor tests to own file
cristim Oct 20, 2026
522720d
test(cmd): move CSV load error and write failure tests
cristim Oct 20, 2026
e6b74f5
test(cmd): move max-instances CSV cap tests to own file
cristim Oct 20, 2026
4d986b2
test(cmd): move RunToolFromCSV min-count and ranking cap tests
cristim Oct 20, 2026
1e04b56
test(cmd): move CSV cap refusal and ordering tests
cristim Oct 20, 2026
1236020
test(cmd): move region discovery tests to own file
cristim Oct 20, 2026
8fd33db
test(cmd): move PopulateAccountNames test to own file
cristim Oct 20, 2026
5bda2f3
test(cmd): move account name logic and service determination tests
cristim Oct 20, 2026
d74d90b
test(cmd): move file path validation tests to own file
cristim Oct 20, 2026
cabcdb4
test(cmd): move coverage and lookback validation tests
cristim Oct 20, 2026
f0bf9d3
test(cmd): move extended support and excluded version coverage tests
cristim Oct 20, 2026
e9d57b0
test(cmd): move major engine version query coverage tests
cristim Oct 20, 2026
9313f16
test(cmd): move Azure credential store tests to own file
cristim Oct 20, 2026
57954b1
test(cmd): move GCP credential store tests to own file
cristim Oct 20, 2026
84d4112
test(cmd): move Archera key masking tests to own file
cristim Oct 20, 2026
7b18222
test(cmd): move PrintComparisonSection test to own file
cristim Oct 20, 2026
00e0f21
test(cmd): move extended support exclusion wiring test to own file
cristim Oct 20, 2026
2245935
test(cmd): move GCP create service account step tests to own file
cristim Oct 20, 2026
43f5add
test(cmd): drop allowlist entries for files that no longer hold the f…
cristim Oct 20, 2026
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
6 changes: 3 additions & 3 deletions .gitallowed
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,14 @@
^cmd/multi_service(_helpers)?\.go:[0-9]+:[[:space:]]*AppLogger\.Printf\("(\\n)?(━)+\\n"\)$
^cmd/configure_gcp_test\.go:[0-9]+:// --- addMemberToPolicyBinding -+$
^cmd/configure_test\.go:[0-9]+:[[:space:]]*assert\.Equal\(t, "-----BEGIN PRIVAT[E] KEY-----\\n\.\.\.", creds\.PrivateKey\)$
^cmd/configure_test\.go:[0-9]+:[[:space:]]*"private_key": "-----BEGIN PRIVAT[E] KEY-----\\nMIIEvQIBADANBg\.\.\.\\n-----END PRIVATE KEY-----\\n",$
^cmd/configure_gcp_store_test\.go:[0-9]+:[[:space:]]*"private_key": "-----BEGIN PRIVAT[E] KEY-----\\nMIIEvQIBADANBg\.\.\.\\n-----END PRIVATE KEY-----\\n",$
^cmd/configure_gcp_test\.go:[0-9]+:[[:space:]]*keyMaterial := \[\]byte\(`\{"type":"service_account","project_id":"proj","client_email":"sa@proj\.iam\.gserviceaccount\.com","private_key":"-----BEGIN PRIVAT[E] KEY-----\\nx\\n-----END PRIVATE KEY-----\\n"\}`\)$

# Synthetic 12-digit account-ID fixtures (sequential or repeated-digit
# blocks, none a real account) that the AWS account-ID detector flags in Go
# test tables. Each entry pins the path, the placeholder digits and the
# whole line, so anything else on the line is still scanned.
^cmd/(helpers_test|multi_service_filters_test|multi_service_test)\.go:[0-9]+:[[:space:]]*(accountID|Account):[[:space:]]*"(123456789012|999888777666|111222333444)",$
^cmd/multi_service_helpers_test\.go:[0-9]+:[[:space:]]*\{Account: "(123456789012|210987654321|111222333444|555666777888)", AccountName: ""\},( // Same account - should use cache)?$
^cmd/(multi_service_filters_test|multi_service_csv_report_test|helpers_account_alias_test|multi_service_should_include_test)\.go:[0-9]+:[[:space:]]*(accountID|Account):[[:space:]]*"(123456789012|999888777666|111222333444)",$
^cmd/multi_service_account_names_test\.go:[0-9]+:[[:space:]]*\{Account: "(123456789012|210987654321|111222333444|555666777888)", AccountName: ""\},( // Same account - should use cache)?$
^cmd/savings_plan_purchase_test\.go:[0-9]+:[[:space:]]*(Service: common\.ServiceSavingsPlansCompute, )?(Count: 1, )?Account: "11111111111[1]", Term: "1yr", PaymentOption: "no-upfront",$
^cmd/savings_plan_purchase_test\.go:[0-9]+:[[:space:]]*"account": func\(r \*common\.Recommendation\) \{ r\.Account = "22222222222[2]" \},$
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ repos:
- id: detect-private-key
name: Detect private keys
# Only these test fixtures embed PEM-shaped dummy keys; any other file is scanned.
exclude: '^(cmd/configure_test\.go|cmd/configure_gcp_test\.go)$'
exclude: '^(cmd/configure_test\.go|cmd/configure_gcp_test\.go|cmd/configure_gcp_store_test\.go)$'
- id: check-case-conflict
name: Check for case conflicts

Expand Down
206 changes: 206 additions & 0 deletions cmd/archera_comparison_masking_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
package main

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log"
"log/slog"
"net/http"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestArcheraErrorsMaskObfuscatedKey(t *testing.T) {
k := archeraTestKey
split := func(sep string) string { return k[:5] + sep + k[5:] }
pad := strings.Repeat("p", archeraMaxErrorSize-len("archera API returned HTTP 403: ")-len(k)/2)
cases := map[string]string{
"zero-width split": "bad key " + split("\u200b"),
"control split": "bad key " + split("\u0001"),
"bidi split": "bad key " + split("\u202e"),
"echoed twice": k + " and " + split("\u200b"),
"straddles the cap": pad + k,
"bidi isolate+RLO": "x\u202e\u2066y\u200b" + k,
}
for name, msg := range cases {
t.Run("http "+name, func(t *testing.T) {
setupArchera(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Retry-After", "30")
w.WriteHeader(403)
body, _ := json.Marshal(map[string]string{"message": msg})
_, _ = w.Write(body)
})
out, errOut, err := runArchera(t, fullEnv())
require.Error(t, err)
assert.NotContains(t, out+errOut+err.Error(), k)
assertArcheraNoControlChars(t, err.Error())
assert.LessOrEqual(t, len(err.Error()), archeraMaxErrorSize)
assert.Contains(t, err.Error(), "retry after 30s")
assert.NotContains(t, err.Error(), "\u202e")
})
}
t.Run("key as enum value in a decode error", func(t *testing.T) {
body := strings.Replace(archeraComparisonFixture(), `"payment_option":"no_upfront"`, `"payment_option":"`+split("\u200b")+`"`, 1)
setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) })
out, errOut, err := runArchera(t, fullEnv())
require.Error(t, err)
assert.NotContains(t, out+errOut+err.Error(), k)
// pkg/insurance no longer echoes the rejected value at all.
assert.Contains(t, err.Error(), "not shown")
})
t.Run("plain key as enum value", func(t *testing.T) {
body := strings.Replace(archeraComparisonFixture(), `"payment_option":"no_upfront"`, `"payment_option":"`+k+`"`, 1)
setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) })
_, _, err := runArchera(t, fullEnv())
require.Error(t, err)
assert.NotContains(t, err.Error(), k)
})
}

// archeraKeyHidingSeparators split the key with characters that survive the
// strip, so an exact-match mask misses a key the terminal still displays.
var archeraKeyHidingSeparators = map[string]string{
"variation selector 16": "\ufe0f",
"variation selector 17": "\U000e0100",
"combining grapheme join": "\u034f",
"combining acute": "\u0301",
"enclosing circle": "\u20dd",
"hangul choseong filler": "\u115f",
"hangul jungseong filler": "\u1160",
"hangul filler": "\u3164",
"halfwidth hangul filler": "\uffa0",
"space": " ",
"invalid utf-8": "\xff",
}

func archeraSplitEverywhere(k, sep string) string {
return strings.Join(strings.Split(k, ""), sep)
}

type archeraFailingTransport struct{ err error }

func (f archeraFailingTransport) RoundTrip(*http.Request) (*http.Response, error) { return nil, f.err }

func TestArcheraErrorsWithholdKeyHiddenBehindSurvivingCharacters(t *testing.T) {
k := archeraTestKey
checkMsg := func(t *testing.T, got string) {
t.Helper()
assert.NotContains(t, got, k)
assert.Contains(t, got, archeraWithheld)
assertArcheraNoControlChars(t, got)
assert.LessOrEqual(t, len(got), archeraMaxErrorSize)
}
for name, sep := range archeraKeyHidingSeparators {
for shape, echo := range map[string]string{
"split everywhere": archeraSplitEverywhere(k, sep),
"split once": k[:5] + sep + k[5:],
} {
msg := "bad key " + echo + " was rejected"
t.Run("http "+name+" "+shape, func(t *testing.T) {
setupArchera(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Retry-After", "30")
w.WriteHeader(403)
body, _ := json.Marshal(map[string]string{"message": msg})
_, _ = w.Write(body)
})
out, errOut, err := runArchera(t, fullEnv())
require.Error(t, err)
assert.NotContains(t, out+errOut, k)
checkMsg(t, err.Error())
assert.Contains(t, err.Error(), "HTTP 403")
assert.Contains(t, err.Error(), "retry after 30s")
})
t.Run("decode "+name+" "+shape, func(t *testing.T) {
body := strings.Replace(archeraComparisonFixture(), `"payment_option":"no_upfront"`, `"payment_option":`+mustJSON(t, echo), 1)
setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) })
out, errOut, err := runArchera(t, fullEnv())
require.Error(t, err)
assert.NotContains(t, out+errOut, k)
// pkg/insurance no longer echoes the rejected value, so there is
// nothing to withhold; the key must still not appear.
assert.NotContains(t, err.Error(), k)
assertArcheraNoControlChars(t, err.Error())
assert.LessOrEqual(t, len(err.Error()), archeraMaxErrorSize)
})
for kind, wrapped := range map[string]error{
"transport": errors.New(msg),
"deadline": fmt.Errorf("%s: %w", msg, context.DeadlineExceeded),
"canceled": fmt.Errorf("%s: %w", msg, context.Canceled),
} {
t.Run(kind+" "+name+" "+shape, func(t *testing.T) {
setupArchera(t, okHandler)
archeraHTTPClient = &http.Client{Transport: archeraFailingTransport{err: wrapped}}
out, errOut, err := runArchera(t, fullEnv())
require.Error(t, err)
assert.NotContains(t, out+errOut, k)
checkMsg(t, err.Error())
})
}
}
}
}

func mustJSON(t *testing.T, v string) string {
t.Helper()
b, err := json.Marshal(v)
require.NoError(t, err)
return string(b)
}

// The mask must run before the cap: a key cut by the cap boundary would
// otherwise survive as a readable prefix. Sweep the boundary and assert no
// 4+ byte prefix of the key remains, for a plain and a strip-split key.
func TestArcheraErrorsLeaveNoKeyPrefixAtCap(t *testing.T) {
k := archeraTestKey
for _, echo := range []string{k, k[:5] + "\u200b" + k[5:], archeraSplitEverywhere(k, "\u200b")} {
for padLen := archeraMaxErrorSize - len(k) - 8; padLen <= archeraMaxErrorSize+2; padLen++ {
err := archeraSafeError(errors.New(strings.Repeat("p", padLen)+echo), k)
got := err.Error()
for n := 4; n <= len(k); n++ {
for start := 0; start+n <= len(k); start++ {
require.NotContains(t, got, k[start:start+n], "pad %d, fragment %q", padLen, k[start:start+n])
}
}
}
}
}

// The withhold check must see the full text: a cut through a split key would
// hide it from a check run after the cap while leaving a readable prefix.
func TestArcheraErrorsWithholdSplitKeyAtAnyCapOffset(t *testing.T) {
k := archeraTestKey
for name, sep := range map[string]string{"variation selector": "\ufe0f", "invalid utf-8": "\xff", "combining acute": "\u0301"} {
for padLen := archeraMaxErrorSize - 3*len(k); padLen <= archeraMaxErrorSize+2; padLen++ {
got := archeraSafeError(errors.New(strings.Repeat("p", padLen)+archeraSplitEverywhere(k, sep)), k).Error()
require.Equal(t, archeraWithheld, got, "%s pad %d", name, padLen)
}
}
}

func TestArcheraErrorsTrimKeyBeforeMasking(t *testing.T) {
got := archeraSafeError(errors.New("bad key "+archeraTestKey[:5]+"\u200b"+archeraTestKey[5:]), " "+archeraTestKey+"\n").Error()
assert.Equal(t, "bad key [redacted]", got)
}

// A log or slog write of the key would bypass os.Stderr captured per test, so
// route both default loggers into the capture for the whole run.
func TestArcheraRunDoesNotLogKey(t *testing.T) {
var logs bytes.Buffer
savedLog, savedFlags := log.Writer(), log.Flags()
log.SetOutput(&logs)
savedSlog := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil)))
t.Cleanup(func() { log.SetOutput(savedLog); log.SetFlags(savedFlags); slog.SetDefault(savedSlog) })
setupArchera(t, okHandler)
_, _, err := runArchera(t, fullEnv())
require.NoError(t, err)
setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(403) })
_, _, _ = runArchera(t, fullEnv())
assert.NotContains(t, logs.String(), archeraTestKey)
}
Loading
Loading