Skip to content

feat(conform): add opt-in group mutation test - #118

Open
thirschel-microsoft wants to merge 7 commits into
xregistry:masterfrom
thirschel:thirschel-microsoft-conform-group-mutation
Open

thirschel-microsoft wants to merge 7 commits into
xregistry:masterfrom
thirschel:thirschel-microsoft-conform-group-mutation

Conversation

@thirschel-microsoft

Copy link
Copy Markdown
Contributor

Summary

  • Add an explicitly authorized mutations conformance run that exercises one ordinary Group create/read/delete lifecycle.
  • Require --allow-mutations and exactly one target before any network request; existing default, all, smoke, and entities runs remain read-only.
  • Gate the lifecycle on verified capabilities and model support, using a unique run-owned Group ID and ownership label.
  • Protect cleanup by requiring a verified creation epoch, then re-checking ownership, emptiness, and the unchanged epoch before deletion. Unknown or residual data is reported rather than deleted.
  • Add focused lifecycle, cleanup-failure, CLI safety, text/JSON output, and help documentation coverage.

Testing

  • go test -count=1 ./cmds/xr ./cmds/xr/xrlib
  • go test -count=1 ./tests -run '^(TestXRConform.*|TestGroupCreateIgnoresEpochAndCollisionPreservesExisting)$'
  • Repeated both focused scopes with -race
  • make docs xrlint errors
  • XR_SPEC=/tmp/xregistry-spec-pinned make -B xrlint errors using spec snapshot ffac2f5ad77bd86db01fa8fca5866d0d3d8a761c
  • Verified generated files remained unchanged and all modified Go files are gofmt clean

@duglin

duglin commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

@thirschel-microsoft can you rebase? I find it kind of disturbing that github shows lots of old code in your files when I expand them - it'll probably just merge them but the latest changes I merged are so big I'd prefer to double-check.

Comment thread cmds/xr/conform.go Outdated
}
}

func conformMutationSelected(names []string) bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You can replace calls to this with calls to common.ArrayContains()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done

Comment thread cmds/xr/td_registry.go Outdated
nTD = NewTD(td, "Verifying model MUST work")
nTD.NoError(model.Verify())
verifyErr := model.Verify()
nTD.NoError(verifyErr)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's just use NoErrorStop() instead and then we can remove the "if" around the reg.Model = model

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done

Comment thread cmds/xr/td_registry.go Outdated
td.Skip()
return
}
if !reg.Capabilities.FlagEnabled("epoch") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Seems like we could still test delete even w/o the ?epoch flag... both w/o any epoch checking and using the http body instead of the query param

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah thats fair. I removed the ?epoch requirement.

Comment thread cmds/xr/td_registry.go Outdated
td.Skip()
return
}
if reg.Model == nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Shouldn't the td.DependsOn(TestModel) prevent the need for this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right. Removed this check

Comment thread cmds/xr/td_registry.go Outdated
}))

preflightTD := NewTD(td, "Confirm mutation destination is absent")
preflightRes, _ := reg.HttpDoNoRedirect(VerboseCount > 2, "GET", path, nil)

@duglin duglin Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why did you want to not allow a redirect? It's a bit odd, but it's possible that someone is hosting an xReg thru some kind of proxy/shadow site and they blindly do a redirect based on the URLs, and so if the redirect fails point to a non-existing entity then that'll return a 404 after we get the 3xx, yielding the same net result, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

My concern was something replaying the write. I haven't thought about all these different xreg setups but Im open to thoughts here

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think that's out of our control/concern - as long as we generate the proper number of "writes", we then need to react to the server just like any other client would. Which means, follow the redirect that the server told us to.

Comment thread cmds/xr/td_registry.go Resolved
Comment thread tests/xr_test.go
├─ PASS: Create run-owned Group
├─ PASS: Read run-owned Group
└─ FAIL: Clean up run-owned Group
├─ FAIL: Cleanup GET MUST return 200 or 404

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we show what it did return to help people debug it a little?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think using td.Log() will help since those should only show on failure.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah td.Log() makes sense. I added some td.Log() logs

Comment thread tests/xr_test.go Outdated
└─ PASS: TestTDGroupCreateReadDelete (skip:1)
├─ PASS: TestModel
├─ PASS: TestCapabilities (skip:1)
├─ PASS: Confirm mutation destination is absent

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

perhaps instead of "Confirm mutation destination is absent", we could make it "Confirm target Group is absent" to be a bit more user friendly?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done

