diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index 106e102..e5dcffb 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -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 diff --git a/internal/platform/integration_test.go b/internal/platform/integration_test.go index f27adb4..42a9535 100644 --- a/internal/platform/integration_test.go +++ b/internal/platform/integration_test.go @@ -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)