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)
}
}