Skip to content

Commit 2c5e88b

Browse files
Merge pull request #37 from jjuanrivvera/fix/emit-cache-and-edge-cases
fix: harden the emit idempotency cache and surface silent truncations
2 parents cc3fd36 + c15f85d commit 2c5e88b

24 files changed

Lines changed: 698 additions & 62 deletions

CONTRIBUTING.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,11 @@ A resource needs three files and no edits to shared code:
5858
```
5959

6060
3. `internal/api/<resource>_test.go` — an httptest-based service test (reuse the
61-
`newTestClient` helper).
61+
`newTestClient` helper). Test what is **unique** to the resource — special
62+
field types, custom actions, odd response shapes — not the generic CRUD
63+
plumbing, which is already covered once by the `Resource[T]` tests
64+
(`resource_test.go`, `client_failures_test.go`). A List/Get happy-path pair
65+
adds volume, not signal.
6266

6367
Custom actions (e.g. `void`, `email`) go through the `Extra` hook using
6468
`NewActionCmd` / `NewCollectionActionCmd`. Non-CRUD resources (singletons,
@@ -82,6 +86,16 @@ reports) build a plain cobra command using `client.GetInto/PostInto/PutInto`.
8286
`internal/api`).
8387
- Prefer `require` for fatal assertions, `assert` for the rest.
8488
- Keep coverage healthy — the suite sits above 80%; new code should ship tests.
89+
- **Test failure paths, not just happy paths.** Every parse of external state
90+
(API bodies, config files, caches) needs a test with corrupt input; every
91+
batch operation needs a partial-failure test asserting counts and a non-zero
92+
exit. Coverage measures execution, not assertion quality — a swallowed error
93+
can be 100% "covered" and still hide a bug.
94+
- The flexible JSON types have fuzzers with value-level properties
95+
(`internal/api/fuzz_test.go`); run them after touching a decoder:
96+
`go test ./internal/api -fuzz '^FuzzID$' -fuzztime 30s` (likewise `FuzzInt`,
97+
`FuzzMoney`, `FuzzStringOrSlice`). Counterexamples land in `testdata/fuzz/`
98+
and become permanent regression cases — commit them.
8599

86100
## Reporting bugs & security issues
87101

commands/auth.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,10 @@ func newAuthStatusCmd() *cobra.Command {
109109
if err != nil {
110110
return err
111111
}
112-
cfg, _ := config.Load()
112+
cfg, err := config.Load()
113+
if err != nil {
114+
return err
115+
}
113116
profile := cfg.ActiveProfileName(flagProfile)
114117
r := cfg.Resolve(profile)
115118
out := cmd.OutOrStdout()

commands/country.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,7 @@ func cacheCountry(cfg *config.Config, profile, country string) {
3636
}
3737
p.Country = country
3838
cfg.SetProfile(p)
39+
// Best-effort by design: the cache only saves a future detection call, so
40+
// a failed write must never break the command that triggered it.
3941
_ = cfg.Save()
4042
}

commands/emit.go

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package commands
22

33
import (
44
"encoding/json"
5+
"errors"
56
"fmt"
67
"os"
78
"path/filepath"
@@ -60,9 +61,14 @@ emission is NOT idempotent on Alegra's side, so this prevents duplicates.`,
6061
return fmt.Errorf("no invoices to emit (pass ids or --all)")
6162
}
6263

