Verify asset permission revocation and immutable adoption rejection receipts
Foundry Go checks / check (push) Successful in 5m12s
Foundry Go checks / check (pull_request) Successful in 5m0s

This commit is contained in:
2026-11-09 16:36:19 +08:00
parent efa916060a
commit 95812c3a96
2 changed files with 57 additions and 1 deletions
+1 -1
View File
@@ -71,7 +71,7 @@ jobs:
PYSLACK
- name: Fetch pinned headless HTTP integration client
env:
HEADLESS_CLIENT_REVISION: a626e6c3006765ae5569b40cf7d48b7f263a722a
HEADLESS_CLIENT_REVISION: 902e30a65b07a97304ffc41fbbc7a9d1a91d08fd
run: |
set -eu
mkdir -p .headless-client
+56
View File
@@ -706,6 +706,62 @@ func TestOnboardingAndSharing(t *testing.T) {
t.Fatalf("stable URL lost child asset binding: %v count=%d", err, managedBindings)
}
call("POST", assetCanvasPath+"/subgraphs/asset-frame/dissolve", tokens[0], response{"expected_versions": map[string]int64{assetBoard["id"].(string): 5, assetChild: 2}}, 200, "", accept, "40000000-0000-4000-8000-000000000003")
// A warm metadata/original cache is not a future permission grant. An external
// collaborator can read through a source board grant, but a new canonical
// target write must recheck asset readability after that grant is revoked.
sourceGrantPath := workspacePath + "/materials/" + assetBoard["id"].(string) + "/grants"
call("POST", sourceGrantPath, tokens[0], response{"account_id": accounts[2], "role": "viewer"}, 204)
call("GET", metadataPath, tokens[2], nil, 200)
download(tokens[2], 200)
targetBoard := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "board", "title": "Asset adoption target", "content": response{}}, 201)
targetID := targetBoard["id"].(string)
targetPath := workspacePath + "/materials/" + targetID
targetGrantPath := targetPath + "/grants"
call("POST", targetGrantPath, tokens[0], response{"account_id": accounts[2], "role": "editor"}, 204)
migrationID := modelTestID(9200)
call("POST", targetPath+"/canvas/model/migrate", tokens[0], response{"protocol_version": 2, "request_id": migrationID, "expected_versions": map[string]int64{targetID: 1}}, 200, "", accept, migrationID)
targetModel := call("GET", targetPath+"/canvas/model", tokens[2], nil, 200)["model"].(map[string]any)
targetVersion := int64(targetModel["versions"].(map[string]any)[targetID].(float64))
adoptedElement := modelTestNode(modelTestID(9201), targetID)
adoptedElement["componentId"] = "image"
adoptedElement["data"] = map[string]any{"title": "Adopted", "text": "", "assetId": uploaded["id"], "src": uploaded["url"]}
adoptionRequest := func(requestID, operationID string) modelBatch {
return modelBatch{ProtocolVersion: 2, RequestID: requestID, Expected: map[string]int64{targetID: targetVersion}, Operations: []modelOperation{{ID: operationID, Changes: []modelChange{{Kind: "element", ID: modelString(adoptedElement["id"]), Before: modelJSON(nil), After: modelJSON(adoptedElement)}}}}}
}
call("DELETE", sourceGrantPath+"/"+accounts[2], tokens[0], nil, 204)
call("GET", metadataPath, tokens[2], nil, 404)
download(tokens[2], 404)
deniedID := modelTestID(9202)
deniedBody := adoptionRequest(deniedID, modelTestID(9203))
denied := call("POST", targetPath+"/canvas/model/operations", tokens[2], deniedBody, 400, "", accept, deniedID)
if denied["status"] != "not-applied" || denied["terminal"] != true || denied["error"] != "invalid_asset_reference" {
t.Fatalf("revoked source did not yield a definitive no-write receipt: %v", denied)
}
unchanged := call("GET", targetPath+"/canvas/model", tokens[2], nil, 200)["model"].(map[string]any)
if !modelEqual(unchanged, targetModel) {
t.Fatal("revoked-source adoption changed target model or versions")
}
var targetBindings int
if err := db.QueryRowContext(ctx, "SELECT count(*) FROM canvas_asset_bindings WHERE project=$1 AND asset_id=$2 AND board_id=$3", project, uploaded["id"], targetID).Scan(&targetBindings); err != nil || targetBindings != 0 {
t.Fatalf("rejected adoption left a target binding: %d %v", targetBindings, err)
}
// Restoring source access does not change the frozen rejected request's result.
call("POST", sourceGrantPath, tokens[0], response{"account_id": accounts[2], "role": "viewer"}, 204)
call("GET", metadataPath, tokens[2], nil, 200)
replayedDenial := call("POST", targetPath+"/canvas/model/operations", tokens[2], deniedBody, 400, "", accept, deniedID)
if !modelEqual(denied, replayedDenial) {
t.Fatal("permission restoration changed a terminal receipt")
}
acceptedID := modelTestID(9204)
call("POST", targetPath+"/canvas/model/operations", tokens[2], adoptionRequest(acceptedID, modelTestID(9205)), 200, "", accept, acceptedID)
call("DELETE", sourceGrantPath+"/"+accounts[2], tokens[0], nil, 204)
// The independently authorized target now owns a binding; source revocation
// must not revoke a resource still readable through that target.
call("GET", metadataPath, tokens[2], nil, 200)
download(tokens[2], 200)
call("DELETE", targetGrantPath+"/"+accounts[2], tokens[0], nil, 204)
call("GET", metadataPath, tokens[2], nil, 404)
download(tokens[2], 404)
download(tokens[1], 200)
upload([]byte("not an image"), "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", 400)