package main import ( "bytes" "os" "os/exec" "path/filepath" "regexp" "strings" "testing" "time" "tangled.org/core/spindle/db" "tangled.org/core/spindle/quota" ) func TestCLIInvalidLogFormat(t *testing.T) { cmd := exec.Command(os.Args[0], "-test.run=TestHelperProcess") cmd.Env = append(os.Environ(), "SPINDLE_LOG_FORMAT=invalid", "WANT_HELPER_PROCESS=1") var stderr bytes.Buffer cmd.Stderr = &stderr err := cmd.Run() if err == nil { t.Fatalf("expected command to fail, but it succeeded") } exitErr, ok := err.(*exec.ExitError) if !ok { t.Fatalf("expected exit error, got %v", err) } if code := exitErr.ExitCode(); code != 1 { t.Errorf("expected exit code 1, got %d", code) } output := stderr.String() if !strings.Contains(output, "invalid log format") { t.Errorf("expected error message to contain 'invalid log format', got: %q", output) } } func TestHelperProcess(t *testing.T) { if os.Getenv("WANT_HELPER_PROCESS") != "1" { return } for i, arg := range os.Args { if arg == "--" { os.Args = append([]string{os.Args[0]}, os.Args[i+1:]...) main() return } } main() } func TestCLIQuotaCommands(t *testing.T) { tmpDb := filepath.Join(t.TempDir(), "spindle.db") tests := []struct { name string args []string wantExitCode int wantError string wantOutput string }{ { name: "invalid DID", args: []string{"quota", "set", "alice", "--resource", "cache_storage_bytes", "--limit", "10"}, wantExitCode: 255, wantError: "not a did or handle", }, { name: "invalid resource", args: []string{"quota", "set", "did:web:alice", "--resource", strings.Repeat("a", 65), "--limit", "10"}, wantExitCode: 255, wantError: "invalid resource", }, { name: "conflicting flags limit and unlimited", args: []string{"quota", "set", "did:web:alice", "--resource", "cache_storage_bytes", "--limit", "10", "--unlimited"}, wantExitCode: 255, wantError: "cannot specify both --limit and --unlimited", }, { name: "missing flags limit and unlimited", args: []string{"quota", "set", "did:web:alice", "--resource", "cache_storage_bytes"}, wantExitCode: 255, wantError: "must specify either --limit or --unlimited", }, { name: "zero limit", args: []string{"quota", "set", "did:web:alice", "--resource", "workflows", "--limit", "0"}, wantExitCode: 255, wantError: "--unlimited", }, { name: "cache storage limit overflow", args: []string{"quota", "set", "did:web:alice", "--resource", "cache_storage_bytes", "--limit", "9223372036854775807"}, wantExitCode: 255, wantError: "would overflow bytes", }, { name: "set user cache_storage_bytes limit", args: []string{"quota", "set", "did:web:alice", "--resource", "cache_storage_bytes", "--limit", "10"}, wantExitCode: 0, wantOutput: "quota override set", }, { name: "set user workflows limit", args: []string{"quota", "set", "did:web:alice", "--resource", "workflows", "--limit", "5"}, wantExitCode: 0, wantOutput: "quota override set", }, { name: "set user vcpus limit", args: []string{"quota", "set", "did:web:alice", "--resource", "vcpus", "--limit", "4"}, wantExitCode: 0, wantOutput: "quota override set", }, { name: "set user memory_mib limit", args: []string{"quota", "set", "did:web:alice", "--resource", "memory_mib", "--limit", "2048"}, wantExitCode: 0, wantOutput: "quota override set", }, { name: "set user disk_mib limit", args: []string{"quota", "set", "did:web:alice", "--resource", "disk_mib", "--limit", "4096"}, wantExitCode: 0, wantOutput: "quota override set", }, { name: "set user cache_storage_bytes unlimited", args: []string{"quota", "set", "did:web:alice", "--resource", "cache_storage_bytes", "--unlimited"}, wantExitCode: 0, wantOutput: "quota override set", }, { name: "list quota limits", args: []string{"quota", "list"}, wantExitCode: 0, wantOutput: "LIMIT", }, { name: "usage list", args: []string{"quota", "usage"}, wantExitCode: 0, wantOutput: "LIMIT", }, { name: "unset user cache_storage_bytes limit", args: []string{"quota", "unset", "did:web:alice", "--resource", "cache_storage_bytes"}, wantExitCode: 0, wantOutput: "quota override removed", }, { name: "unset user workflows limit", args: []string{"quota", "unset", "did:web:alice", "--resource", "workflows"}, wantExitCode: 0, wantOutput: "quota override removed", }, { name: "set user generic resource limit", args: []string{"quota", "set", "did:web:alice", "--resource", "custom_gpu", "--limit", "8"}, wantExitCode: 0, wantOutput: "quota override set", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { fullArgs := append([]string{"-test.run=TestHelperProcess", "--"}, tt.args...) fullArgs = append(fullArgs, "--db", tmpDb) cmd := exec.Command(os.Args[0], fullArgs...) cmd.Env = append(os.Environ(), "WANT_HELPER_PROCESS=1", "SPINDLE_SERVER_HOSTNAME=spindle.example.com", "SPINDLE_SERVER_OWNER=did:web:spindle.example.com", ) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() exitCode := 0 if err != nil { if exitErr, ok := err.(*exec.ExitError); ok { exitCode = exitErr.ExitCode() } else { t.Fatalf("unexpected run error: %v", err) } } expectedCode := tt.wantExitCode if expectedCode == -1 { expectedCode = 255 } if exitCode != expectedCode { t.Errorf("exitCode = %d, want %d. Stderr: %q, Stdout: %q", exitCode, expectedCode, stderr.String(), stdout.String()) } combined := stdout.String() + "\n" + stderr.String() if tt.wantError != "" && !strings.Contains(combined, tt.wantError) { t.Errorf("expected error/output to contain %q, got combined: %q", tt.wantError, combined) } if tt.wantOutput != "" && !strings.Contains(combined, tt.wantOutput) { t.Errorf("expected output to contain %q, got combined: %q", tt.wantOutput, combined) } }) } } func runCLI(t *testing.T, dbPath string, args ...string) (string, int) { t.Helper() cmd := exec.Command(os.Args[0], append(append([]string{"-test.run=TestHelperProcess", "--"}, args...), "--db", dbPath)...) cmd.Env = append(os.Environ(), "WANT_HELPER_PROCESS=1") var out bytes.Buffer cmd.Stdout = &out cmd.Stderr = &out err := cmd.Run() if exitErr, ok := err.(*exec.ExitError); ok { return out.String(), exitErr.ExitCode() } else if err != nil { t.Fatalf("running %v: %v", args, err) } return out.String(), 0 } func TestCLIAllowListCommands(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "spindle.db") listed := func() string { out, code := runCLI(t, dbPath, "allowlist", "list") if code != 0 { t.Fatalf("allowlist list = %d: %s", code, out) } return out } if out, code := runCLI(t, dbPath, "allowlist", "add", "alice"); code == 0 || !strings.Contains(out, "not a did or handle") { t.Fatalf("adding something that's neither a did nor a handle = %d: %s", code, out) } if out, code := runCLI(t, dbPath, "allowlist", "add", "did:plc:alice"); code != 0 { t.Fatalf("allowlist add = %d: %s", code, out) } if out := listed(); !strings.Contains(out, "did:plc:alice") { t.Fatalf("alice isn't listed after add: %q", out) } if out, code := runCLI(t, dbPath, "allowlist", "remove", "did:plc:alice"); code != 0 { t.Fatalf("allowlist remove = %d: %s", code, out) } if out := listed(); strings.Contains(out, "did:plc:alice") { t.Fatalf("alice is still listed after remove: %q", out) } } func TestCLIInviteMintsARedeemableCode(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "spindle.db") out, code := runCLI(t, dbPath, "invite") if code != 0 { t.Fatalf("invite = %d: %s", code, out) } // migrations log to the same output, so pick the code out invite := regexp.MustCompile(`\b[0-9a-f]{32}\b`).FindString(out) d, err := db.Make(t.Context(), dbPath) if err != nil { t.Fatal(err) } defer d.Close() if err := d.RedeemInvite(invite, "did:plc:bob", time.Now()); err != nil { t.Fatalf("redeeming the cli's code: %v", err) } listed, err := db.AllowlistContains(d, "did:plc:bob") if err != nil || !listed { t.Fatalf("bob listed = %v, %v after redeeming", listed, err) } } func TestCLIListsPerWorkflowLimits(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "spindle.db") for _, set := range [][]string{ {"--resource", quota.ResourceWorkflowVCPUs, "--limit", "6"}, {"--resource", quota.ResourceWorkflowMemoryMiB, "--limit", "8192"}, {"--resource", quota.ResourceWorkflowDiskMiB, "--limit", "20480"}, } { if out, code := runCLI(t, dbPath, append([]string{"quota", "set", "did:web:alice"}, set...)...); code != 0 { t.Fatalf("quota set %v exited %d: %s", set, code, out) } } out, code := runCLI(t, dbPath, "quota", "list") if code != 0 { t.Fatalf("quota list exited %d: %s", code, out) } for _, want := range []string{"6 vCPUs", "8192 MiB", "20480 MiB"} { if !strings.Contains(out, want) { t.Errorf("quota list = %q, want %q", out, want) } } } func TestNormalizeDefaultLimit(t *testing.T) { if got := normalizeDefaultLimit(0); got != -1 { t.Fatalf("zero configured limit = %d, want -1", got) } if got := normalizeDefaultLimit(2); got != 2 { t.Fatalf("finite configured limit = %d, want 2", got) } if got := formatLimit(quota.ResourceWorkflows, 0); got != "0 workflows" { t.Fatalf("zero override = %q, want 0 workflows", got) } }