63-
// 2. Idempotency guard.
64+
// 2. Idempotency guard. A cache that exists but cannot be read is a
65+
// hard stop: proceeding with an empty guard could re-emit invoices
66+
// that were already stamped (a fiscal duplicate, not undoable).
6467
profile := currentProfileName()
65-
cache, _ := loadEmitCache()
68+
cache, cerr := loadEmitCache()
69+
if cerr != nil {
70+
return fmt.Errorf("cannot read the emission idempotency cache: %w\nInspect or remove %s, then retry", cerr, emitCachePath())
71+
}
6672
todo, skipped := filterEmitted(ids, cache, profile, force)
6773
out := cmd.OutOrStdout()
6874
if len(skipped) > 0 {
@@ -92,9 +98,14 @@ emission is NOT idempotent on Alegra's side, so this prevents duplicates.`,
9298
emitted++
9399
}
94100
fmt.Fprintf(out, "stamped: %v\n", batch)
101+
// Persist after every batch, and stop on failure: stamping more
102+
// invoices the guard cannot record would risk re-emission on the
103+
// next run.
104+
if werr := saveEmitCache(cache); werr != nil {
105+
return fmt.Errorf("stamped %v but could not record them in the idempotency cache: %w\nRecord these ids in %s before re-running, or a re-run may emit them twice", batch, werr, emitCachePath())
106+
}
95107
}
96108
if !flagDryRun {
97-
_ = saveEmitCache(cache)
98109
fmt.Fprintf(out, "Emitted %d invoice(s); %d batch(es) failed.\n", emitted, failedChunks)
99110
}
100111
if failedChunks > 0 {
@@ -146,26 +157,54 @@ func emitCachePath() string {
146157
return filepath.Join(filepath.Dir(config.DefaultPath()), "emitted.json")
147158
}
148159

160+
// loadEmitCache reads the emitted-ids cache. A missing file is a fresh start;
161+
// an unreadable or corrupt file is an error — silently treating it as empty
162+
// would drop the idempotency guard and allow double emission.
149163
func loadEmitCache() (map[string]bool, error) {
150164
cache := map[string]bool{}
151165
data, err := os.ReadFile(emitCachePath()) //nolint:gosec // path under config dir
166+
if errors.Is(err, os.ErrNotExist) {
167+
return cache, nil
168+
}
152169
if err != nil {
153-
return cache, err
170+
return nil, err
171+
}
172+
if err := json.Unmarshal(data, &cache); err != nil {
173+
return nil, fmt.Errorf("corrupt cache %s: %w", emitCachePath(), err)
154174
}
155-
_ = json.Unmarshal(data, &cache)
156175
return cache, nil
157176
}
158177

178+
// saveEmitCache persists atomically (write temp + rename): a crash mid-write
179+
// must never leave a torn emitted.json, which would read as corrupt and block
180+
// (or, worse, lose) the guard.
159181
func saveEmitCache(cache map[string]bool) error {
160182
path := emitCachePath()
161-
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
183+
dir := filepath.Dir(path)
184+
if err := os.MkdirAll(dir, 0o700); err != nil {
162185
return err
163186
}
164187
data, err := json.Marshal(cache)
165188
if err != nil {
166189
return err
167190
}
168-
return os.WriteFile(path, data, 0o600)
191+
tmp, err := os.CreateTemp(dir, "emitted-*.json")
192+
if err != nil {
193+
return err
194+
}
195+
defer func() { _ = os.Remove(tmp.Name()) }() // no-op once renamed
196+
if err := tmp.Chmod(0o600); err != nil {
197+
_ = tmp.Close()
198+
return err
199+
}
200+
if _, err := tmp.Write(data); err != nil {
201+
_ = tmp.Close()
202+
return err
203+
}
204+
if err := tmp.Close(); err != nil {
205+
return err
206+
}
207+
return os.Rename(tmp.Name(), path)
169208
}
170209

171210
// currentProfileName returns the active profile name for cache scoping.

commands/failures_test.go

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
package commands
2+
3+
import (
4+
"encoding/json"
5+
"io"
6+
"net/http"
7+
"net/http/httptest"
8+
"os"
9+
"path/filepath"
10+
"runtime"
11+
"strings"
12+
"sync/atomic"
13+
"testing"
14+
15+
"github.com/stretchr/testify/assert"
16+
"github.com/stretchr/testify/require"
17+
"github.com/zalando/go-keyring"
18+
19+
"github.com/jjuanrivvera/alegra-cli/internal/config"
20+
)
21+
22+
// These tests exercise the failure paths the integration suite never hit: the
23+
// fake servers previously only returned 200/404, so partial batch failures,
24+
// API errors mid-operation, and persistence failures were all untested.
25+
26+
// failureTestEnv points the CLI at srv with env-only credentials and an
27+
// isolated config dir, returning that dir.
28+
func failureTestEnv(t *testing.T, srv *httptest.Server) string {
29+
t.Helper()
30+
dir := t.TempDir()
31+
t.Setenv(config.EnvBaseURL, srv.URL)
32+
t.Setenv(config.EnvEmail, "e@x.com")
33+
t.Setenv(config.EnvToken, "tok")
34+
t.Setenv(config.EnvProfile, "")
35+
t.Setenv(config.EnvConfig, filepath.Join(dir, "config.yaml"))
36+
return dir
37+
}
38+
39+
func TestImport_PartialFailureFailsLoudly(t *testing.T) {
40+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
41+
w.Header().Set("Content-Type", "application/json")
42+
body, _ := io.ReadAll(r.Body)
43+
var m map[string]any
44+
_ = json.Unmarshal(body, &m)
45+
if m["name"] == "Bad" {
46+
w.WriteHeader(http.StatusBadRequest)
47+
_, _ = w.Write([]byte(`{"message":"name is invalid","code":400}`))
48+
return
49+
}
50+
_, _ = w.Write([]byte(`{"id":"9","name":"ok"}`))
51+
}))
52+
t.Cleanup(srv.Close)
53+
dir := failureTestEnv(t, srv)
54+
55+
csvFile := filepath.Join(dir, "rows.csv")
56+
require.NoError(t, os.WriteFile(csvFile, []byte("name\nGood\nBad\nAlsoGood\n"), 0o600))
57+
58+
out, err := runRoot(t, "contacts", "import", "-f", csvFile)
59+
// A partial import must fail the command (exit code), report which row
60+
// broke, and still account for the rows that were created.
61+
require.Error(t, err, "partial failure must not look like success to pipelines")
62+
assert.Contains(t, err.Error(), "1 row(s) failed")
63+
assert.Contains(t, out, "[row 2] FAILED")
64+
assert.Contains(t, out, "Imported 2, failed 1")
65+
}
66+
67+
func TestEmit_BatchAPIErrorFailsAndKeepsCacheClean(t *testing.T) {
68+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
69+
w.Header().Set("Content-Type", "application/json")
70+
if strings.HasSuffix(r.URL.Path, "/invoices/stamp") {
71+
w.WriteHeader(http.StatusBadRequest) // 4xx: not retried, fails fast
72+
_, _ = w.Write([]byte(`{"message":"EPR001: certificado vencido"}`))
73+
return
74+
}
75+
_, _ = w.Write([]byte(`{}`))
76+
}))
77+
t.Cleanup(srv.Close)
78+
dir := failureTestEnv(t, srv)
79+
80+
out, err := runRoot(t, "invoices", "emit", "7", "--force")
81+
require.Error(t, err)
82+
assert.Contains(t, err.Error(), "1 batch(es) failed")
83+
assert.Contains(t, out, "FAILED")
84+
85+
// A failed batch must never be recorded as emitted.
86+
data, rerr := os.ReadFile(filepath.Join(dir, "emitted.json"))
87+
if rerr == nil {
88+
assert.NotContains(t, string(data), ":7", "failed invoice must not be marked emitted")
89+
}
90+
}
91+
92+
func TestEmit_CacheSaveFailureStopsEmission(t *testing.T) {
93+
if runtime.GOOS == "windows" {
94+
t.Skip("read-only directory permissions are not enforced on Windows")
95+
}
96+
var stamps int32
97+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
98+
w.Header().Set("Content-Type", "application/json")
99+
if strings.HasSuffix(r.URL.Path, "/invoices/stamp") {
100+
atomic.AddInt32(&stamps, 1)
101+
_, _ = w.Write([]byte(`{"stamped":true}`))
102+
return
103+
}
104+
_, _ = w.Write([]byte(`{}`))
105+
}))
106+
t.Cleanup(srv.Close)
107+
dir := failureTestEnv(t, srv)
108+
109+
// Make the config dir unwritable so the post-batch cache save fails.
110+
require.NoError(t, os.Chmod(dir, 0o500))
111+
t.Cleanup(func() { _ = os.Chmod(dir, 0o700) })
112+
113+
// 12 ids → 2 batches. The save failure after batch 1 must stop the run
114+
// before batch 2 is stamped, and the error must name the affected ids.
115+
ids := []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"}
116+
out, err := runRoot(t, append([]string{"invoices", "emit", "--force"}, ids...)...)
117+
require.Error(t, err)
118+
assert.Contains(t, err.Error(), "could not record them in the idempotency cache")
119+
assert.Contains(t, err.Error(), "1") // affected ids are listed
120+
assert.Contains(t, out, "stamped")
121+
assert.Equal(t, int32(1), atomic.LoadInt32(&stamps), "must stop stamping once the guard cannot record progress")
122+
}
123+
124+
func TestDelete_AbortsWithoutConfirmation(t *testing.T) {
125+
var deletes int32
126+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
127+
w.Header().Set("Content-Type", "application/json")
128+
if r.Method == http.MethodDelete {
129+
atomic.AddInt32(&deletes, 1)
130+
}
131+
_, _ = w.Write([]byte(`{}`))
132+
}))
133+
t.Cleanup(srv.Close)
134+
failureTestEnv(t, srv)
135+
136+
// Piped/empty stdin without --yes: the prompt cannot be answered, so the
137+
// delete must abort — and the DELETE request must never be sent.
138+
rootCmd.SetIn(strings.NewReader(""))
139+
t.Cleanup(func() { rootCmd.SetIn(nil) })
140+
141+
_, err := runRoot(t, "contacts", "delete", "1")
142+
require.Error(t, err)
143+
assert.Contains(t, err.Error(), "aborted")
144+
assert.Zero(t, atomic.LoadInt32(&deletes), "no DELETE may be sent without confirmation")
145+
}
146+
147+
func TestAuthLogin_NeverPersistsTokenToConfig(t *testing.T) {
148+
keyring.MockInit()
149+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
150+
w.Header().Set("Content-Type", "application/json")
151+
_, _ = w.Write([]byte(`{"name":"Tester","applicationVersion":"colombia"}`))
152+
}))
153+
t.Cleanup(srv.Close)
154+
dir := failureTestEnv(t, srv)
155+
t.Setenv(config.EnvToken, "") // force login to use the flag token
156+
157+
const secret = "super-secret-token"
158+
_, err := runRoot(t, "auth", "login", "--email", "a@x.com", "--token", secret, "--profile", "p1")
159+
require.NoError(t, err)
160+
161+
// The token belongs in the keyring only; the YAML must never contain it.
162+
data, rerr := os.ReadFile(filepath.Join(dir, "config.yaml"))
163+
require.NoError(t, rerr)
164+
assert.NotContains(t, string(data), secret, "plaintext token leaked into config.yaml")
165+
}