Comment thread tests/xr_test.go Outdated
owner := ""
groupGets := 0
deletes := 0
server := httptest.NewServer(http.HandlerFunc(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm not 100% sure I'm following this but it looks like you're introducing a proxy to watch the http flows between td and the server so you can verify something - a few comments would help. But, I'm trying to understand why we need this when I would think the td stuff would need to do similar validation and then fail if the bits you're looking for are not correct, no? I assume the if groupGets == 2 is critical in what's going on, but it's not clear to me how it all fits in - I'm guessing it's related to testing the deletion of the Group not happening. And if that's all that's going on, I think comments would help the next person reading this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This uses the real server but I made it so the second GET fails. That acts as the check before deleting the group so the test makes sure we leave it alone when we can’t verify ownership. Added a comment to explain that.

Comment thread cmds/xr/td_registry.go Outdated
return "", nil
}

func conformMutationGroupState(body []byte, groupModel *xrlib.GroupModel,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

With the commit I merged today I'm on the verge of allowing for all of the server code to be available to the client (w/o the mysql and http layers). A fair amount of it is actually there already due to me moving it into the "shared_xxx" files in common. One of the reasons I'm headed this direction is to allow us to call the verification funcs that the server defines but from the client, in order to avoid duplication of logic which is what I think the funcs below is starting to do. IOW, I'd like to see if we can get to the point where we can just have things like:

xErr := group.Validate()

in our client code for instances and:

xErr := model.Verify()

for model stuff. That should handle most of the checks. Some of them (e.g. COLLECTION* attrs) might need new code, but I think it would be good to have that logic live outside of the TD stuff in case we want to make it available to other apps that want to use our stuff as an SDK (another long-term goal of mine).

From a "registry" dir perspective I think the code is ready. But I need to tweak some client-side (xrlib) code first to remove duplication and other things first. I'm hoping to get that done over the next day or so. Then all of the common/shared_xxx files will be moved back into "registry" and the cli will then be able to import it as a shared package. "common" can then return to being just a package for utils.

@duglin

duglin commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

I know it's super anal, but I think in the long run it's easier for new people when they join... but can you use "dirs" and "files" in the model instead of "widgets"? Unless there's a specific reason to deviate, I think eliminating one extra thing to think about (e.g. are widgets groups or resources? or to have a changing model names per new dev) eases the learning curve.

@thirschel-microsoft
thirschel-microsoft force-pushed the thirschel-microsoft-conform-group-mutation branch from 888efb7 to dad0cc3 Compare October 20, 2026 19:11
Comment thread registry/registry.go Outdated
if isNew {
// A create request must silently ignore any supplied epoch value.
delete(obj, "epoch")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think it would be better to solve this generically by modifying RemoveReadOnlyImmutable like this:

-                       continue
+
+                       // however, there is one very special case for "epoch".
+                       // On create, delete it no matter what. In edge cases where
+                       // it's not even a uint we don't want to see a "bad type" error.
+                       // e.OriginObj being empty is a sign that it's new.
+                       if key != "epoch" || len(e.OriginObject) != 0 {
+                               continue
+                       }

at line 774 (the if-statement that treats epoch as special). This will work for all entity types.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You can then add an XHTTP call to the end of TestHTTPEpoch() to do: PUT /?inline=* with a full tree (new dirs/files/versions) specifying "epoch": "foo" for all (except registry) and verify no error was generated and epoch is 1 for all except registry.

@thirschel-microsoft
thirschel-microsoft force-pushed the thirschel-microsoft-conform-group-mutation branch from dad0cc3 to 99ddd74 Compare October 23, 2026 16:28
Comment thread registry/entity.go
if key != "epoch" || e.OriginObject == nil ||
len(e.OriginObject) != 0 {
continue
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I did some checking to see why this isn't set in all cases and I decided that only setting it when we edit the entity isn't good - it should be set all the time so the calling code doesn't need to think about it. I create a new PR for the fix: #122 and will merge it tomorrow once I double check it. I ran your new tests and they work but I'll leave those in your PR since you wrote them. I'll let you know when you can rebase.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

merged. I'll try to complete the review of the PR later today

@duglin duglin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I've only had time to look at the code related to the "server testing" itself, not the "td logic testing" yet, but wanted to send what I have so far....

Comment thread cmds/xr/conform.go Outdated
Error("--run mutations requires --allow-mutations")
}
if len(servers) != 1 {
Error("--run mutations requires exactly one target")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Any reason we can't run the mutations on each server?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fair point. removed this restriction

Comment thread cmds/xr/td_registry.go Outdated
}
groupType, groupModel := conformMutationGroupModel(reg.Model)
if groupModel == nil {
td.Skip("No Group Type supports run-owned creation - leaving")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I tried to run this against the server immediately after I built it and I got this skip msg. Took me a few seconds to realize why... I had no model defined at all. It would be nice if the output told the user what we're looking for to make this determination, or what was missing, so they know what to change to enable it. I think some td.Msg lines before the td.Skip here would be sufficient for the general requirements... or in conformMutationGroupModel if we can be more specific about what's missing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I added some diagnostic output that should give better hints to the user about what they are missing in these cases

Comment thread cmds/xr/td_registry.go Outdated
}
}

func TestTDGroupCreateReadDelete(td *TD) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

let's rename this to TestMutations to align with the --run flag.
While we're at it, rename TestTDAll to just TestAll.
Actually let's remove the "TD" from all user-facing tests (and keep TestTD for TD testing funcs). I never intended for the TestTD funcs to be visible to the user, they're for our own testing. But somehow some (like All) ended up switching from a td-test func into a user facing one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done

Comment thread cmds/xr/conform.go Outdated
if config.Output != "text" && config.Output != "json" {
Error("--output must be one of: text, json")
}
if common.ArrayContains(runNames, "mutations") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm not sure how I feel about requiring --allow-mutations. I kind of understand the desire to avoid touching a registry if the user didn't really know what they're doing, but at the same time, between the "entities" capability and the test creating its own group instances, and then cleaning them up, it seems like it should be safe. I'll think about it more, however, in the meantime, I think we should make TestAll actually test everything, including "mutations" and "entities" - but we can make mutations conditional on --allow-mutations for the time being - and showing a skip if they didn't include the flag.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I changed TestAll to include entities and mutations

Comment thread cmds/xr/td_registry.go Outdated
nTD = NewTD(td, "Verifying model MUST work")
nTD.NoError(model.Verify())
nTD.NoErrorStop(model.Verify())
reg.Model = model

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

let's remove this setting of the model here. My current thinking is that the tests shouldn't really be setting/saving values in the Entities in most cases - that should be done by the common Registry entity processing logic. Instead, if a test needs the model (but isn't explicitly testing the model) then it should call reg.GetModel() and my recent changes should automatically load it if it's not already available. This does a few things:

  • it doesn't require TestModel to be run before a test uses the model. Even if we end up always making TestModel a dependency, I think it's a better design to try to have a clean separation between "stuff we're testing" and "stuff we need to move around/reason about the target registry".
  • GetModel(), while not yet, will eventually retrieve the model via GET /model or GET /?inline=model as needed. Meaning it'll try all possible mechanisms to get the model, while (as of now) TestModel only tries GET /model. Yes I do want TestModel to test both paths too eventually, but if the tests fails for some reason we shouldn't be dependent on it saving the model for the rest of the tests. Again, separation of data.
  • GetModel() will do additional logic on the reg.Model data that the testing code won't do - or shouldn't need to do - and the logic here shouldn't know about (or do) that extra logic. That's why it should be doing whatever raw GET it needs for testing, and not use reg.GetModel() unless it needs the model with all appropriate additional processing done to it (meaning, the model as seen outside of the model-testing logic)

Same logic for capabilities too, but that's a different issue/PR

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That makes sense. Ive removed the model from this step

Comment thread cmds/xr/td_registry.go Resolved
Comment thread cmds/xr/td_registry.go Resolved
Comment thread cmds/xr/td_registry.go Resolved
Comment thread cmds/xr/td_registry.go Resolved
Comment thread cmds/xr/td_registry.go Resolved
@thirschel-microsoft
thirschel-microsoft force-pushed the thirschel-microsoft-conform-group-mutation branch from 99ddd74 to 8a8e250 Compare November 2, 2026 14:00
@thirschel-microsoft

Copy link
Copy Markdown
Contributor Author

Pretty busy week last week. Ive updated the PR but let me go through and address the comments as well

Signed-off-by: Tyler Hirschel <thirschel@microsoft.com>
Signed-off-by: Tyler Hirschel <thirschel@microsoft.com>
Signed-off-by: Tyler Hirschel <thirschel@microsoft.com>
Signed-off-by: Tyler Hirschel <thirschel@microsoft.com>
Signed-off-by: Tyler Hirschel <thirschel@microsoft.com>
@thirschel-microsoft
thirschel-microsoft force-pushed the thirschel-microsoft-conform-group-mutation branch from 8a8e250 to 50168ed Compare November 6, 2026 14:36

@duglin duglin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

sorry for the delay - busy week.
I think it might be good to move the mutation tests into td_mutations.go (or td_group.go) since I was actually hoping to scope td_registry to just registry-entity testing... but not there yet.

Comment thread cmds/xr/td.go Outdated
}

nTD.HTTPBodyMustJSON(res, "GET %s", path)
nTD.httpBodyMustJSON(res, true, "GET %s", path)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I wonder if it would be better to add a flag to HTTPResponse indicating if it has been logged or not, then LogHTTP(res) could just return immediately if already logged w/o all callers having to know if it's been logged or not and pass in the extra arg?

Comment thread cmds/xr/td_registry.go Outdated
Comment on lines +456 to +457
createTD.NoError(createRes.Error, "Creating the run-owned Group MUST work")
createTD.StopOnFail()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think you can replace all occurrences of NoError()+StopOnFail() with NoErrorStop()

Comment thread cmds/xr/td_registry.go Outdated

for _, resourceType := range SortedKeys(groupModel.Resources) {
countName := resourceType + "count"
td.ObjReqMustGe(res.JSON, countName, 0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why do we have the above check given the next one?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Any reason why you're not letting it default the msgs on these types of checks?
I find:

"filescount" (0) MUST = 0

more useful than

file count MUST be zero

since it uses the real attr name and shows "got" and "expected" values:

Comment thread cmds/xr/td_registry.go Outdated
Comment on lines +538 to +544
epochAny, _ := td.GetObjProp(res.JSON, "epoch")
epoch, epochErr := conformMutationUInt(epochAny)
if epochErr != nil {
td.Log("Invalid epoch: %s", epochErr)
td.Fail("\"epoch\" MUST be an exact uinteger less than 2^53")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

are these lines needed given the epoch check on line 536?

Comment thread cmds/xr/td_registry.go Outdated
"%s count MUST be zero", resourceType)

if _, exists := td.GetObjProp(res.JSON, resourceType); exists {
td.ObjReqMustEq(res.JSON, resourceType, map[string]any{})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't think this is true. W/o ?inline they shouldn't show the collection, even if empty. So I think just the MustNotExist() check on line 554 is needed

Comment thread tests/xr_test.go Outdated
func TestXRConformMutations(t *testing.T) {
const target = "http://localhost:8181"

t.Run("authorization before network", func(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's not use t.Run(), just do the test directly. Using t.Run() will cause the golang testing infrastructure to indent the output which makes it really hard to just copy-n-paste the "Raw Output" from the testing output and use it as the expected output. If you look in the Makefile you'll see that there's a "sed" cmd to properly outdent the "go test" output 8 spaces to make this task easier - t.Run() breaks that.

Comment thread tests/xr_test.go Outdated
"http://localhost:8282/conform", "",
`PASS: http://localhost:8282/conform (skip:2)
Pass: 50 Fail: 0 Warn: 0 Skip: 2
`, "*", 0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If you search in xr_test.go you'll see uses of ExpectCount func to make this stuff easier. For example, I think this can now be rewritten as:

  XCLI(...).
    ExpectCount("err", "Request: PUT ", 0).
    ExpectCount("err", "Request: DELETE ", 0)

and it supports regular expressions too. Also, it might be best to look at how it sets up clog to send those HTTP logs to a separate file. Then 1) you don't need -vvv and 2) we can test stderr w/o the clutter of the http logging. Then it becomes ExpectCount(clog, ...) - but it does support "err" (Stderr) or "out" (stdout) if needed.

Comment thread tests/xr_test.go Outdated
}
`)

XHTTP(t, reg, "PUT", "/dirs/existing", `{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is this just for fun or is it actually needed for the conformance testing?

Comment thread cmds/xr/td_registry.go Outdated
expectedURL := reg.WithPath(path)
createTD.HTTPHeaderMustEqual(createRes, "Location",
expectedURL, "Create MUST return the exact Location")
createTD.StopOnFail()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Did you want to test updating the group (w/ and w/o epoch (good and bad value)) as part of this PR or save it for later?

Comment thread cmds/xr/td_registry.go Outdated
deletePath = "/" + groupModel.Plural
deleteBody = []byte(fmt.Sprintf(`{%s:{"epoch":%s}}`,
strconv.Quote(id), epochString))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It might be good to test w/o any epoch at all (no query param or in body) - as well as testing cases where ?epoch isn't supported but we try to use it (it should ignore the epoch value - even if it's a bad value). Follow-on PR is fine too - just popped in my head since you're touching on the subject here

Signed-off-by: Tyler Hirschel <thirschel@microsoft.com>
Signed-off-by: Tyler Hirschel <thirschel@microsoft.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants