2026-03-29 23:56:43 +02:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"os"
|
|
|
|
|
"os/exec"
|
|
|
|
|
"path/filepath"
|
2026-03-31 22:36:49 +02:00
|
|
|
"slices"
|
2026-03-29 23:56:43 +02:00
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-31 22:36:49 +02:00
|
|
|
// ── JSON response types ───────────────────────────────────────────────────────
|
|
|
|
|
|
2026-03-29 23:56:43 +02:00
|
|
|
type NodeResponse struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
Title string `json:"title"`
|
|
|
|
|
Content string `json:"content"`
|
2026-03-31 22:36:49 +02:00
|
|
|
DueDate string `json:"due_date"`
|
|
|
|
|
CreatedAt string `json:"created_at"`
|
|
|
|
|
UpdatedAt string `json:"updated_at"`
|
2026-03-29 23:56:43 +02:00
|
|
|
Tags []string `json:"tags"`
|
|
|
|
|
Relations map[string][]string `json:"relations"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 22:36:49 +02:00
|
|
|
func (n NodeResponse) HasTag(tag string) bool { return slices.Contains(n.Tags, tag) }
|
|
|
|
|
func (n NodeResponse) Property(key string) string {
|
|
|
|
|
prefix := "_" + key + "::"
|
|
|
|
|
for _, t := range n.Tags {
|
|
|
|
|
if strings.HasPrefix(t, prefix) {
|
|
|
|
|
return strings.TrimPrefix(t, prefix)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
func (n NodeResponse) HasRelation(relType, targetID string) bool {
|
|
|
|
|
return slices.Contains(n.Relations[relType], targetID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Test environment ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
type testEnv struct {
|
2026-03-31 23:52:24 +02:00
|
|
|
t *testing.T
|
|
|
|
|
dir string
|
|
|
|
|
user string // if non-empty, overrides AX_USER for every command
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// envWithUser returns the current process environment with AX_USER replaced.
|
|
|
|
|
func envWithUser(user string) []string {
|
|
|
|
|
var filtered []string
|
|
|
|
|
for _, e := range os.Environ() {
|
|
|
|
|
if !strings.HasPrefix(e, "AX_USER=") {
|
|
|
|
|
filtered = append(filtered, e)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return append(filtered, "AX_USER="+user)
|
2026-03-31 22:36:49 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (e *testEnv) ax(args ...string) (string, error) {
|
2026-03-29 23:56:43 +02:00
|
|
|
cmd := exec.Command("./ax", args...)
|
2026-03-31 22:36:49 +02:00
|
|
|
cmd.Dir = e.dir
|
2026-03-31 23:52:24 +02:00
|
|
|
if e.user != "" {
|
|
|
|
|
cmd.Env = envWithUser(e.user)
|
|
|
|
|
}
|
2026-03-29 23:56:43 +02:00
|
|
|
out, err := cmd.CombinedOutput()
|
|
|
|
|
return string(out), err
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 22:36:49 +02:00
|
|
|
func (e *testEnv) mustAx(args ...string) string {
|
|
|
|
|
e.t.Helper()
|
|
|
|
|
out, err := e.ax(args...)
|
2026-03-29 23:56:43 +02:00
|
|
|
if err != nil {
|
2026-03-31 22:36:49 +02:00
|
|
|
e.t.Fatalf("ax %v failed: %v\n%s", args, err, out)
|
2026-03-29 23:56:43 +02:00
|
|
|
}
|
2026-03-31 22:36:49 +02:00
|
|
|
return out
|
2026-03-29 23:56:43 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-31 22:36:49 +02:00
|
|
|
func (e *testEnv) parseNode(out string) NodeResponse {
|
|
|
|
|
e.t.Helper()
|
|
|
|
|
var n NodeResponse
|
|
|
|
|
if err := json.Unmarshal([]byte(out), &n); err != nil {
|
|
|
|
|
e.t.Fatalf("failed to parse node JSON: %v\noutput: %s", err, out)
|
|
|
|
|
}
|
|
|
|
|
return n
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (e *testEnv) parseNodes(out string) []NodeResponse {
|
|
|
|
|
e.t.Helper()
|
|
|
|
|
// service returns JSON null for empty results
|
|
|
|
|
if strings.TrimSpace(out) == "null" {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
var nodes []NodeResponse
|
|
|
|
|
if err := json.Unmarshal([]byte(out), &nodes); err != nil {
|
|
|
|
|
e.t.Fatalf("failed to parse node list JSON: %v\noutput: %s", err, out)
|
|
|
|
|
}
|
|
|
|
|
return nodes
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (e *testEnv) findInList(nodes []NodeResponse, id string) (NodeResponse, bool) {
|
|
|
|
|
for _, n := range nodes {
|
|
|
|
|
if n.ID == id {
|
|
|
|
|
return n, true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return NodeResponse{}, false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Test setup ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-03-29 23:56:43 +02:00
|
|
|
func TestE2E(t *testing.T) {
|
2026-03-31 22:36:49 +02:00
|
|
|
// Build binary once for all tests.
|
|
|
|
|
if err := exec.Command("go", "build", "-o", "ax", ".").Run(); err != nil {
|
|
|
|
|
t.Fatalf("build failed: %v", err)
|
2026-03-29 23:56:43 +02:00
|
|
|
}
|
|
|
|
|
defer os.Remove("./ax")
|
|
|
|
|
|
|
|
|
|
tmpDir, err := os.MkdirTemp("", "ax-e2e-*")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("failed to create temp dir: %v", err)
|
|
|
|
|
}
|
|
|
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
|
|
2026-03-31 22:36:49 +02:00
|
|
|
if err := exec.Command("cp", "./ax", filepath.Join(tmpDir, "ax")).Run(); err != nil {
|
2026-03-29 23:56:43 +02:00
|
|
|
t.Fatalf("failed to copy binary: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 22:36:49 +02:00
|
|
|
t.Setenv("AX_USER", "testuser")
|
2026-03-29 23:56:43 +02:00
|
|
|
|
2026-03-31 22:36:49 +02:00
|
|
|
// ── Init ─────────────────────────────────────────────────────────────────
|
2026-03-29 23:56:43 +02:00
|
|
|
|
2026-03-31 22:36:49 +02:00
|
|
|
t.Run("Init", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
env.mustAx("init")
|
2026-03-29 23:56:43 +02:00
|
|
|
|
2026-03-31 22:36:49 +02:00
|
|
|
if _, err := os.Stat(filepath.Join(tmpDir, ".ax.db")); err != nil {
|
|
|
|
|
t.Fatal(".ax.db not created")
|
|
|
|
|
}
|
|
|
|
|
})
|
2026-03-29 23:56:43 +02:00
|
|
|
|
2026-03-31 22:36:49 +02:00
|
|
|
t.Run("Init_AlreadyExists_Fails", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
_, err := env.ax("init")
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Fatal("expected error re-initialising existing DB, got none")
|
2026-03-29 23:56:43 +02:00
|
|
|
}
|
2026-03-31 22:36:49 +02:00
|
|
|
})
|
2026-03-29 23:56:43 +02:00
|
|
|
|
2026-03-31 22:36:49 +02:00
|
|
|
// ── Add ──────────────────────────────────────────────────────────────────
|
2026-03-29 23:56:43 +02:00
|
|
|
|
2026-03-31 22:36:49 +02:00
|
|
|
// Shared node IDs used across the remaining subtests.
|
|
|
|
|
var issueID, issue2ID, noteID string
|
2026-03-29 23:56:43 +02:00
|
|
|
|
2026-03-31 22:36:49 +02:00
|
|
|
t.Run("Add_IssueDefaults", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
out := env.mustAx("add", "My first issue", "--json")
|
|
|
|
|
n := env.parseNode(out)
|
|
|
|
|
issueID = n.ID
|
2026-03-29 23:56:43 +02:00
|
|
|
|
2026-03-31 22:36:49 +02:00
|
|
|
if n.Title != "My first issue" {
|
|
|
|
|
t.Errorf("title: want %q, got %q", "My first issue", n.Title)
|
|
|
|
|
}
|
|
|
|
|
if n.Property("type") != "issue" {
|
|
|
|
|
t.Errorf("type: want issue, got %q", n.Property("type"))
|
|
|
|
|
}
|
|
|
|
|
if n.Property("status") != "open" {
|
|
|
|
|
t.Errorf("status: want open, got %q", n.Property("status"))
|
|
|
|
|
}
|
|
|
|
|
if n.Property("prio") != "" {
|
|
|
|
|
t.Errorf("prio: want empty, got %q", n.Property("prio"))
|
|
|
|
|
}
|
|
|
|
|
if len(n.Relations["created"]) == 0 {
|
|
|
|
|
t.Error("expected created relation to be set")
|
|
|
|
|
}
|
|
|
|
|
if len(n.Relations["in_namespace"]) == 0 {
|
|
|
|
|
t.Error("expected in_namespace relation to be set (defaults to current user)")
|
|
|
|
|
}
|
|
|
|
|
if n.CreatedAt == "" || n.UpdatedAt == "" {
|
|
|
|
|
t.Error("expected timestamps to be set")
|
|
|
|
|
}
|
|
|
|
|
})
|
2026-03-29 23:56:43 +02:00
|
|
|
|
2026-03-31 22:36:49 +02:00
|
|
|
t.Run("Add_WithAllFlags", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
out := env.mustAx("add", "Full issue",
|
|
|
|
|
"--prio", "high",
|
|
|
|
|
"--status", "open",
|
|
|
|
|
"--namespace", "myproject",
|
|
|
|
|
"--assignee", "alice",
|
|
|
|
|
"--tag", "backend",
|
|
|
|
|
"--tag", "urgent",
|
|
|
|
|
"--due", "2099-12-31",
|
|
|
|
|
"--content", "some body",
|
|
|
|
|
"--json",
|
|
|
|
|
)
|
|
|
|
|
n := env.parseNode(out)
|
|
|
|
|
issue2ID = n.ID
|
2026-03-29 23:56:43 +02:00
|
|
|
|
2026-03-31 22:36:49 +02:00
|
|
|
if n.Property("type") != "issue" {
|
|
|
|
|
t.Errorf("type: want issue, got %q", n.Property("type"))
|
|
|
|
|
}
|
|
|
|
|
if n.Property("prio") != "high" {
|
|
|
|
|
t.Errorf("prio: want high, got %q", n.Property("prio"))
|
|
|
|
|
}
|
|
|
|
|
if n.Property("status") != "open" {
|
|
|
|
|
t.Errorf("status: want open, got %q", n.Property("status"))
|
|
|
|
|
}
|
|
|
|
|
if !n.HasTag("backend") {
|
|
|
|
|
t.Error("expected tag 'backend'")
|
|
|
|
|
}
|
|
|
|
|
if !n.HasTag("urgent") {
|
|
|
|
|
t.Error("expected tag 'urgent'")
|
|
|
|
|
}
|
|
|
|
|
if n.DueDate != "2099-12-31" {
|
|
|
|
|
t.Errorf("due_date: want 2099-12-31, got %q", n.DueDate)
|
|
|
|
|
}
|
|
|
|
|
if n.Content != "some body" {
|
|
|
|
|
t.Errorf("content: want %q, got %q", "some body", n.Content)
|
|
|
|
|
}
|
|
|
|
|
if len(n.Relations["in_namespace"]) == 0 {
|
|
|
|
|
t.Error("expected in_namespace relation")
|
|
|
|
|
}
|
|
|
|
|
if len(n.Relations["assignee"]) == 0 {
|
|
|
|
|
t.Error("expected assignee relation")
|
|
|
|
|
}
|
|
|
|
|
})
|
2026-03-29 23:56:43 +02:00
|
|
|
|
2026-03-31 22:36:49 +02:00
|
|
|
t.Run("Add_NoteType_NoDefaultStatus", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
out := env.mustAx("add", "A note", "--type", "note", "--json")
|
|
|
|
|
n := env.parseNode(out)
|
|
|
|
|
noteID = n.ID
|
2026-03-29 23:56:43 +02:00
|
|
|
|
2026-03-31 22:36:49 +02:00
|
|
|
if n.Property("type") != "note" {
|
|
|
|
|
t.Errorf("type: want note, got %q", n.Property("type"))
|
2026-03-29 23:56:43 +02:00
|
|
|
}
|
2026-03-31 22:36:49 +02:00
|
|
|
// Notes should NOT get a default status
|
|
|
|
|
if n.Property("status") != "" {
|
|
|
|
|
t.Errorf("note should have no default status, got %q", n.Property("status"))
|
|
|
|
|
}
|
|
|
|
|
})
|
2026-03-29 23:56:43 +02:00
|
|
|
|
2026-03-31 22:36:49 +02:00
|
|
|
t.Run("Add_CustomRelation", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
if issueID == "" || issue2ID == "" {
|
|
|
|
|
t.Skip("prerequisite nodes missing")
|
|
|
|
|
}
|
|
|
|
|
// issue2 blocks issueID
|
|
|
|
|
out := env.mustAx("update", issue2ID, "--rel", "blocks:"+issueID, "--json")
|
|
|
|
|
n := env.parseNode(out)
|
|
|
|
|
if !n.HasRelation("blocks", issueID) {
|
|
|
|
|
t.Errorf("expected blocks relation to %s, got relations: %v", issueID, n.Relations)
|
|
|
|
|
}
|
|
|
|
|
})
|
2026-03-29 23:56:43 +02:00
|
|
|
|
2026-03-31 22:36:49 +02:00
|
|
|
// ── Show ─────────────────────────────────────────────────────────────────
|
2026-03-29 23:56:43 +02:00
|
|
|
|
2026-03-31 22:36:49 +02:00
|
|
|
t.Run("Show_Fields", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
if issue2ID == "" {
|
|
|
|
|
t.Skip("prerequisite node missing")
|
2026-03-29 23:56:43 +02:00
|
|
|
}
|
2026-03-31 22:36:49 +02:00
|
|
|
out := env.mustAx("show", issue2ID, "--json")
|
|
|
|
|
n := env.parseNode(out)
|
2026-03-29 23:56:43 +02:00
|
|
|
|
2026-03-31 22:36:49 +02:00
|
|
|
if n.ID != issue2ID {
|
|
|
|
|
t.Errorf("id: want %q, got %q", issue2ID, n.ID)
|
|
|
|
|
}
|
|
|
|
|
if n.Title != "Full issue" {
|
|
|
|
|
t.Errorf("title: want %q, got %q", "Full issue", n.Title)
|
|
|
|
|
}
|
|
|
|
|
if n.DueDate != "2099-12-31" {
|
|
|
|
|
t.Errorf("due_date: want 2099-12-31, got %q", n.DueDate)
|
|
|
|
|
}
|
|
|
|
|
if n.Content != "some body" {
|
|
|
|
|
t.Errorf("content: want %q, got %q", "some body", n.Content)
|
|
|
|
|
}
|
|
|
|
|
})
|
2026-03-29 23:56:43 +02:00
|
|
|
|
2026-03-31 22:36:49 +02:00
|
|
|
t.Run("Show_NotFound_Fails", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
_, err := env.ax("show", "xxxxx")
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Fatal("expected error for unknown ID, got none")
|
|
|
|
|
}
|
|
|
|
|
})
|
2026-03-29 23:56:43 +02:00
|
|
|
|
2026-03-31 22:36:49 +02:00
|
|
|
// ── List ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
t.Run("List_All", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
out := env.mustAx("list", "--json")
|
|
|
|
|
nodes := env.parseNodes(out)
|
|
|
|
|
if len(nodes) < 3 {
|
|
|
|
|
t.Errorf("expected at least 3 nodes, got %d", len(nodes))
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("List_ByStatus", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
out := env.mustAx("list", "--status", "open", "--json")
|
|
|
|
|
nodes := env.parseNodes(out)
|
|
|
|
|
for _, n := range nodes {
|
|
|
|
|
if n.Property("status") != "open" {
|
|
|
|
|
t.Errorf("node %s has status %q, expected only open nodes", n.ID, n.Property("status"))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if _, ok := env.findInList(nodes, issueID); !ok {
|
|
|
|
|
t.Errorf("expected issueID %s in open list", issueID)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("List_ByPrio", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
out := env.mustAx("list", "--prio", "high", "--json")
|
|
|
|
|
nodes := env.parseNodes(out)
|
|
|
|
|
for _, n := range nodes {
|
|
|
|
|
if n.Property("prio") != "high" {
|
|
|
|
|
t.Errorf("node %s has prio %q, expected only high-priority nodes", n.ID, n.Property("prio"))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if _, ok := env.findInList(nodes, issue2ID); !ok {
|
|
|
|
|
t.Errorf("expected issue2ID %s in high-prio list", issue2ID)
|
|
|
|
|
}
|
|
|
|
|
if _, ok := env.findInList(nodes, issueID); ok {
|
|
|
|
|
t.Errorf("issueID %s has no prio, should not appear in high-prio list", issueID)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("List_ByType", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
out := env.mustAx("list", "--type", "note", "--json")
|
|
|
|
|
nodes := env.parseNodes(out)
|
|
|
|
|
for _, n := range nodes {
|
|
|
|
|
if n.Property("type") != "note" {
|
|
|
|
|
t.Errorf("node %s has type %q, expected only notes", n.ID, n.Property("type"))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if _, ok := env.findInList(nodes, noteID); !ok {
|
|
|
|
|
t.Errorf("expected noteID %s in note list", noteID)
|
|
|
|
|
}
|
|
|
|
|
if _, ok := env.findInList(nodes, issueID); ok {
|
|
|
|
|
t.Errorf("issueID %s should not appear in note list", issueID)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("List_ByNamespace", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
out := env.mustAx("list", "--namespace", "myproject", "--json")
|
|
|
|
|
nodes := env.parseNodes(out)
|
|
|
|
|
if _, ok := env.findInList(nodes, issue2ID); !ok {
|
|
|
|
|
t.Errorf("issue2ID %s should be in namespace 'myproject'", issue2ID)
|
|
|
|
|
}
|
|
|
|
|
if _, ok := env.findInList(nodes, issueID); ok {
|
|
|
|
|
t.Errorf("issueID %s should NOT be in namespace 'myproject'", issueID)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("List_ByAssignee", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
out := env.mustAx("list", "--assignee", "alice", "--json")
|
|
|
|
|
nodes := env.parseNodes(out)
|
|
|
|
|
if _, ok := env.findInList(nodes, issue2ID); !ok {
|
|
|
|
|
t.Errorf("issue2ID %s should appear in alice's assignments", issue2ID)
|
|
|
|
|
}
|
|
|
|
|
if _, ok := env.findInList(nodes, issueID); ok {
|
|
|
|
|
t.Errorf("issueID %s is not assigned to alice, should not appear", issueID)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("List_ByTag", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
out := env.mustAx("list", "--tag", "backend", "--json")
|
|
|
|
|
nodes := env.parseNodes(out)
|
|
|
|
|
for _, n := range nodes {
|
|
|
|
|
if !n.HasTag("backend") {
|
|
|
|
|
t.Errorf("node %s lacks 'backend' tag, should not be in result", n.ID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if _, ok := env.findInList(nodes, issue2ID); !ok {
|
|
|
|
|
t.Errorf("issue2ID %s (tagged backend) should be in result", issue2ID)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("List_MultipleTagFilters", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
// Both tags must match; a node with only one should be excluded.
|
|
|
|
|
out := env.mustAx("list", "--tag", "backend", "--tag", "urgent", "--json")
|
|
|
|
|
nodes := env.parseNodes(out)
|
|
|
|
|
for _, n := range nodes {
|
|
|
|
|
if !n.HasTag("backend") || !n.HasTag("urgent") {
|
|
|
|
|
t.Errorf("node %s does not have both tags", n.ID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if _, ok := env.findInList(nodes, issue2ID); !ok {
|
|
|
|
|
t.Errorf("issue2ID %s should match both tags", issue2ID)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("List_NoMatch_ReturnsEmpty", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
out := env.mustAx("list", "--tag", "nonexistent-tag-xyz", "--json")
|
|
|
|
|
nodes := env.parseNodes(out)
|
|
|
|
|
if len(nodes) != 0 {
|
|
|
|
|
t.Errorf("expected empty result, got %d nodes", len(nodes))
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ── Update ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
t.Run("Update_Title", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
if issueID == "" {
|
|
|
|
|
t.Skip("prerequisite node missing")
|
|
|
|
|
}
|
|
|
|
|
out := env.mustAx("update", issueID, "--title", "Updated title", "--json")
|
|
|
|
|
n := env.parseNode(out)
|
|
|
|
|
if n.Title != "Updated title" {
|
|
|
|
|
t.Errorf("title: want %q, got %q", "Updated title", n.Title)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("Update_Content", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
if issueID == "" {
|
|
|
|
|
t.Skip("prerequisite node missing")
|
|
|
|
|
}
|
|
|
|
|
out := env.mustAx("update", issueID, "--content", "New content body", "--json")
|
|
|
|
|
n := env.parseNode(out)
|
|
|
|
|
if n.Content != "New content body" {
|
|
|
|
|
t.Errorf("content: want %q, got %q", "New content body", n.Content)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("Update_Priority", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
if issueID == "" {
|
|
|
|
|
t.Skip("prerequisite node missing")
|
|
|
|
|
}
|
|
|
|
|
out := env.mustAx("update", issueID, "--prio", "medium", "--json")
|
|
|
|
|
n := env.parseNode(out)
|
|
|
|
|
if n.Property("prio") != "medium" {
|
|
|
|
|
t.Errorf("prio: want medium, got %q", n.Property("prio"))
|
|
|
|
|
}
|
|
|
|
|
// Only one _prio tag should exist.
|
|
|
|
|
var prioTags []string
|
|
|
|
|
for _, tag := range n.Tags {
|
|
|
|
|
if strings.HasPrefix(tag, "_prio::") {
|
|
|
|
|
prioTags = append(prioTags, tag)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if len(prioTags) != 1 {
|
|
|
|
|
t.Errorf("expected exactly 1 _prio tag, got %v", prioTags)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("Update_DueDate", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
if issueID == "" {
|
|
|
|
|
t.Skip("prerequisite node missing")
|
|
|
|
|
}
|
|
|
|
|
out := env.mustAx("update", issueID, "--due", "2099-01-01", "--json")
|
|
|
|
|
n := env.parseNode(out)
|
|
|
|
|
if n.DueDate != "2099-01-01" {
|
|
|
|
|
t.Errorf("due_date: want 2099-01-01, got %q", n.DueDate)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("Update_ClearDueDate", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
if issueID == "" {
|
|
|
|
|
t.Skip("prerequisite node missing")
|
|
|
|
|
}
|
|
|
|
|
out := env.mustAx("update", issueID, "--clear-due", "--json")
|
|
|
|
|
n := env.parseNode(out)
|
|
|
|
|
if n.DueDate != "" {
|
|
|
|
|
t.Errorf("due_date: want empty after --clear-due, got %q", n.DueDate)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("Update_AddAndRemoveTag", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
if issueID == "" {
|
|
|
|
|
t.Skip("prerequisite node missing")
|
|
|
|
|
}
|
|
|
|
|
env.mustAx("update", issueID, "--tag", "mytag")
|
|
|
|
|
out := env.mustAx("show", issueID, "--json")
|
|
|
|
|
if !env.parseNode(out).HasTag("mytag") {
|
|
|
|
|
t.Error("expected tag 'mytag' after add")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
env.mustAx("update", issueID, "--tag-remove", "mytag")
|
|
|
|
|
out = env.mustAx("show", issueID, "--json")
|
|
|
|
|
if env.parseNode(out).HasTag("mytag") {
|
|
|
|
|
t.Error("expected tag 'mytag' to be gone after remove")
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("Update_Assignee", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
if issueID == "" {
|
|
|
|
|
t.Skip("prerequisite node missing")
|
|
|
|
|
}
|
|
|
|
|
out := env.mustAx("update", issueID, "--assignee", "bob", "--json")
|
|
|
|
|
n := env.parseNode(out)
|
|
|
|
|
if len(n.Relations["assignee"]) == 0 {
|
|
|
|
|
t.Error("expected assignee relation after --assignee bob")
|
|
|
|
|
}
|
|
|
|
|
// Verify bob appears in list filtered by assignee.
|
|
|
|
|
listOut := env.mustAx("list", "--assignee", "bob", "--json")
|
|
|
|
|
listed := env.parseNodes(listOut)
|
|
|
|
|
if _, ok := env.findInList(listed, issueID); !ok {
|
|
|
|
|
t.Errorf("issueID %s not found in --assignee bob list", issueID)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("Update_Assignee_Replaces_Previous", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
if issueID == "" {
|
|
|
|
|
t.Skip("prerequisite node missing")
|
|
|
|
|
}
|
|
|
|
|
out := env.mustAx("update", issueID, "--assignee", "carol", "--json")
|
|
|
|
|
n := env.parseNode(out)
|
|
|
|
|
if len(n.Relations["assignee"]) != 1 {
|
|
|
|
|
t.Errorf("expected exactly 1 assignee, got %v", n.Relations["assignee"])
|
|
|
|
|
}
|
|
|
|
|
// bob should no longer be assignee
|
|
|
|
|
listOut := env.mustAx("list", "--assignee", "bob", "--json")
|
|
|
|
|
listed := env.parseNodes(listOut)
|
|
|
|
|
if _, ok := env.findInList(listed, issueID); ok {
|
|
|
|
|
t.Errorf("issueID %s still appears under bob after reassigning to carol", issueID)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("Update_AddAndRemoveRelation", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
if issueID == "" || noteID == "" {
|
|
|
|
|
t.Skip("prerequisite nodes missing")
|
|
|
|
|
}
|
|
|
|
|
// Link issueID as related to noteID.
|
|
|
|
|
env.mustAx("update", issueID, "--rel", "related:"+noteID)
|
|
|
|
|
out := env.mustAx("show", issueID, "--json")
|
|
|
|
|
n := env.parseNode(out)
|
|
|
|
|
if !n.HasRelation("related", noteID) {
|
|
|
|
|
t.Errorf("expected related relation to %s", noteID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Remove it.
|
|
|
|
|
env.mustAx("update", issueID, "--rel-remove", "related:"+noteID)
|
|
|
|
|
out = env.mustAx("show", issueID, "--json")
|
|
|
|
|
n = env.parseNode(out)
|
|
|
|
|
if n.HasRelation("related", noteID) {
|
|
|
|
|
t.Errorf("related relation to %s should be gone after rel-remove", noteID)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("Update_Namespace", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
if issueID == "" {
|
|
|
|
|
t.Skip("prerequisite node missing")
|
|
|
|
|
}
|
|
|
|
|
env.mustAx("update", issueID, "--namespace", "otherproject")
|
|
|
|
|
out := env.mustAx("list", "--namespace", "otherproject", "--json")
|
|
|
|
|
nodes := env.parseNodes(out)
|
|
|
|
|
if _, ok := env.findInList(nodes, issueID); !ok {
|
|
|
|
|
t.Errorf("issueID %s should be in namespace 'otherproject' after update", issueID)
|
|
|
|
|
}
|
|
|
|
|
// Should no longer be in old namespace (testuser's).
|
|
|
|
|
prevOut := env.mustAx("list", "--namespace", "testuser", "--json")
|
|
|
|
|
prev := env.parseNodes(prevOut)
|
|
|
|
|
if _, ok := env.findInList(prev, issueID); ok {
|
|
|
|
|
t.Errorf("issueID %s should NOT be in old namespace after namespace update", issueID)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ── Blocking ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
t.Run("Blocking_CannotCloseBlockedIssue", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
if issueID == "" || issue2ID == "" {
|
|
|
|
|
t.Skip("prerequisite nodes missing")
|
|
|
|
|
}
|
|
|
|
|
// issue2ID blocks issueID (set in Add_CustomRelation).
|
|
|
|
|
// issue2ID is still open, so closing issueID must fail.
|
|
|
|
|
out, err := env.ax("update", issueID, "--status", "done")
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Fatal("expected error closing blocked issue, got none")
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(out, "cannot close") {
|
|
|
|
|
t.Errorf("expected 'cannot close' in output, got: %s", out)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("Blocking_CloseBlockerThenClose", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
if issueID == "" || issue2ID == "" {
|
|
|
|
|
t.Skip("prerequisite nodes missing")
|
|
|
|
|
}
|
|
|
|
|
// Close the blocker first.
|
|
|
|
|
env.mustAx("update", issue2ID, "--status", "done")
|
|
|
|
|
// Now closing issueID must succeed.
|
|
|
|
|
out := env.mustAx("update", issueID, "--status", "done", "--json")
|
|
|
|
|
n := env.parseNode(out)
|
|
|
|
|
if n.Property("status") != "done" {
|
|
|
|
|
t.Errorf("status: want done, got %q", n.Property("status"))
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("Blocking_ReopenAndVerify", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
if issueID == "" {
|
|
|
|
|
t.Skip("prerequisite node missing")
|
|
|
|
|
}
|
|
|
|
|
out := env.mustAx("update", issueID, "--status", "open", "--json")
|
|
|
|
|
n := env.parseNode(out)
|
|
|
|
|
if n.Property("status") != "open" {
|
|
|
|
|
t.Errorf("status: want open after reopen, got %q", n.Property("status"))
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ── Mentions ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
var mentionNodeID string
|
|
|
|
|
|
|
|
|
|
t.Run("Mentions_AutoCreateUser", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
out := env.mustAx("add", "Review this @diana", "--json")
|
|
|
|
|
n := env.parseNode(out)
|
|
|
|
|
mentionNodeID = n.ID
|
|
|
|
|
|
|
|
|
|
if len(n.Relations["mentions"]) == 0 {
|
|
|
|
|
t.Fatal("expected mentions relation to be created for @diana")
|
|
|
|
|
}
|
|
|
|
|
// Verify the mention target is a user node.
|
|
|
|
|
dianaID := n.Relations["mentions"][0]
|
|
|
|
|
userOut := env.mustAx("show", dianaID, "--json")
|
|
|
|
|
user := env.parseNode(userOut)
|
|
|
|
|
if user.Title != "diana" {
|
|
|
|
|
t.Errorf("mention target title: want diana, got %q", user.Title)
|
|
|
|
|
}
|
|
|
|
|
if user.Property("type") != "user" {
|
|
|
|
|
t.Errorf("mention target type: want user, got %q", user.Property("type"))
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("Mentions_InboxFilter", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
if mentionNodeID == "" {
|
|
|
|
|
t.Skip("prerequisite node missing")
|
|
|
|
|
}
|
|
|
|
|
out := env.mustAx("list", "--mention", "diana", "--json")
|
|
|
|
|
nodes := env.parseNodes(out)
|
|
|
|
|
if _, ok := env.findInList(nodes, mentionNodeID); !ok {
|
|
|
|
|
t.Errorf("mentionNodeID %s should appear in diana's inbox", mentionNodeID)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("Mentions_SyncOnTitleUpdate", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
if mentionNodeID == "" {
|
|
|
|
|
t.Skip("prerequisite node missing")
|
|
|
|
|
}
|
|
|
|
|
// Replace @diana with @eve.
|
|
|
|
|
out := env.mustAx("update", mentionNodeID, "--title", "Review this @eve", "--json")
|
|
|
|
|
n := env.parseNode(out)
|
|
|
|
|
|
|
|
|
|
if len(n.Relations["mentions"]) != 1 {
|
|
|
|
|
t.Errorf("expected exactly 1 mention after update, got %v", n.Relations["mentions"])
|
|
|
|
|
}
|
|
|
|
|
// The mention target should now be eve, not diana.
|
|
|
|
|
eveID := n.Relations["mentions"][0]
|
|
|
|
|
userOut := env.mustAx("show", eveID, "--json")
|
|
|
|
|
if env.parseNode(userOut).Title != "eve" {
|
|
|
|
|
t.Errorf("mention target after update should be eve")
|
|
|
|
|
}
|
|
|
|
|
// Diana's inbox should be empty for this node.
|
|
|
|
|
dianaList := env.mustAx("list", "--mention", "diana", "--json")
|
|
|
|
|
dianaNodes := env.parseNodes(dianaList)
|
|
|
|
|
if _, ok := env.findInList(dianaNodes, mentionNodeID); ok {
|
|
|
|
|
t.Errorf("mentionNodeID %s should no longer be in diana's inbox", mentionNodeID)
|
|
|
|
|
}
|
|
|
|
|
// Eve's inbox should contain the node.
|
|
|
|
|
eveList := env.mustAx("list", "--mention", "eve", "--json")
|
|
|
|
|
eveNodes := env.parseNodes(eveList)
|
|
|
|
|
if _, ok := env.findInList(eveNodes, mentionNodeID); !ok {
|
|
|
|
|
t.Errorf("mentionNodeID %s should now be in eve's inbox", mentionNodeID)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("Mentions_ContentMention", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
out := env.mustAx("add", "No mention in title",
|
|
|
|
|
"--content", "But @frank is mentioned here", "--json")
|
|
|
|
|
n := env.parseNode(out)
|
|
|
|
|
if len(n.Relations["mentions"]) == 0 {
|
|
|
|
|
t.Fatal("expected mention from content field")
|
|
|
|
|
}
|
|
|
|
|
frankID := n.Relations["mentions"][0]
|
|
|
|
|
frankOut := env.mustAx("show", frankID, "--json")
|
|
|
|
|
if env.parseNode(frankOut).Title != "frank" {
|
|
|
|
|
t.Error("content mention should create user frank")
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ── Users ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
t.Run("Users_ListedAsNodes", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
out := env.mustAx("list", "--type", "user", "--json")
|
|
|
|
|
users := env.parseNodes(out)
|
|
|
|
|
titles := make([]string, len(users))
|
|
|
|
|
for i, u := range users {
|
|
|
|
|
titles[i] = u.Title
|
|
|
|
|
}
|
|
|
|
|
for _, name := range []string{"testuser", "alice", "diana", "eve", "frank"} {
|
|
|
|
|
if !slices.Contains(titles, name) {
|
|
|
|
|
t.Errorf("expected user %q to exist, got users: %v", name, titles)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ── Delete ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
t.Run("Delete_Force", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
// Create a throwaway node.
|
|
|
|
|
out := env.mustAx("add", "Delete me", "--json")
|
|
|
|
|
id := env.parseNode(out).ID
|
|
|
|
|
|
|
|
|
|
env.mustAx("del", id, "--force")
|
|
|
|
|
|
|
|
|
|
// Verify it's gone.
|
|
|
|
|
_, err := env.ax("show", id)
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Errorf("show after delete should fail, node %s still exists", id)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("Delete_NotFound_Fails", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
_, err := env.ax("del", "xxxxx", "--force")
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Fatal("expected error deleting non-existent node, got none")
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ── Aliases ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
t.Run("Alias_DefaultsPresent", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
out := env.mustAx("alias", "--json")
|
|
|
|
|
var aliases []map[string]string
|
|
|
|
|
if err := json.Unmarshal([]byte(out), &aliases); err != nil {
|
|
|
|
|
t.Fatalf("failed to parse alias JSON: %v\n%s", err, out)
|
|
|
|
|
}
|
|
|
|
|
names := make([]string, len(aliases))
|
|
|
|
|
for i, a := range aliases {
|
|
|
|
|
names[i] = a["name"]
|
|
|
|
|
}
|
|
|
|
|
for _, want := range []string{"mine", "due", "inbox"} {
|
|
|
|
|
if !slices.Contains(names, want) {
|
|
|
|
|
t.Errorf("default alias %q not found in: %v", want, names)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("Alias_Create_Show_Delete", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
env.mustAx("alias", "myopen", "list --status open", "--desc", "My open issues")
|
|
|
|
|
|
|
|
|
|
// Show the alias.
|
|
|
|
|
showOut, err := env.ax("alias", "myopen")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("alias show failed: %v\n%s", err, showOut)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(showOut, "list --status open") {
|
|
|
|
|
t.Errorf("alias command not found in show output: %s", showOut)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Delete it.
|
|
|
|
|
env.mustAx("alias", "del", "myopen")
|
|
|
|
|
|
|
|
|
|
// Should be gone.
|
|
|
|
|
_, err = env.ax("alias", "myopen")
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Fatal("expected error after alias deletion, got none")
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("Alias_CannotDeleteDefault", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
_, err := env.ax("alias", "del", "inbox")
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Fatal("expected error deleting default alias, got none")
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("Alias_Execute", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
// The built-in 'due' alias lists open issues; we just verify it runs without error.
|
|
|
|
|
out := env.mustAx("due", "--json")
|
|
|
|
|
// Result must be parseable (array or null).
|
|
|
|
|
env.parseNodes(out)
|
|
|
|
|
})
|
2026-03-29 23:56:43 +02:00
|
|
|
|
2026-03-31 22:36:49 +02:00
|
|
|
t.Run("Alias_Execute_WithMeExpansion", func(t *testing.T) {
|
|
|
|
|
env := &testEnv{t: t, dir: tmpDir}
|
|
|
|
|
// 'mine' expands $me to AX_USER=testuser.
|
|
|
|
|
out := env.mustAx("mine", "--json")
|
|
|
|
|
env.parseNodes(out) // must parse without error
|
|
|
|
|
})
|
2026-03-31 23:52:24 +02:00
|
|
|
|
|
|
|
|
// ── Permissions ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
t.Run("Permissions", func(t *testing.T) {
|
|
|
|
|
permDir, err := os.MkdirTemp("", "ax-perm-*")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
defer os.RemoveAll(permDir)
|
|
|
|
|
if err := exec.Command("cp", "./ax", filepath.Join(permDir, "ax")).Run(); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
alice := &testEnv{t: t, dir: permDir, user: "alice"}
|
|
|
|
|
bob := &testEnv{t: t, dir: permDir, user: "bob"}
|
|
|
|
|
|
|
|
|
|
alice.mustAx("init")
|
|
|
|
|
|
|
|
|
|
// Alice creates a node in her namespace.
|
|
|
|
|
aliceNode := alice.parseNode(alice.mustAx("add", "Alice's secret", "--json"))
|
|
|
|
|
aliceNodeID := aliceNode.ID
|
|
|
|
|
|
|
|
|
|
// Resolve alice's user and namespace node IDs.
|
|
|
|
|
var aliceUserID, aliceNSID string
|
|
|
|
|
for _, u := range alice.parseNodes(alice.mustAx("list", "--type", "user", "--json")) {
|
|
|
|
|
if u.Title == "alice" {
|
|
|
|
|
aliceUserID = u.ID
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for _, ns := range alice.parseNodes(alice.mustAx("list", "--type", "namespace", "--json")) {
|
|
|
|
|
if ns.Title == "alice" {
|
|
|
|
|
aliceNSID = ns.ID
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if aliceUserID == "" || aliceNSID == "" {
|
|
|
|
|
t.Fatal("could not resolve alice's user/namespace node IDs")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 bootstraps his own namespace first.
|
|
|
|
|
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 (visible to alice because user nodes have no namespace).
|
|
|
|
|
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) {
|
|
|
|
|
// Bob attempts to grant himself write access to alice's namespace.
|
|
|
|
|
_, err := bob.ax("update", bobUserID, "--rel", "has_write_access:"+aliceNSID)
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Error("bob should not be able to grant himself write access to alice's namespace")
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("ReadAccess_Grant", func(t *testing.T) {
|
|
|
|
|
// Alice grants bob read access to her namespace.
|
|
|
|
|
alice.mustAx("update", bobUserID, "--rel", "has_read_access:"+aliceNSID)
|
|
|
|
|
|
|
|
|
|
// Bob can now show alice's node.
|
|
|
|
|
if _, err := bob.ax("show", aliceNodeID); err != nil {
|
|
|
|
|
t.Error("bob should be able to show alice's node after read access granted")
|
|
|
|
|
}
|
|
|
|
|
// And it appears in his list.
|
|
|
|
|
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) {
|
|
|
|
|
// Bob creates his own node and tries to link it to alice's node.
|
|
|
|
|
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 grants bob write access.
|
|
|
|
|
alice.mustAx("update", bobUserID, "--rel", "has_write_access:"+aliceNSID)
|
|
|
|
|
|
|
|
|
|
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) {
|
|
|
|
|
// Bob creates a node and links it to alice's node (now has write access).
|
|
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-03-29 23:56:43 +02:00
|
|
|
}
|