diff --git a/internal/utils/cloudflare/dns_test.go b/internal/utils/cloudflare/dns_test.go new file mode 100644 index 0000000000..ee4d00c299 --- /dev/null +++ b/internal/utils/cloudflare/dns_test.go @@ -0,0 +1,128 @@ +package cloudflare + +import ( + "context" + "net/http" + "testing" + + "github.com/h2non/gock" + "github.com/stretchr/testify/assert" + "github.com/supabase/cli/internal/testing/apitest" +) + +func TestDNSQuery(t *testing.T) { + t.Run("successfully queries A records", func(t *testing.T) { + api := NewCloudflareAPI() + defer gock.OffAll() + gock.New("https://1.1.1.1"). + Get("/dns-query"). + MatchParam("name", "example.com"). + MatchHeader("accept", "application/dns-json"). + Reply(http.StatusOK). + JSON(DNSResponse{ + Answer: []DNSAnswer{ + { + Name: "example.com", + Type: TypeA, + Ttl: 300, + Data: "93.184.216.34", + }, + }, + }) + + resp, err := api.DNSQuery(context.Background(), DNSParams{ + Name: "example.com", + }) + + assert.NoError(t, err) + assert.Len(t, resp.Answer, 1) + assert.Equal(t, "93.184.216.34", resp.Answer[0].Data) + assert.Equal(t, TypeA, resp.Answer[0].Type) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("successfully queries specific DNS type", func(t *testing.T) { + api := NewCloudflareAPI() + dnsType := TypeCNAME + defer gock.OffAll() + gock.New("https://1.1.1.1"). + Get("/dns-query"). + MatchParam("name", "www.example.com"). + MatchParam("type", "5"). // TypeCNAME = 5 + MatchHeader("accept", "application/dns-json"). + Reply(http.StatusOK). + JSON(DNSResponse{ + Answer: []DNSAnswer{ + { + Name: "www.example.com", + Type: TypeCNAME, + Ttl: 3600, + Data: "example.com", + }, + }, + }) + + resp, err := api.DNSQuery(context.Background(), DNSParams{ + Name: "www.example.com", + Type: &dnsType, + }) + + assert.NoError(t, err) + assert.Len(t, resp.Answer, 1) + assert.Equal(t, "example.com", resp.Answer[0].Data) + assert.Equal(t, TypeCNAME, resp.Answer[0].Type) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("handles network error", func(t *testing.T) { + api := NewCloudflareAPI() + defer gock.OffAll() + gock.New("https://1.1.1.1"). + Get("/dns-query"). + MatchParam("name", "example.com"). + ReplyError(gock.ErrCannotMatch) + + resp, err := api.DNSQuery(context.Background(), DNSParams{ + Name: "example.com", + }) + + assert.Error(t, err) + assert.Empty(t, resp.Answer) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("handles service unavailable", func(t *testing.T) { + api := NewCloudflareAPI() + defer gock.OffAll() + gock.New("https://1.1.1.1"). + Get("/dns-query"). + MatchParam("name", "example.com"). + Reply(http.StatusServiceUnavailable) + + resp, err := api.DNSQuery(context.Background(), DNSParams{ + Name: "example.com", + }) + + assert.ErrorContains(t, err, "status 503") + assert.Empty(t, resp.Answer) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("handles malformed response", func(t *testing.T) { + api := NewCloudflareAPI() + defer gock.OffAll() + gock.New("https://1.1.1.1"). + Get("/dns-query"). + MatchParam("name", "example.com"). + Reply(http.StatusOK). + JSON("invalid json") + + resp, err := api.DNSQuery(context.Background(), DNSParams{ + Name: "example.com", + }) + + assert.ErrorContains(t, err, "failed to parse") + assert.Empty(t, resp.Answer) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) +} diff --git a/internal/utils/config_test.go b/internal/utils/config_test.go new file mode 100644 index 0000000000..9ac835170f --- /dev/null +++ b/internal/utils/config_test.go @@ -0,0 +1,168 @@ +package utils + +import ( + "os" + "testing" + + "github.com/spf13/afero" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetId(t *testing.T) { + t.Run("generates container id", func(t *testing.T) { + Config.ProjectId = "test-project" + name := "test-service" + + id := GetId(name) + + assert.Equal(t, "supabase_test-service_test-project", id) + }) +} + +func TestUpdateDockerIds(t *testing.T) { + t.Run("updates all container ids", func(t *testing.T) { + Config.ProjectId = "test-project" + viper.Set("network-id", "custom-network") + defer viper.Reset() + + UpdateDockerIds() + + assert.Equal(t, "custom-network", NetId) + assert.Equal(t, "supabase_db_test-project", DbId) + assert.Equal(t, "supabase_kong_test-project", KongId) + assert.Equal(t, "supabase_auth_test-project", GotrueId) + assert.Equal(t, "supabase_inbucket_test-project", InbucketId) + assert.Equal(t, "supabase_realtime_test-project", RealtimeId) + assert.Equal(t, "supabase_rest_test-project", RestId) + assert.Equal(t, "supabase_storage_test-project", StorageId) + assert.Equal(t, "supabase_imgproxy_test-project", ImgProxyId) + assert.Equal(t, "supabase_differ_test-project", DifferId) + assert.Equal(t, "supabase_pg_meta_test-project", PgmetaId) + assert.Equal(t, "supabase_studio_test-project", StudioId) + assert.Equal(t, "supabase_edge_runtime_test-project", EdgeRuntimeId) + assert.Equal(t, "supabase_analytics_test-project", LogflareId) + assert.Equal(t, "supabase_vector_test-project", VectorId) + assert.Equal(t, "supabase_pooler_test-project", PoolerId) + }) + + t.Run("generates network id if not set", func(t *testing.T) { + Config.ProjectId = "test-project" + viper.Reset() + + UpdateDockerIds() + + assert.Equal(t, "supabase_network_test-project", NetId) + }) +} + +func TestInitConfig(t *testing.T) { + t.Run("creates new config file", func(t *testing.T) { + fsys := afero.NewMemMapFs() + params := InitParams{ + ProjectId: "test-project", + } + + err := InitConfig(params, fsys) + + assert.NoError(t, err) + exists, err := afero.Exists(fsys, ConfigPath) + assert.NoError(t, err) + assert.True(t, exists) + }) + + t.Run("creates config with orioledb", func(t *testing.T) { + fsys := afero.NewMemMapFs() + params := InitParams{ + ProjectId: "test-project", + UseOrioleDB: true, + } + + err := InitConfig(params, fsys) + + assert.NoError(t, err) + content, err := afero.ReadFile(fsys, ConfigPath) + assert.NoError(t, err) + assert.Contains(t, string(content), "15.1.0.150") + }) + + t.Run("fails if config exists and no overwrite", func(t *testing.T) { + fsys := afero.NewMemMapFs() + err := afero.WriteFile(fsys, ConfigPath, []byte("existing"), 0644) + require.NoError(t, err) + params := InitParams{ + ProjectId: "test-project", + } + + err = InitConfig(params, fsys) + + assert.ErrorIs(t, err, os.ErrExist) + }) + + t.Run("overwrites existing config when specified", func(t *testing.T) { + fsys := afero.NewMemMapFs() + err := afero.WriteFile(fsys, ConfigPath, []byte("existing"), 0644) + require.NoError(t, err) + params := InitParams{ + ProjectId: "test-project", + Overwrite: true, + } + + err = InitConfig(params, fsys) + + assert.NoError(t, err) + content, err := afero.ReadFile(fsys, ConfigPath) + assert.NoError(t, err) + assert.NotEqual(t, "existing", string(content)) + }) +} + +func TestGetApiUrl(t *testing.T) { + t.Run("returns external url when configured", func(t *testing.T) { + Config.Api.ExternalUrl = "https://api.example.com" + + url := GetApiUrl("/test") + + assert.Equal(t, "https://api.example.com/test", url) + }) + + t.Run("builds url from hostname and port", func(t *testing.T) { + Config.Hostname = "localhost" + Config.Api.Port = 8000 + Config.Api.ExternalUrl = "" + + url := GetApiUrl("/test") + + assert.Equal(t, "http://localhost:8000/test", url) + }) +} + +func TestRootFS(t *testing.T) { + t.Run("opens file from root fs", func(t *testing.T) { + fsys := afero.NewMemMapFs() + content := []byte("test content") + err := afero.WriteFile(fsys, "/test.txt", content, 0644) + require.NoError(t, err) + + rootfs := NewRootFS(fsys) + f, err := rootfs.Open("/test.txt") + assert.NoError(t, err) + defer f.Close() + + buf := make([]byte, len(content)) + n, err := f.Read(buf) + assert.NoError(t, err) + assert.Equal(t, len(content), n) + assert.Equal(t, content, buf) + }) + + t.Run("returns error for non-existent file", func(t *testing.T) { + fsys := afero.NewMemMapFs() + rootfs := NewRootFS(fsys) + + _, err := rootfs.Open("/non-existent.txt") + + assert.Error(t, err) + }) +} diff --git a/internal/utils/container_output_test.go b/internal/utils/container_output_test.go new file mode 100644 index 0000000000..cf3e529bb3 --- /dev/null +++ b/internal/utils/container_output_test.go @@ -0,0 +1,171 @@ +package utils + +import ( + "bytes" + "encoding/json" + "io" + "sync" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/docker/docker/pkg/jsonmessage" + "github.com/docker/docker/pkg/stdcopy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProcessDiffOutput(t *testing.T) { + t.Run("processes valid diff entries", func(t *testing.T) { + input := []DiffEntry{ + { + Type: "table", + Status: "Different", + DiffDdl: "ALTER TABLE test;", + GroupName: "public", + }, + { + Type: "extension", + Status: "Different", + DiffDdl: "CREATE EXTENSION test;", + GroupName: "public", + }, + } + inputBytes, err := json.Marshal(input) + require.NoError(t, err) + + output, err := ProcessDiffOutput(inputBytes) + + assert.NoError(t, err) + assert.Contains(t, string(output), "ALTER TABLE test;") + assert.Contains(t, string(output), "CREATE EXTENSION test;") + }) + + t.Run("filters out internal schemas", func(t *testing.T) { + input := []DiffEntry{ + { + Type: "table", + Status: "Different", + DiffDdl: "ALTER TABLE test;", + GroupName: "auth", + }, + { + Type: "extension", + Status: "Different", + DiffDdl: "CREATE EXTENSION test;", + GroupName: "auth", + }, + } + inputBytes, err := json.Marshal(input) + require.NoError(t, err) + + output, err := ProcessDiffOutput(inputBytes) + + assert.NoError(t, err) + assert.Nil(t, output) + }) +} + +func TestProcessPsqlOutput(t *testing.T) { + t.Run("processes psql output", func(t *testing.T) { + var buf bytes.Buffer + writer := stdcopy.NewStdWriter(&buf, stdcopy.Stdout) + _, err := writer.Write([]byte("test output\n")) + require.NoError(t, err) + + var lastLine *string + p := NewMockProgram(func(msg tea.Msg) { + if m, ok := msg.(PsqlMsg); ok { + lastLine = m + } + }) + + err = ProcessPsqlOutput(&buf, p) + + assert.NoError(t, err) + assert.Nil(t, lastLine) + }) + + t.Run("handles stderr output", func(t *testing.T) { + var buf bytes.Buffer + writer := stdcopy.NewStdWriter(&buf, stdcopy.Stderr) + _, err := writer.Write([]byte("error message\n")) + require.NoError(t, err) + + p := NewMockProgram(nil) + + err = ProcessPsqlOutput(&buf, p) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "error message") + }) +} + +func TestProcessPullOutput(t *testing.T) { + t.Run("processes docker pull messages", func(t *testing.T) { + messages := []jsonmessage.JSONMessage{ + {Status: "Pulling from library/postgres"}, + {ID: "layer1", Status: "Pulling fs layer"}, + {ID: "layer1", Status: "Downloading", Progress: &jsonmessage.JSONProgress{Current: 50, Total: 100}}, + {ID: "layer2", Status: "Pulling fs layer"}, + {ID: "layer2", Status: "Downloading", Progress: &jsonmessage.JSONProgress{Current: 75, Total: 100}}, + } + + // Create a pipe to write messages + r, w := io.Pipe() + enc := json.NewEncoder(w) + go func() { + for _, msg := range messages { + err := enc.Encode(msg) + assert.Nil(t, err) + } + w.Close() + }() + + var status string + var progressFirst *float64 + var progress *float64 + p := NewMockProgram(func(msg tea.Msg) { + switch m := msg.(type) { + case StatusMsg: + status = string(m) + case ProgressMsg: + progress = m + if progressFirst == nil { + progressFirst = m + } + } + }) + + err := ProcessPullOutput(r, p) + + assert.NoError(t, err) + assert.Equal(t, "Pulling from library/postgres...", status) + assert.Equal(t, *progressFirst, 0.5) + assert.Nil(t, progress) + }) +} + +type MockProgram struct { + handler func(tea.Msg) + mu sync.Mutex // Add mutex for thread safety +} + +func NewMockProgram(handler func(tea.Msg)) *MockProgram { + return &MockProgram{ + handler: handler, + } +} + +func (m *MockProgram) Send(msg tea.Msg) { + if m.handler != nil { + m.mu.Lock() + m.handler(msg) + m.mu.Unlock() + } +} + +func (m *MockProgram) Start() error { + return nil +} + +func (m *MockProgram) Quit() {} diff --git a/internal/utils/credentials/store_test.go b/internal/utils/credentials/store_test.go new file mode 100644 index 0000000000..4bb62f6581 --- /dev/null +++ b/internal/utils/credentials/store_test.go @@ -0,0 +1,99 @@ +package credentials + +import ( + "testing" + + "github.com/go-errors/errors" + "github.com/stretchr/testify/assert" + "github.com/zalando/go-keyring" +) + +func TestKeyringStore(t *testing.T) { + t.Run("stores and retrieves password", func(t *testing.T) { + keyring.MockInit() + project := "test-project" + password := "test-password" + + err := StoreProvider.Set(project, password) + assert.NoError(t, err) + + retrieved, err := StoreProvider.Get(project) + assert.NoError(t, err) + assert.Equal(t, password, retrieved) + }) + + t.Run("returns error for non-existent project", func(t *testing.T) { + keyring.MockInit() + project := "non-existent" + + retrieved, err := StoreProvider.Get(project) + assert.ErrorIs(t, err, keyring.ErrNotFound) + assert.Empty(t, retrieved) + }) + + t.Run("deletes specific project password", func(t *testing.T) { + keyring.MockInit() + project := "test-project" + password := "test-password" + + err := StoreProvider.Set(project, password) + assert.NoError(t, err) + err = StoreProvider.Delete(project) + assert.NoError(t, err) + + _, err = StoreProvider.Get(project) + assert.ErrorIs(t, err, keyring.ErrNotFound) + }) + + t.Run("deletes all project passwords", func(t *testing.T) { + keyring.MockInit() + projects := []string{"project1", "project2"} + + for _, project := range projects { + err := StoreProvider.Set(project, "password") + assert.NoError(t, err) + } + + err := StoreProvider.DeleteAll() + assert.NoError(t, err) + + for _, project := range projects { + _, err := StoreProvider.Get(project) + assert.ErrorIs(t, err, keyring.ErrNotFound) + } + }) +} + +func TestKeyringErrors(t *testing.T) { + t.Run("handles Get error", func(t *testing.T) { + mockErr := errors.New("mock error") + keyring.MockInitWithError(mockErr) + + _, err := StoreProvider.Get("test") + assert.ErrorIs(t, err, mockErr) + }) + + t.Run("handles Set error", func(t *testing.T) { + mockErr := errors.New("mock error") + keyring.MockInitWithError(mockErr) + + err := StoreProvider.Set("test", "pass") + assert.ErrorIs(t, err, mockErr) + }) + + t.Run("handles Delete error", func(t *testing.T) { + mockErr := errors.New("mock error") + keyring.MockInitWithError(mockErr) + + err := StoreProvider.Delete("test") + assert.ErrorIs(t, err, mockErr) + }) + + t.Run("handles DeleteAll error", func(t *testing.T) { + mockErr := errors.New("mock error") + keyring.MockInitWithError(mockErr) + + err := StoreProvider.DeleteAll() + assert.ErrorIs(t, err, mockErr) + }) +} diff --git a/internal/utils/deno_test.go b/internal/utils/deno_test.go index a20b3acacd..c5b140a8f7 100644 --- a/internal/utils/deno_test.go +++ b/internal/utils/deno_test.go @@ -1,6 +1,10 @@ package utils import ( + "context" + "os" + "path/filepath" + "runtime" "testing" "github.com/spf13/afero" @@ -34,3 +38,110 @@ import "./child/index.ts"` }) }) } + +func TestGetDenoPath(t *testing.T) { + t.Run("returns override path when set", func(t *testing.T) { + override := "/custom/path/to/deno" + DenoPathOverride = override + defer func() { DenoPathOverride = "" }() + + path, err := GetDenoPath() + + assert.NoError(t, err) + assert.Equal(t, override, path) + }) + + t.Run("returns default path", func(t *testing.T) { + home, err := os.UserHomeDir() + require.NoError(t, err) + expected := filepath.Join(home, ".supabase", "deno") + if runtime.GOOS == "windows" { + expected += ".exe" + } + + path, err := GetDenoPath() + + assert.NoError(t, err) + assert.Equal(t, expected, path) + }) +} + +func TestIsScriptModified(t *testing.T) { + t.Run("detects modified script", func(t *testing.T) { + fsys := afero.NewMemMapFs() + destPath := "/path/to/script.ts" + original := []byte("original content") + modified := []byte("modified content") + require.NoError(t, afero.WriteFile(fsys, destPath, modified, 0644)) + + isModified, err := isScriptModified(fsys, destPath, original) + + assert.NoError(t, err) + assert.True(t, isModified) + }) + + t.Run("detects unmodified script", func(t *testing.T) { + fsys := afero.NewMemMapFs() + destPath := "/path/to/script.ts" + content := []byte("test content") + require.NoError(t, afero.WriteFile(fsys, destPath, content, 0644)) + + isModified, err := isScriptModified(fsys, destPath, content) + + assert.NoError(t, err) + assert.False(t, isModified) + }) + + t.Run("handles non-existent script", func(t *testing.T) { + fsys := afero.NewMemMapFs() + destPath := "/path/to/script.ts" + content := []byte("test content") + + isModified, err := isScriptModified(fsys, destPath, content) + + assert.NoError(t, err) + assert.True(t, isModified) + }) +} + +func TestCopyDenoScripts(t *testing.T) { + t.Run("copies deno scripts", func(t *testing.T) { + fsys := afero.NewMemMapFs() + home, err := os.UserHomeDir() + require.NoError(t, err) + denoDir := filepath.Join(home, ".supabase") + require.NoError(t, fsys.MkdirAll(denoDir, 0755)) + + scripts, err := CopyDenoScripts(context.Background(), fsys) + + assert.NoError(t, err) + assert.NotNil(t, scripts) + extractExists, err := afero.Exists(fsys, scripts.ExtractPath) + assert.NoError(t, err) + assert.True(t, extractExists) + }) + + t.Run("skips copying unmodified scripts", func(t *testing.T) { + fsys := afero.NewMemMapFs() + home, err := os.UserHomeDir() + require.NoError(t, err) + scriptDir := filepath.Join(home, ".supabase", "denos") + require.NoError(t, fsys.MkdirAll(scriptDir, 0755)) + + scripts1, err := CopyDenoScripts(context.Background(), fsys) + require.NoError(t, err) + stat1, err := fsys.Stat(scripts1.ExtractPath) + require.NoError(t, err) + modTime1 := stat1.ModTime() + + // Second copy + scripts2, err := CopyDenoScripts(context.Background(), fsys) + require.NoError(t, err) + stat2, err := fsys.Stat(scripts2.ExtractPath) + require.NoError(t, err) + modTime2 := stat2.ModTime() + + // Verify file wasn't rewritten + assert.Equal(t, modTime1, modTime2) + }) +} diff --git a/internal/utils/flags/db_url_test.go b/internal/utils/flags/db_url_test.go new file mode 100644 index 0000000000..ba9676b5fc --- /dev/null +++ b/internal/utils/flags/db_url_test.go @@ -0,0 +1,118 @@ +package flags + +import ( + "os" + "testing" + + "github.com/spf13/afero" + "github.com/spf13/pflag" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/supabase/cli/internal/testing/apitest" + "github.com/supabase/cli/internal/utils" +) + +func TestParseDatabaseConfig(t *testing.T) { + t.Run("parses direct connection from db-url flag", func(t *testing.T) { + flagSet := pflag.NewFlagSet("test", pflag.ContinueOnError) + flagSet.String("db-url", "postgres://postgres:password@localhost:5432/postgres", "") + err := flagSet.Set("db-url", "postgres://admin:secret@db.example.com:6432/app") + assert.Nil(t, err) + + fsys := afero.NewMemMapFs() + + err = ParseDatabaseConfig(flagSet, fsys) + + assert.NoError(t, err) + assert.Equal(t, "db.example.com", DbConfig.Host) + assert.Equal(t, uint16(6432), DbConfig.Port) + assert.Equal(t, "admin", DbConfig.User) + assert.Equal(t, "secret", DbConfig.Password) + assert.Equal(t, "app", DbConfig.Database) + }) + + t.Run("parses local connection", func(t *testing.T) { + flagSet := pflag.NewFlagSet("test", pflag.ContinueOnError) + flagSet.Bool("local", false, "") + err := flagSet.Set("local", "true") + assert.Nil(t, err) + + fsys := afero.NewMemMapFs() + + utils.Config.Hostname = "localhost" + utils.Config.Db.Port = 54322 + utils.Config.Db.Password = "local-password" + + err = ParseDatabaseConfig(flagSet, fsys) + + assert.NoError(t, err) + assert.Equal(t, "localhost", DbConfig.Host) + assert.Equal(t, uint16(54322), DbConfig.Port) + assert.Equal(t, "postgres", DbConfig.User) + assert.Equal(t, "local-password", DbConfig.Password) + assert.Equal(t, "postgres", DbConfig.Database) + }) + + t.Run("parses linked connection", func(t *testing.T) { + flagSet := pflag.NewFlagSet("test", pflag.ContinueOnError) + flagSet.Bool("linked", false, "") + err := flagSet.Set("linked", "true") + assert.Nil(t, err) + + fsys := afero.NewMemMapFs() + + project := apitest.RandomProjectRef() + err = afero.WriteFile(fsys, utils.ProjectRefPath, []byte(project), 0644) + require.NoError(t, err) + + err = ParseDatabaseConfig(flagSet, fsys) + + assert.NoError(t, err) + assert.Equal(t, utils.GetSupabaseDbHost(project), DbConfig.Host) + }) +} + +func TestPromptPassword(t *testing.T) { + t.Run("returns user input when provided", func(t *testing.T) { + r, w, err := os.Pipe() + require.NoError(t, err) + defer r.Close() + go func() { + defer w.Close() + _, err := w.Write([]byte("test-password")) + assert.Nil(t, err) + }() + + password := PromptPassword(r) + + assert.Equal(t, "test-password", password) + }) + + t.Run("generates password when input is empty", func(t *testing.T) { + r, w, err := os.Pipe() + require.NoError(t, err) + defer r.Close() + go func() { + defer w.Close() + _, err := w.Write([]byte("")) + assert.Nil(t, err) + }() + + password := PromptPassword(r) + + assert.Len(t, password, PASSWORD_LENGTH) + assert.NotEqual(t, "", password) + }) +} + +func TestGetDbConfigOptionalPassword(t *testing.T) { + t.Run("uses environment variable when available", func(t *testing.T) { + viper.Set("DB_PASSWORD", "env-password") + projectRef := apitest.RandomProjectRef() + + config := GetDbConfigOptionalPassword(projectRef) + + assert.Equal(t, "env-password", config.Password) + }) +} diff --git a/internal/utils/misc_test.go b/internal/utils/misc_test.go index 6472c2145f..b12d68c789 100644 --- a/internal/utils/misc_test.go +++ b/internal/utils/misc_test.go @@ -75,3 +75,114 @@ func TestProjectRoot(t *testing.T) { assert.Equal(t, cwd, path) }) } + +func TestShortContainerImageName(t *testing.T) { + t.Run("extracts short name from image", func(t *testing.T) { + input := "registry.supabase.com/postgres:15.1.0.99" + expected := "postgres" + + result := ShortContainerImageName(input) + + assert.Equal(t, expected, result) + }) +} + +func TestIsBranchNameReserved(t *testing.T) { + t.Run("identifies reserved names", func(t *testing.T) { + reserved := []string{"_current_branch", "main"} + for _, name := range reserved { + assert.True(t, IsBranchNameReserved(name), "Expected %s to be reserved", name) + } + }) + + t.Run("allows custom names", func(t *testing.T) { + allowed := []string{"my-feature", "test-branch-123"} + for _, name := range allowed { + assert.False(t, IsBranchNameReserved(name), "Expected %s to be allowed", name) + } + }) +} + +func TestValidateFunctionSlug(t *testing.T) { + t.Run("validates correct slugs", func(t *testing.T) { + valid := []string{ + "my-function", + "MyFunction", + "function_1", + "a123", + } + for _, slug := range valid { + err := ValidateFunctionSlug(slug) + assert.NoError(t, err, "Expected %s to be valid", slug) + } + }) + + t.Run("rejects invalid slugs", func(t *testing.T) { + invalid := []string{ + "1function", + "my function", + "function!", + "", + "-function", + "_function", + } + for _, slug := range invalid { + err := ValidateFunctionSlug(slug) + assert.ErrorIs(t, err, ErrInvalidSlug, "Expected %s to be invalid", slug) + } + }) +} + +func TestAssertProjectRefIsValid(t *testing.T) { + t.Run("validates correct refs", func(t *testing.T) { + validRef := "abcdefghijklmnopqrst" + err := AssertProjectRefIsValid(validRef) + assert.NoError(t, err) + }) + + t.Run("rejects invalid refs", func(t *testing.T) { + invalid := []string{ + "tooshort", + "toolongabcdefghijklmnopqrst", + "UPPERCASE", + "special-chars", + "123", + "", + } + for _, ref := range invalid { + err := AssertProjectRefIsValid(ref) + assert.ErrorIs(t, err, ErrInvalidRef, "Expected %s to be invalid", ref) + } + }) +} + +func TestWriteFile(t *testing.T) { + t.Run("writes file with directories", func(t *testing.T) { + fsys := afero.NewMemMapFs() + path := filepath.Join("deep", "nested", "dir", "file.txt") + content := []byte("test content") + + err := WriteFile(path, content, fsys) + + assert.NoError(t, err) + + written, err := afero.ReadFile(fsys, path) + assert.NoError(t, err) + assert.Equal(t, content, written) + }) + + t.Run("overwrites existing file", func(t *testing.T) { + fsys := afero.NewMemMapFs() + path := "test.txt" + original := []byte("original") + updated := []byte("updated") + require.NoError(t, afero.WriteFile(fsys, path, original, 0644)) + + err := WriteFile(path, updated, fsys) + + assert.NoError(t, err) + written, err := afero.ReadFile(fsys, path) + assert.NoError(t, err) + assert.Equal(t, updated, written) + }) +} diff --git a/internal/utils/output_test.go b/internal/utils/output_test.go new file mode 100644 index 0000000000..088136ebac --- /dev/null +++ b/internal/utils/output_test.go @@ -0,0 +1,141 @@ +package utils + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEncodeOutput(t *testing.T) { + t.Run("encodes env format", func(t *testing.T) { + input := map[string]string{ + "DATABASE_URL": "postgres://user:pass@host:5432/db", + "API_KEY": "secret-key", + } + var buf bytes.Buffer + err := EncodeOutput(OutputEnv, &buf, input) + assert.NoError(t, err) + expected := "API_KEY=\"secret-key\"\nDATABASE_URL=\"postgres://user:pass@host:5432/db\"\n" + assert.Equal(t, expected, buf.String()) + }) + + t.Run("fails env format with invalid type", func(t *testing.T) { + input := map[string]int{"FOO": 123} + var buf bytes.Buffer + err := EncodeOutput(OutputEnv, &buf, input) + assert.ErrorContains(t, err, "value is not a map[string]string") + }) + + t.Run("encodes json format", func(t *testing.T) { + input := map[string]interface{}{ + "name": "test", + "config": map[string]interface{}{ + "port": 5432, + "enabled": true, + }, + } + var buf bytes.Buffer + err := EncodeOutput(OutputJson, &buf, input) + assert.NoError(t, err) + expected := `{ + "config": { + "enabled": true, + "port": 5432 + }, + "name": "test" +} +` + assert.Equal(t, expected, buf.String()) + }) + + t.Run("encodes yaml format", func(t *testing.T) { + input := map[string]interface{}{ + "name": "test", + "config": map[string]interface{}{ + "port": 5432, + "enabled": true, + }, + } + var buf bytes.Buffer + err := EncodeOutput(OutputYaml, &buf, input) + assert.NoError(t, err) + expected := `config: + enabled: true + port: 5432 +name: test +` + assert.Equal(t, expected, buf.String()) + }) + + t.Run("encodes toml format", func(t *testing.T) { + input := map[string]interface{}{ + "name": "test", + "config": map[string]interface{}{ + "port": 5432, + "enabled": true, + }, + } + var buf bytes.Buffer + err := EncodeOutput(OutputToml, &buf, input) + assert.NoError(t, err) + expected := `name = "test" + +[config] + enabled = true + port = 5432 +` + assert.Equal(t, expected, buf.String()) + }) + + t.Run("fails with unsupported format", func(t *testing.T) { + var buf bytes.Buffer + err := EncodeOutput("invalid", &buf, nil) + assert.ErrorContains(t, err, `Unsupported output encoding "invalid"`) + }) + + t.Run("handles complex nested structures", func(t *testing.T) { + input := map[string]interface{}{ + "database": map[string]interface{}{ + "connections": []map[string]interface{}{ + { + "host": "localhost", + "port": 5432, + }, + { + "host": "remote", + "port": 6543, + }, + }, + "settings": map[string]interface{}{ + "max_connections": 100, + "ssl_enabled": true, + }, + }, + } + var buf bytes.Buffer + err := EncodeOutput(OutputJson, &buf, input) + require.NoError(t, err) + expected := `{ + "database": { + "connections": [ + { + "host": "localhost", + "port": 5432 + }, + { + "host": "remote", + "port": 6543 + } + ], + "settings": { + "max_connections": 100, + "ssl_enabled": true + } + } +} +` + assert.Equal(t, expected, buf.String()) + }) +} diff --git a/internal/utils/slice_test.go b/internal/utils/slice_test.go index 81642c73ff..47bdea171f 100644 --- a/internal/utils/slice_test.go +++ b/internal/utils/slice_test.go @@ -7,9 +7,59 @@ import ( ) func TestSliceEqual(t *testing.T) { - assert.False(t, SliceEqual([]string{"a"}, []string{"b"})) + t.Run("different slices", func(t *testing.T) { + assert.False(t, SliceEqual([]string{"a"}, []string{"b"})) + }) + + t.Run("different lengths", func(t *testing.T) { + assert.False(t, SliceEqual([]string{"a"}, []string{"a", "b"})) + assert.False(t, SliceEqual([]string{"a", "b"}, []string{"a"})) + }) + + t.Run("equal slices", func(t *testing.T) { + assert.True(t, SliceEqual([]string{"a", "b"}, []string{"a", "b"})) + assert.True(t, SliceEqual([]int{1, 2, 3}, []int{1, 2, 3})) + }) + + t.Run("empty slices", func(t *testing.T) { + assert.True(t, SliceEqual([]string{}, []string{})) + }) } func TestSliceContains(t *testing.T) { - assert.False(t, SliceContains([]string{"a"}, "b")) + t.Run("not contains element", func(t *testing.T) { + assert.False(t, SliceContains([]string{"a"}, "b")) + }) + + t.Run("contains element", func(t *testing.T) { + assert.True(t, SliceContains([]string{"a", "b", "c"}, "b")) + assert.True(t, SliceContains([]int{1, 2, 3}, 2)) + }) + + t.Run("empty slice", func(t *testing.T) { + assert.False(t, SliceContains([]string{}, "a")) + }) +} + +func TestRemoveDuplicates(t *testing.T) { + t.Run("string slice", func(t *testing.T) { + input := []string{"a", "b", "a", "c", "b", "d"} + expected := []string{"a", "b", "c", "d"} + assert.Equal(t, expected, RemoveDuplicates(input)) + }) + + t.Run("int slice", func(t *testing.T) { + input := []int{1, 2, 2, 3, 1, 4, 3, 5} + expected := []int{1, 2, 3, 4, 5} + assert.Equal(t, expected, RemoveDuplicates(input)) + }) + + t.Run("empty slice", func(t *testing.T) { + assert.Empty(t, RemoveDuplicates([]string{})) + }) + + t.Run("no duplicates", func(t *testing.T) { + input := []int{1, 2, 3, 4, 5} + assert.Equal(t, input, RemoveDuplicates(input)) + }) } diff --git a/internal/utils/tenant/client_test.go b/internal/utils/tenant/client_test.go new file mode 100644 index 0000000000..6057f502f2 --- /dev/null +++ b/internal/utils/tenant/client_test.go @@ -0,0 +1,151 @@ +package tenant + +import ( + "context" + "net/http" + "testing" + + "github.com/h2non/gock" + "github.com/stretchr/testify/assert" + "github.com/supabase/cli/internal/testing/apitest" + "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/pkg/api" +) + +func TestApiKey(t *testing.T) { + t.Run("creates api key from response", func(t *testing.T) { + resp := []api.ApiKeyResponse{ + {Name: "anon", ApiKey: "anon-key"}, + {Name: "service_role", ApiKey: "service-key"}, + } + + keys := NewApiKey(resp) + + assert.Equal(t, "anon-key", keys.Anon) + assert.Equal(t, "service-key", keys.ServiceRole) + assert.False(t, keys.IsEmpty()) + }) + + t.Run("handles empty response", func(t *testing.T) { + resp := []api.ApiKeyResponse{} + + keys := NewApiKey(resp) + + assert.Empty(t, keys.Anon) + assert.Empty(t, keys.ServiceRole) + assert.True(t, keys.IsEmpty()) + }) + + t.Run("handles partial response", func(t *testing.T) { + resp := []api.ApiKeyResponse{ + {Name: "anon", ApiKey: "anon-key"}, + } + + keys := NewApiKey(resp) + + assert.Equal(t, "anon-key", keys.Anon) + assert.Empty(t, keys.ServiceRole) + assert.False(t, keys.IsEmpty()) + }) +} + +func TestGetApiKeys(t *testing.T) { + t.Run("retrieves api keys successfully", func(t *testing.T) { + token := apitest.RandomAccessToken(t) + t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) + + defer gock.OffAll() + projectRef := apitest.RandomProjectRef() + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + projectRef + "/api-keys"). + Reply(http.StatusOK). + JSON([]api.ApiKeyResponse{ + {Name: "anon", ApiKey: "anon-key"}, + {Name: "service_role", ApiKey: "service-key"}, + }) + + keys, err := GetApiKeys(context.Background(), projectRef) + + assert.NoError(t, err) + assert.Equal(t, "anon-key", keys.Anon) + assert.Equal(t, "service-key", keys.ServiceRole) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("handles network error", func(t *testing.T) { + token := apitest.RandomAccessToken(t) + t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) + + defer gock.OffAll() + projectRef := apitest.RandomProjectRef() + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + projectRef + "/api-keys"). + ReplyError(gock.ErrCannotMatch) + + keys, err := GetApiKeys(context.Background(), projectRef) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to get api keys") + assert.True(t, keys.IsEmpty()) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("handles unauthorized error", func(t *testing.T) { + token := apitest.RandomAccessToken(t) + t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) + + defer gock.OffAll() + projectRef := apitest.RandomProjectRef() + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + projectRef + "/api-keys"). + Reply(http.StatusUnauthorized). + JSON(map[string]string{"message": "unauthorized"}) + + keys, err := GetApiKeys(context.Background(), projectRef) + + assert.ErrorIs(t, err, ErrAuthToken) + assert.True(t, keys.IsEmpty()) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("handles missing anon key", func(t *testing.T) { + token := apitest.RandomAccessToken(t) + t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) + + defer gock.OffAll() + projectRef := apitest.RandomProjectRef() + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + projectRef + "/api-keys"). + Reply(http.StatusOK). + JSON([]api.ApiKeyResponse{}) // should this error if response has only service_role key? + + keys, err := GetApiKeys(context.Background(), projectRef) + + assert.ErrorIs(t, err, errMissingKey) + assert.True(t, keys.IsEmpty()) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) +} + +func TestNewTenantAPI(t *testing.T) { + t.Run("creates tenant api client", func(t *testing.T) { + projectRef := apitest.RandomProjectRef() + anonKey := "test-key" + + api := NewTenantAPI(context.Background(), projectRef, anonKey) + + assert.NotNil(t, api.Fetcher) + + defer gock.OffAll() + gock.New("https://"+utils.GetSupabaseHost(projectRef)). + Get("/test"). + MatchHeader("apikey", anonKey). + MatchHeader("User-Agent", "SupabaseCLI/"+utils.Version). + Reply(http.StatusOK) + + _, err := api.Send(context.Background(), http.MethodGet, "/test", nil) + + assert.NoError(t, err) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) +} diff --git a/internal/utils/tenant/database_test.go b/internal/utils/tenant/database_test.go new file mode 100644 index 0000000000..c6680f2b72 --- /dev/null +++ b/internal/utils/tenant/database_test.go @@ -0,0 +1,88 @@ +package tenant + +import ( + "context" + "net/http" + "testing" + + "github.com/h2non/gock" + "github.com/stretchr/testify/assert" + "github.com/supabase/cli/internal/testing/apitest" + "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/pkg/api" +) + +func TestGetDatabaseVersion(t *testing.T) { + t.Run("retrieves database version successfully", func(t *testing.T) { + token := apitest.RandomAccessToken(t) + t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) + + defer gock.OffAll() + projectRef := apitest.RandomProjectRef() + gock.New(utils.DefaultApiHost). + Get("/v1/projects"). + Reply(http.StatusOK). + JSON([]api.V1ProjectWithDatabaseResponse{ + { + Id: projectRef, + Database: api.V1DatabaseResponse{ + Version: "14.1.0.99", + }, + }, + }) + + version, err := GetDatabaseVersion(context.Background(), projectRef) + + assert.NoError(t, err) + assert.Equal(t, "14.1.0.99", version) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("handles project not found", func(t *testing.T) { + token := apitest.RandomAccessToken(t) + t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) + + defer gock.OffAll() + projectRef := apitest.RandomProjectRef() + gock.New(utils.DefaultApiHost). + Get("/v1/projects"). + Reply(http.StatusOK). + JSON([]api.V1ProjectWithDatabaseResponse{ + { + Id: "different-project", + Database: api.V1DatabaseResponse{ + Version: "14.1.0.99", + }, + }, + }) + + version, err := GetDatabaseVersion(context.Background(), projectRef) + + assert.ErrorIs(t, err, errDatabaseVersion) + assert.Empty(t, version) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("handles empty database version", func(t *testing.T) { + token := apitest.RandomAccessToken(t) + t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) + + defer gock.OffAll() + projectRef := apitest.RandomProjectRef() + gock.New(utils.DefaultApiHost). + Get("/v1/projects"). + Reply(http.StatusOK). + JSON([]api.V1ProjectWithDatabaseResponse{ + { + Id: projectRef, + Database: api.V1DatabaseResponse{}, + }, + }) + + version, err := GetDatabaseVersion(context.Background(), projectRef) + + assert.ErrorIs(t, err, errDatabaseVersion) + assert.Empty(t, version) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) +}