diff --git a/tests/blackbox/executor.go b/tests/blackbox/executor.go index 68e315482..e5a299d98 100644 --- a/tests/blackbox/executor.go +++ b/tests/blackbox/executor.go @@ -1768,6 +1768,11 @@ func (h *TestHarness) executeTriggerDiscovery(t *testing.T, model *StateModel) { } t.Logf("TriggerDiscovery: inventory not converged (attempt %d)", attempt+1) } + if _, unmanagedInventory, err := h.extractManagedAndUnmanagedInventory(); err == nil { + for _, v := range CheckUnmanagedModelVsInventory(model, unmanagedInventory) { + t.Logf("TriggerDiscovery: unconverged: %s", v.Message) + } + } require.Failf(t, "discovery did not ingest", "expected unmanaged resources were not ingested after %d attempts", maxDiscoverAttempts) } @@ -1954,6 +1959,16 @@ func (h *TestHarness) waitForAbsorbedInventory(t *testing.T, query, nativeID, ex func (h *TestHarness) executeCloudCreate(t *testing.T, op *Operation, model *StateModel) { t.Helper() + // The generator draws native ids from a small pool, so an id can repeat + // within a sequence. Re-creating an entry the model already tracks as + // present is an out-of-band MODIFY of that resource, not a create: + // discovery only ingests unknown native ids, so it can never converge + // the model's fresh CloudProperties with the already-ingested row (that + // is sync absorption, which OpCloudModify exercises). Skip the op. + if res := model.UnmanagedResources[op.NativeID]; res != nil && (res.PresentInCloud || res.PresentInInventory) { + t.Logf("[op %d] CloudCreate: %s → skipped (already present out-of-band)", op.SequenceNum, op.NativeID) + return + } h.putCloudStateWithRetry(t, op.NativeID, op.ResourceType, op.Properties) model.ApplyUnmanagedCloudCreate(op.NativeID, op.ResourceType, op.Properties) t.Logf("[op %d] CloudCreate: %s (%s)", op.SequenceNum, op.NativeID, op.ResourceType) diff --git a/tests/blackbox/unmanaged_recreate_test.go b/tests/blackbox/unmanaged_recreate_test.go new file mode 100644 index 000000000..e47fed6b4 --- /dev/null +++ b/tests/blackbox/unmanaged_recreate_test.go @@ -0,0 +1,49 @@ +// © 2026 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +//go:build integration || property + +package blackbox + +import ( + "testing" + "time" + + "github.com/platform-engineering-labs/formae/internal/metastructure/testutil" + "github.com/stretchr/testify/require" +) + +// A CloudCreate that reuses the native id of an already-ingested unmanaged +// resource is skipped: discovery only ingests unknown native ids, so fresh +// CloudProperties expectations for a known id could never converge through +// the discovery path. After the skip, a discovery trigger must still +// converge cleanly. +func TestCloudCreate_RepeatedNativeIDIsSkipped(t *testing.T) { + testutil.RunTestFromProjectRoot(t, func(t *testing.T) { + h := NewTestHarness(t, 30*time.Second) + defer h.Cleanup() + h.ResetAgentState(t) + config := PropertyTestConfig{ResourceCount: 10, StackCount: 1} + model := NewStateModel(config.StackCount, config.ResourceCount) + h.SetupStacks(t, model, config) + + create := Operation{Kind: OpCloudCreate, NativeID: "cloud-1", + ResourceType: "Test::Generic::Resource", + Properties: `{"Name":"cp-1","Value":"v1","SetTags":[],"EntityTags":[],"OrderedItems":[]}`} + h.ExecuteOperation(t, &create, model) + h.executeTriggerDiscovery(t, model) + require.True(t, model.UnmanagedResources["cloud-1"].PresentInInventory) + ingested := model.UnmanagedResources["cloud-1"].CloudProperties + + recreate := Operation{Kind: OpCloudCreate, NativeID: "cloud-1", + ResourceType: "Test::Generic::Resource", + Properties: `{"Name":"cp-other","Value":"v2","SetTags":[],"EntityTags":[],"OrderedItems":[]}`} + h.ExecuteOperation(t, &recreate, model) + + require.Equal(t, ingested, model.UnmanagedResources["cloud-1"].CloudProperties, + "a repeated CloudCreate must not rewrite the tracked cloud properties") + require.True(t, h.waitForUnmanagedInventoryExpectations(t, model, 10*time.Second), + "the unmanaged inventory must still converge after the skipped re-create") + }) +}