commands/generic.go

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,12 @@ func buildResourceCmd[T any](sp resourceSpec[T]) *cobra.Command {
131131

132132
// --- list ---
133133

134+
// droppedListFilters records resource filters that were skipped at registration
135+
// because of an empty definition or a flag collision. The CLI must never panic
136+
// at init over a bad resource definition, so the mistake is surfaced by a
137+
// registry test (TestNoListFiltersAreSilentlyDropped) failing CI instead.
138+
var droppedListFilters []string
139+
134140
// reservedListFlags are the built-in flag names on every `list` subcommand;
135141
// resource-specific filters that collide with these are skipped.
136142
var reservedListFlags = map[string]bool{
@@ -245,9 +251,15 @@ func newListCmd[T any](sp resourceSpec[T]) *cobra.Command {
245251
fs.StringVar(&lf.orderDir, "order-direction", "", "Sort direction: ASC or DESC")
246252
// Register resource-specific filters, skipping any that would collide with a
247253
// built-in list flag or a previously declared filter (defensive: a bad
248-
// resource definition must never panic the whole CLI at init).
254+
// resource definition must never panic the whole CLI at init). Skips are
255+
// recorded so the registry test fails CI instead of losing filters silently.
249256
for _, f := range sp.ListFilters {
250-
if f.Flag == "" || f.Query == "" || reservedListFlags[f.Flag] || fs.Lookup(f.Flag) != nil {
257+
if f.Flag == "" || f.Query == "" {
258+
droppedListFilters = append(droppedListFilters, fmt.Sprintf("%s: filter %+v has an empty flag or query", sp.Use, f))
259+
continue
260+
}
261+
if reservedListFlags[f.Flag] || fs.Lookup(f.Flag) != nil {
262+
droppedListFilters = append(droppedListFilters, fmt.Sprintf("%s: --%s collides with a built-in or duplicate flag", sp.Use, f.Flag))
251263
continue
252264
}
253265
filterVals[f.Query] = fs.String(f.Flag, "", f.Usage)
@@ -415,18 +427,21 @@ failures are reported and do not stop the run.`,
415427
setDotPath(body, field, inferValue(cell))
416428
}
417429
if flagDryRun {
418-
raw, _ := json.Marshal(body)
430+
raw, merr := json.Marshal(body)
431+
if merr != nil {
432+
failed++
433+
fmt.Fprintf(cmd.ErrOrStderr(), "[row %d] FAILED: cannot encode body: %v\n", i+1, merr)
434+
continue
435+
}
419436
fmt.Fprintf(out, "[row %d] would create: %s\n", i+1, raw)
420437
continue
421438
}
422-
item, cerr := res.Create(cmd.Context(), body)
423-
if cerr != nil {
439+
if _, cerr := res.Create(cmd.Context(), body); cerr != nil {
424440
failed++
425441
fmt.Fprintf(cmd.ErrOrStderr(), "[row %d] FAILED: %v\n", i+1, cerr)
426442
continue
427443
}
428444
created++
429-
_ = item
430445
fmt.Fprintf(out, "[row %d] created\n", i+1)
431446
}
432447
if !flagDryRun {
@@ -541,7 +556,10 @@ func newCreateCmd[T any](sp resourceSpec[T]) *cobra.Command {
541556
if draft {
542557
if m, ok := bodyToMap(body); ok {
543558
delete(m, "stamp")
544-
body, _ = json.Marshal(m)
559+
body, err = json.Marshal(m)
560+
if err != nil {
561+
return fmt.Errorf("re-encoding body after stripping stamp: %w", err)
562+
}
545563
}
546564
}
547565
if !noValidate {

commands/generic_more_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,11 @@ func TestConfirm(t *testing.T) {
8787
assert.False(t, confirm(cmd, "proceed"), "input %q", in)
8888
}
8989
}
90+
91+
// Every registered resource builds its list command at init, so by the time
92+
// tests run any filter dropped over an empty definition or a flag collision is
93+
// already recorded. An entry here means a resource silently lost a filter —
94+
// rename the flag in the resource definition.
95+
func TestNoListFiltersAreSilentlyDropped(t *testing.T) {
96+
assert.Empty(t, droppedListFilters)
97+
}

0 commit comments

Comments
 (0)