Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions cmd/wl/cmd_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,10 +174,11 @@ func validateSigning(value string) error {
}

func validateMode(value string) error {
switch value {
case federation.ModeWildWest, federation.ModePR:
return nil
default:
if value == "" {
return fmt.Errorf("invalid mode %q: must be %q or %q", value, federation.ModeWildWest, federation.ModePR)
}
if err := federation.ValidateMode(value); err != nil {
return fmt.Errorf("invalid mode %q: must be %q or %q", value, federation.ModeWildWest, federation.ModePR)
}
return nil
}
24 changes: 24 additions & 0 deletions cmd/wl/cmd_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package main

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -113,6 +115,28 @@ func TestRunConfigGet_ModeDefault(t *testing.T) {
}
}

func TestRunConfigGet_ModeInvalidConfig(t *testing.T) {
configHome := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", configHome)
cfgPath := filepath.Join(configHome, "wasteland", "wastelands", "hop", "wl-commons.json")
if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
data := []byte("{\n \"upstream\": \"hop/wl-commons\",\n \"fork_org\": \"alice\",\n \"fork_db\": \"wl-commons\",\n \"mode\": \"typo\",\n \"joined_at\": \"2026-03-09T00:00:00Z\"\n}\n")
if err := os.WriteFile(cfgPath, data, 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}

var stdout, stderr bytes.Buffer
err := runConfigGet(configCmd(), &stdout, &stderr, "mode")
if err == nil {
t.Fatal("runConfigGet(mode) expected error for invalid config")
}
if !strings.Contains(err.Error(), `invalid wasteland config: mode "typo" must be "pr" or "wild-west"`) {
t.Fatalf("runConfigGet(mode) error = %v", err)
}
}

