Files
ax/src/e2e/e2e_permissions_test.go
Elias Kohout 63044a697d
Some checks failed
Build and Publish APK Package / build-apk (amd64, x86_64) (push) Successful in 44s
Build and Publish APK Package / build-apk (arm64, aarch64) (push) Successful in 51s
Build and Publish Arch Package / build-arch (amd64, x86_64) (push) Successful in 58s
Build and Publish Arch Package / build-arch (arm64, aarch64) (push) Successful in 1m5s
Build and Push Docker Container / build-and-push (push) Failing after 10m5s
feat: change node ownership to namespace instead of creator
When a node is created in a namespace, the namespace now owns it rather than the creator. This allows namespaces to manage content ownership. Namespace nodes themselves remain owned by their creator. Users retain transitive ownership through their default namespace: user→has_ownership→namespace→has_ownership→node.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 05:41:13 +02:00

196 lines
6.5 KiB
Go

package e2e_test
import (
"testing"
)
func TestPermissions(t *testing.T) {
alice := newTestEnv(t, "alice")
bob := alice.withUser("bob")
// Alice creates a node (gets has_ownership automatically).
aliceNode := alice.parseNode(alice.mustAx("add", "Alice's secret", "--json"))
aliceNodeID := aliceNode.ID
// Resolve alice's user node ID.
var aliceUserID string
for _, u := range alice.parseNodes(alice.mustAx("list", "--type", "user", "--json")) {
if u.Title == "alice" {
aliceUserID = u.ID
}
}
if aliceUserID == "" {
t.Fatal("could not resolve alice's user node ID")
}
t.Run("NoAccess_CannotShow", func(t *testing.T) {
_, err := bob.ax("show", aliceNodeID)
if err == nil {
t.Error("bob should not be able to show alice's node without access")
}
})
t.Run("NoAccess_NotInList", func(t *testing.T) {
bob.mustAx("add", "Bob's scratch", "--json")
nodes := bob.parseNodes(bob.mustAx("list", "--json"))
if _, ok := bob.findInList(nodes, aliceNodeID); ok {
t.Error("alice's node should not appear in bob's list without access")
}
})
t.Run("NoAccess_CannotUpdate", func(t *testing.T) {
_, err := bob.ax("update", aliceNodeID, "--title", "hacked")
if err == nil {
t.Error("bob should not be able to update alice's node without access")
}
})
// Resolve bob's user node ID. User nodes are globally readable.
var bobUserID string
for _, u := range alice.parseNodes(alice.mustAx("list", "--type", "user", "--json")) {
if u.Title == "bob" {
bobUserID = u.ID
}
}
if bobUserID == "" {
t.Fatal("could not resolve bob's user node ID")
}
t.Run("SelfEscalation_Denied", func(t *testing.T) {
_, err := bob.ax("update", bobUserID, "--rel", "can_write:"+aliceNodeID)
if err == nil {
t.Error("bob should not be able to grant himself write access to alice's node")
}
})
t.Run("ReadAccess_Grant", func(t *testing.T) {
alice.mustAx("update", bobUserID, "--rel", "can_read:"+aliceNodeID)
if _, err := bob.ax("show", aliceNodeID); err != nil {
t.Error("bob should be able to show alice's node after read access granted")
}
nodes := bob.parseNodes(bob.mustAx("list", "--json"))
if _, ok := bob.findInList(nodes, aliceNodeID); !ok {
t.Error("alice's node should appear in bob's list after read access granted")
}
})
t.Run("ReadAccess_CannotUpdate", func(t *testing.T) {
_, err := bob.ax("update", aliceNodeID, "--title", "hacked with read access")
if err == nil {
t.Error("bob should not be able to update alice's node with only read access")
}
})
t.Run("ReadAccess_CannotAddRelationToNode", func(t *testing.T) {
bobLinkedID := bob.parseNode(bob.mustAx("add", "Bob's linked node", "--json")).ID
_, err := bob.ax("update", bobLinkedID, "--rel", "related:"+aliceNodeID)
if err == nil {
t.Error("bob should not be able to create a relation to alice's node with only read access")
}
})
t.Run("WriteAccess_Grant", func(t *testing.T) {
alice.mustAx("update", bobUserID, "--rel", "can_write:"+aliceNodeID)
out := bob.mustAx("update", aliceNodeID, "--title", "Bob modified this", "--json")
n := bob.parseNode(out)
if n.Title != "Bob modified this" {
t.Errorf("expected 'Bob modified this', got %q", n.Title)
}
})
t.Run("WriteAccess_CanAddRelationToNode", func(t *testing.T) {
bobNode2 := bob.parseNode(bob.mustAx("add", "Bob's related node", "--json"))
bob.mustAx("update", bobNode2.ID, "--rel", "related:"+aliceNodeID)
out := bob.mustAx("show", bobNode2.ID, "--json")
n := bob.parseNode(out)
if !n.HasRelation("related", aliceNodeID) {
t.Error("expected related relation after write access granted")
}
})
t.Run("Ownership_DefaultOnCreate", func(t *testing.T) {
userOut := alice.mustAx("show", aliceUserID, "--json")
userNode := alice.parseNode(userOut)
if !userNode.HasRelation("has_ownership", aliceUserID) {
t.Errorf("expected user node to have self-ownership, got relations: %v", userNode.Relations)
}
// Nodes are now owned by the namespace they belong to, not directly by the creator.
// Alice's default namespace is owned by alice, so she retains transitive ownership.
if userNode.HasRelation("has_ownership", aliceNodeID) {
t.Errorf("alice should not directly own her node (namespace owns it), got relations: %v", userNode.Relations)
}
// Find alice's default namespace and verify it owns the node.
namespaces := alice.parseNodes(alice.mustAx("list", "--type", "namespace", "--json"))
var aliceNsID string
for _, ns := range namespaces {
if ns.Title == "alice" {
aliceNsID = ns.ID
}
}
if aliceNsID == "" {
t.Fatal("could not find alice's default namespace")
}
nsOut := alice.mustAx("show", aliceNsID, "--json")
nsNode := alice.parseNode(nsOut)
if !nsNode.HasRelation("has_ownership", aliceNodeID) {
t.Errorf("expected alice's namespace to own her node, got relations: %v", nsNode.Relations)
}
})
t.Run("Ownership_CascadeDelete", func(t *testing.T) {
throwaway := alice.withUser("throwaway")
child := throwaway.parseNode(throwaway.mustAx("add", "Child node", "--json"))
var throwawayUserID string
for _, u := range throwaway.parseNodes(throwaway.mustAx("list", "--type", "user", "--json")) {
if u.Title == "throwaway" {
throwawayUserID = u.ID
}
}
if throwawayUserID == "" {
t.Fatal("could not find throwaway user node")
}
throwaway.mustAx("del", throwawayUserID, "--force")
_, err := throwaway.ax("show", child.ID)
if err == nil {
t.Error("child node should have been cascade-deleted when its owner was deleted")
}
})
}
func TestNamespaceExplicitCreate(t *testing.T) {
env := newTestEnv(t, "testuser")
nsNode := env.parseNode(env.mustAx("add", "myworkspace", "--type", "namespace", "--json"))
if !nsNode.HasRelation("in_namespace", nsNode.ID) {
t.Errorf("expected namespace to have in_namespace pointing to itself, got relations: %v", nsNode.Relations)
}
users := env.parseNodes(env.mustAx("list", "--type", "user", "--json"))
var userNode *NodeResponse
for i := range users {
if users[i].Title == "testuser" {
userNode = &users[i]
break
}
}
if userNode == nil {
t.Fatal("could not find testuser node")
}
if !userNode.HasRelation("has_ownership", nsNode.ID) {
t.Errorf("expected creator to have has_ownership on new namespace, got relations: %v", userNode.Relations)
}
env.mustAx("add", "task in workspace", "--namespace", "myworkspace", "--json")
listed := env.parseNodes(env.mustAx("list", "--namespace", "myworkspace", "--json"))
if len(listed) == 0 {
t.Error("expected to list nodes in newly created namespace")
}
}