hostclaim: Update refuses a non-absent observation with no ETag (C5a)
Co-Authored-By: Codex (gpt-6-astra) <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+32
−0
internal/hostclaim/store.go+3 −0internal/hostclaim/store_test.go+29 −0
diff --git a/internal/hostclaim/store.go b/internal/hostclaim/store.go| index ac05ddb33..b421bab85 100644 |
| --- a/internal/hostclaim/store.go |
| +++ b/internal/hostclaim/store.go |
| @@ -70,6 +70,9 @@ func Update(ctx context.Context, s release.Store, obs Observed, tr func(*Claim) |
| if obs.Absent { |
| next = &Claim{V: schemaVersion, Host: obs.Host} |
| } else { |
| + if obs.ETag == "" { |
| + return Observed{}, fmt.Errorf("%w: non-absent observation without ETag", ErrMalformed) |
| + } |
| if obs.Claim == nil || obs.Claim.Host != obs.Host { |
| return Observed{}, fmt.Errorf("%w: observation not bound to %s", ErrMalformed, obs.Host) |
| } |
diff --git a/internal/hostclaim/store_test.go b/internal/hostclaim/store_test.go| index 1485cf956..e9f4dd3a3 100644 |
| --- a/internal/hostclaim/store_test.go |
| +++ b/internal/hostclaim/store_test.go |
| @@ -62,3 +62,32 @@ func TestUpdateDerivesGenAndBindsHost(t *testing.T) { |
| t.Fatalf("stale etag write = %v, want ErrConflict", err) |
| } |
| } |
| + |
| +func TestUpdateRejectsNonAbsentObservationWithoutETag(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + obs := Observed{ |
| + Host: testHost, |
| + Claim: &Claim{V: 2, Host: testHost, Gen: 1}, |
| + Absent: false, |
| + ETag: "", |
| + } |
| + if err := obs.Claim.Validate(); err != nil { |
| + t.Fatalf("invalid test claim: %v", err) |
| + } |
| + transitionCalled := false |
| + _, err := Update(ctx, s, obs, func(c *Claim) error { |
| + transitionCalled = true |
| + return nil |
| + }) |
| + if !errors.Is(err, ErrMalformed) { |
| + t.Errorf("Update = %v, want ErrMalformed", err) |
| + } |
| + if transitionCalled { |
| + t.Error("transition called for observation without ETag") |
| + } |
| + after, err := Read(ctx, s, testHost) |
| + if err != nil || !after.Absent { |
| + t.Errorf("read after rejected update = %+v, %v; want absent", after, err) |
| + } |
| +} |