func TestRunConfigGet_ProviderType(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
saveTestConfig(t, &federation.Config{
Expand Down
4 changes: 4 additions & 0 deletions cmd/wl/cmd_join.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ func runJoin(stdout, stderr io.Writer, upstream, handle, displayName, email, for
fmt.Fprintf(stdout, " Fork: %s/%s\n", existing.ForkOrg, existing.ForkDB)
fmt.Fprintf(stdout, " Local: %s\n", existing.LocalDir)
return nil
} else if !errors.Is(loadErr, federation.ErrNotJoined) {
return fmt.Errorf("loading wasteland config: %w", loadErr)
}

// Resolve fork org: flag > env var
Expand Down Expand Up @@ -228,6 +230,8 @@ func runJoinRemote(stdout, _ io.Writer, upstream, handle, displayName, email, fo
fmt.Fprintf(stdout, " Fork: %s/%s\n", existing.ForkOrg, existing.ForkDB)
fmt.Fprintf(stdout, " Backend: %s\n", existing.ResolveBackend())
return nil
} else if !errors.Is(loadErr, federation.ErrNotJoined) {
return fmt.Errorf("loading wasteland config: %w", loadErr)
}

// Resolve fork org: flag > env var
Expand Down
3 changes: 2 additions & 1 deletion internal/api/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"

"github.com/gastownhall/wasteland/internal/commons"
"github.com/gastownhall/wasteland/internal/federation"
"github.com/gastownhall/wasteland/internal/sdk"
"github.com/getsentry/sentry-go"
)
Expand Down Expand Up @@ -639,7 +640,7 @@ func (s *Server) handleSaveSettings(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
if req.Mode != "wild-west" && req.Mode != "pr" {
if req.Mode == "" || federation.ValidateMode(req.Mode) != nil {
writeError(w, http.StatusBadRequest, "mode must be \"wild-west\" or \"pr\"")
return
}
Expand Down
21 changes: 21 additions & 0 deletions internal/federation/federation.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,17 @@ func (c *Config) ResolveMode() string {
return c.Mode
}

// ValidateMode validates a workflow mode value.
// Empty mode is allowed because PR mode is the default when mode is unset.
func ValidateMode(mode string) error {
switch mode {
case "", ModePR, ModeWildWest:
return nil
default:
return fmt.Errorf("mode %q must be %q or %q", mode, ModePR, ModeWildWest)
}
}

// Backend constants.
const (
BackendRemote = "remote"
Expand Down Expand Up @@ -233,6 +244,8 @@ func (s *Service) Join(upstream, forkOrg, handle, displayName, ownerEmail, versi
// Check if already joined to this specific upstream (idempotent).
if existing, err := s.Config.Load(upstream); err == nil {
return &JoinResult{Config: existing}, nil
} else if !errors.Is(err, ErrNotJoined) {
return nil, fmt.Errorf("loading wasteland config: %w", err)
}

localDir := LocalCloneDir(upstreamOrg, upstreamDB)
Expand Down Expand Up @@ -358,6 +371,8 @@ func (s *Service) Create(opts CreateOptions) (*CreateResult, error) {
// Idempotent: if config already exists, return early.
if existing, err := s.Config.Load(opts.Upstream); err == nil {
return &CreateResult{Config: existing}, nil
} else if !errors.Is(err, ErrNotJoined) {
return nil, fmt.Errorf("loading wasteland config: %w", err)
}

localDir := LocalCloneDir(org, db)
Expand Down Expand Up @@ -734,10 +749,16 @@ func (f *fileConfigStore) Load(upstream string) (*Config, error) {
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing wasteland config: %w", err)
}
if err := ValidateMode(cfg.Mode); err != nil {
return nil, fmt.Errorf("invalid wasteland config: %w", err)
}
return &cfg, nil
}

func (f *fileConfigStore) Save(cfg *Config) error {
if err := ValidateMode(cfg.Mode); err != nil {
return fmt.Errorf("invalid wasteland config: %w", err)
}
path, err := f.configPath(cfg.Upstream)
if err != nil {
return err
Expand Down
43 changes: 38 additions & 5 deletions internal/federation/federation_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,12 +218,45 @@ func TestJoin_ConfigLoadError(t *testing.T) {
svc := &Service{Remote: provider, CLI: cli, Config: cfgStore}

_, err := svc.Join("steveyegge/wl-commons", "alice-dev", "alice-rig", "Alice", "[email protected]", "dev", false, false)
if err != nil {
t.Fatalf("Join() error: %v (expected success since LoadErr only affects Load)", err)
if err == nil {
t.Fatal("Join() expected error when config load fails")
}
if !strings.Contains(err.Error(), "loading wasteland config") {
t.Fatalf("Join() error = %v", err)
}
if len(provider.Calls) != 0 {
t.Errorf("expected no provider calls when config load fails, got %d", len(provider.Calls))
}
if len(cli.Calls) != 0 {
t.Errorf("expected no CLI calls when config load fails, got %d", len(cli.Calls))
}
}

func TestCreate_ConfigLoadError(t *testing.T) {
t.Parallel()
provider := NewFakeProvider()
cli := NewFakeDoltCLI()
cfgStore := NewFakeConfigStore()
cfgStore.LoadErr = fmt.Errorf("permission denied")

svc := &Service{Remote: provider, CLI: cli, Config: cfgStore}

_, err := svc.Create(CreateOptions{
Upstream: "myorg/wl-commons",
Handle: "myrig",
SchemaSQL: "CREATE TABLE test (id INT PRIMARY KEY);",
})
if err == nil {
t.Fatal("Create() expected error when config load fails")
}
if !strings.Contains(err.Error(), "loading wasteland config") {
t.Fatalf("Create() error = %v", err)
}
// Fork should have been called.
if !strings.HasPrefix(provider.Calls[0], "Fork") {
t.Errorf("expected first provider call to be Fork, got %q", provider.Calls[0])
if len(provider.Calls) != 0 {
t.Errorf("expected no provider calls when config load fails, got %d", len(provider.Calls))
}
if len(cli.Calls) != 0 {
t.Errorf("expected no CLI calls when config load fails, got %d", len(cli.Calls))
}
}

Expand Down
44 changes: 44 additions & 0 deletions internal/federation/federation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
Expand Down Expand Up @@ -101,6 +102,48 @@ func TestConfigLoadNotFound(t *testing.T) {
}
}

func TestConfigLoadInvalidMode(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tmpDir)

path := filepath.Join(tmpDir, "wasteland", "wastelands", "org", "db.json")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
data := []byte("{\n \"upstream\": \"org/db\",\n \"fork_org\": \"fork\",\n \"fork_db\": \"db\",\n \"mode\": \"typo\"\n}\n")
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}

store := NewConfigStore()
_, err := store.Load("org/db")
if err == nil {
t.Fatal("Load expected error for invalid mode")
}
if !strings.Contains(err.Error(), `invalid wasteland config: mode "typo" must be "pr" or "wild-west"`) {
t.Fatalf("Load error = %q", err)
}
}

func TestConfigSaveInvalidMode(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tmpDir)

store := NewConfigStore()
err := store.Save(&Config{
Upstream: "org/db",
ForkOrg: "fork",
ForkDB: "db",
Mode: "typo",
})
if err == nil {
t.Fatal("Save expected error for invalid mode")
}
if !strings.Contains(err.Error(), `invalid wasteland config: mode "typo" must be "pr" or "wild-west"`) {
t.Fatalf("Save error = %q", err)
}
}

func TestFileConfigStore_List(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tmpDir)
Expand Down Expand Up @@ -321,6 +364,7 @@ func TestResolveMode(t *testing.T) {
{"empty defaults to pr", "", ModePR},
{"explicit pr", ModePR, ModePR},
{"explicit wild-west", ModeWildWest, ModeWildWest},
{"invalid returned as-is", "typo", "typo"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
Expand Down
69 changes: 69 additions & 0 deletions internal/hosted/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,37 @@ func TestHandleConnect_MissingFields(t *testing.T) {
}
}

func TestHandleConnect_InvalidMode(t *testing.T) {
_, ts := setupHostedTestServer(t)

body := `{
"connection_id": "conn-1",
"rig_handle": "alice",
"fork_org": "alice-org",
"fork_db": "wl-commons",
"upstream": "wasteland/wl-commons",
"mode": "typo"
}`
resp, err := http.Post(ts.URL+"/api/auth/connect", "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close() //nolint:errcheck // test cleanup

if resp.StatusCode != http.StatusBadRequest {
respBody, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 400, got %d: %s", resp.StatusCode, string(respBody))
}

var result map[string]string
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("Decode: %v", err)
}
if result["error"] != `mode "typo" must be "pr" or "wild-west"` {
t.Fatalf("expected invalid mode error, got %q", result["error"])
}
}

func TestHandleAuthStatus_NotAuthenticated(t *testing.T) {
_, ts := setupHostedTestServer(t)

Expand Down Expand Up @@ -613,6 +644,44 @@ func TestHandleJoinWasteland_MissingFields(t *testing.T) {
}
}

func TestHandleJoinWasteland_InvalidMode(t *testing.T) {
sessions, ts := setupHostedTestServer(t)

sessionID, _ := sessions.Create("conn-1")

body := `{
"fork_org": "alice-org",
"fork_db": "gascity",
"upstream": "gastownhall/gascity",
"mode": "typo"
}`
req, _ := http.NewRequest("POST", ts.URL+"/api/auth/join", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{
Name: cookieName,
Value: SignSessionCookie(sessionID, "conn-1", testSecret),
})

resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close() //nolint:errcheck // test cleanup

if resp.StatusCode != http.StatusBadRequest {
respBody, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 400, got %d: %s", resp.StatusCode, string(respBody))
}

var result map[string]string
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("Decode: %v", err)
}
if result["error"] != `mode "typo" must be "pr" or "wild-west"` {
t.Fatalf("expected invalid mode error, got %q", result["error"])
}
}

func TestHandleJoinWasteland_NotAuthenticated(t *testing.T) {
_, ts := setupHostedTestServer(t)

Expand Down
5 changes: 4 additions & 1 deletion internal/hosted/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,10 +182,13 @@ func (wr *WorkspaceResolver) buildClient(wl *WastelandConfig, rigHandle, connect
if err != nil {
return nil, fmt.Errorf("parsing upstream %q: %w", wl.Upstream, err)
}
if err := federation.ValidateMode(wl.Mode); err != nil {
return nil, fmt.Errorf("invalid hosted wasteland config: %w", err)
}

mode := wl.Mode
if mode == "" {
mode = "pr"
mode = federation.ModePR
}

db := backend.NewRemoteDB(apiKey, upOrg, upDB, wl.ForkOrg, wl.ForkDB, mode)
Expand Down
Loading
Loading