supabase-cli/internal/link/link_test.go

431 lines
14 KiB
Go

package link
import (
"context"
"errors"
"net/http"
"testing"
"github.com/h2non/gock"
"github.com/jackc/pgconn"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v4"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/supabase/cli/internal/testing/apitest"
"github.com/supabase/cli/internal/testing/fstest"
"github.com/supabase/cli/internal/testing/helper"
"github.com/supabase/cli/internal/utils"
"github.com/supabase/cli/internal/utils/tenant"
"github.com/supabase/cli/pkg/api"
"github.com/supabase/cli/pkg/migration"
"github.com/supabase/cli/pkg/pgtest"
"github.com/zalando/go-keyring"
)
var dbConfig = pgconn.Config{
Host: "127.0.0.1",
Port: 5432,
User: "admin",
Password: "password",
Database: "postgres",
}
func TestLinkCommand(t *testing.T) {
project := "test-project"
// Setup valid access token
token := apitest.RandomAccessToken(t)
t.Setenv("SUPABASE_ACCESS_TOKEN", string(token))
// Mock credentials store
keyring.MockInit()
t.Run("link valid project", func(t *testing.T) {
t.Cleanup(fstest.MockStdin(t, "\n"))
// Setup in-memory fs
fsys := afero.NewMemMapFs()
// Setup mock postgres
conn := pgtest.NewConn()
defer conn.Close(t)
helper.MockMigrationHistory(conn)
helper.MockSeedHistory(conn)
// Flush pending mocks after test execution
defer gock.OffAll()
// Mock project status
postgres := api.V1DatabaseResponse{
Host: utils.GetSupabaseDbHost(project),
Version: "15.1.0.117",
}
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + project).
Reply(200).
JSON(api.V1ProjectWithDatabaseResponse{
Status: api.V1ProjectWithDatabaseResponseStatusACTIVEHEALTHY,
Database: postgres,
})
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + project + "/api-keys").
Reply(200).
JSON([]api.ApiKeyResponse{{Name: "anon", ApiKey: "anon-key"}})
// Link configs
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + project + "/config/database/postgres").
Reply(200).
JSON(api.PostgresConfigResponse{})
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + project + "/postgrest").
Reply(200).
JSON(api.V1PostgrestConfigResponse{})
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + project + "/config/auth").
Reply(200).
JSON(api.AuthConfigResponse{})
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + project + "/config/storage").
Reply(200).
JSON(api.StorageConfigResponse{})
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + project + "/config/database/pooler").
Reply(200).
JSON(api.V1PgbouncerConfigResponse{})
// Link versions
auth := tenant.HealthResponse{Version: "v2.74.2"}
gock.New("https://" + utils.GetSupabaseHost(project)).
Get("/auth/v1/health").
Reply(200).
JSON(auth)
rest := tenant.SwaggerResponse{Info: tenant.SwaggerInfo{Version: "11.1.0"}}
gock.New("https://" + utils.GetSupabaseHost(project)).
Get("/rest/v1/").
Reply(200).
JSON(rest)
gock.New("https://" + utils.GetSupabaseHost(project)).
Get("/storage/v1/version").
Reply(200).
BodyString("0.40.4")
// Run test
err := Run(context.Background(), project, fsys, conn.Intercept)
// Check error
assert.NoError(t, err)
assert.Empty(t, apitest.ListUnmatchedRequests())
// Validate file contents
content, err := afero.ReadFile(fsys, utils.ProjectRefPath)
assert.NoError(t, err)
assert.Equal(t, []byte(project), content)
restVersion, err := afero.ReadFile(fsys, utils.RestVersionPath)
assert.NoError(t, err)
assert.Equal(t, []byte("v"+rest.Info.Version), restVersion)
authVersion, err := afero.ReadFile(fsys, utils.GotrueVersionPath)
assert.NoError(t, err)
assert.Equal(t, []byte(auth.Version), authVersion)
postgresVersion, err := afero.ReadFile(fsys, utils.PostgresVersionPath)
assert.NoError(t, err)
assert.Equal(t, []byte(postgres.Version), postgresVersion)
})
t.Run("ignores error linking services", func(t *testing.T) {
t.Cleanup(fstest.MockStdin(t, "\n"))
// Setup in-memory fs
fsys := afero.NewMemMapFs()
// Flush pending mocks after test execution
defer gock.OffAll()
// Mock project status
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + project).
Reply(200).
JSON(api.V1ProjectWithDatabaseResponse{
Status: api.V1ProjectWithDatabaseResponseStatusACTIVEHEALTHY,
Database: api.V1DatabaseResponse{},
})
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + project + "/api-keys").
Reply(200).
JSON([]api.ApiKeyResponse{{Name: "anon", ApiKey: "anon-key"}})
// Link configs
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + project + "/config/database/postgres").
ReplyError(errors.New("network error"))
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + project + "/postgrest").
ReplyError(errors.New("network error"))
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + project + "/config/auth").
ReplyError(errors.New("network error"))
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + project + "/config/storage").
ReplyError(errors.New("network error"))
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + project + "/config/database/pooler").
ReplyError(errors.New("network error"))
// Link versions
gock.New("https://" + utils.GetSupabaseHost(project)).
Get("/auth/v1/health").
ReplyError(errors.New("network error"))
gock.New("https://" + utils.GetSupabaseHost(project)).
Get("/rest/v1/").
ReplyError(errors.New("network error"))
gock.New("https://" + utils.GetSupabaseHost(project)).
Get("/storage/v1/version").
ReplyError(errors.New("network error"))
// Run test
err := Run(context.Background(), project, fsys, func(cc *pgx.ConnConfig) {
cc.LookupFunc = func(ctx context.Context, host string) (addrs []string, err error) {
return nil, errors.New("hostname resolving error")
}
})
// Check error
assert.ErrorContains(t, err, "hostname resolving error")
assert.Empty(t, apitest.ListUnmatchedRequests())
})
t.Run("throws error on write failure", func(t *testing.T) {
// Setup in-memory fs
fsys := afero.NewReadOnlyFs(afero.NewMemMapFs())
// Flush pending mocks after test execution
defer gock.OffAll()
// Mock project status
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + project).
Reply(200).
JSON(api.V1ProjectWithDatabaseResponse{
Status: api.V1ProjectWithDatabaseResponseStatusACTIVEHEALTHY,
Database: api.V1DatabaseResponse{},
})
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + project + "/api-keys").
Reply(200).
JSON([]api.ApiKeyResponse{{Name: "anon", ApiKey: "anon-key"}})
// Link configs
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + project + "/config/database/postgres").
ReplyError(errors.New("network error"))
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + project + "/postgrest").
ReplyError(errors.New("network error"))
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + project + "/config/auth").
ReplyError(errors.New("network error"))
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + project + "/config/storage").
ReplyError(errors.New("network error"))
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + project + "/config/database/pooler").
ReplyError(errors.New("network error"))
// Link versions
gock.New("https://" + utils.GetSupabaseHost(project)).
Get("/auth/v1/health").
ReplyError(errors.New("network error"))
gock.New("https://" + utils.GetSupabaseHost(project)).
Get("/rest/v1/").
ReplyError(errors.New("network error"))
gock.New("https://" + utils.GetSupabaseHost(project)).
Get("/storage/v1/version").
ReplyError(errors.New("network error"))
gock.New(utils.DefaultApiHost).
Get("/v1/projects").
ReplyError(errors.New("network error"))
// Run test
err := Run(context.Background(), project, fsys)
// Check error
assert.ErrorContains(t, err, "operation not permitted")
assert.Empty(t, apitest.ListUnmatchedRequests())
// Validate file contents
exists, err := afero.Exists(fsys, utils.ProjectRefPath)
assert.NoError(t, err)
assert.False(t, exists)
})
}
func TestStatusCheck(t *testing.T) {
project := "test-project"
t.Run("updates postgres version when healthy", func(t *testing.T) {
// Setup in-memory fs
fsys := afero.NewMemMapFs()
// Flush pending mocks after test execution
defer gock.OffAll()
// Mock project status
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + project).
Reply(http.StatusOK).
JSON(api.V1ProjectWithDatabaseResponse{
Status: api.V1ProjectWithDatabaseResponseStatusACTIVEHEALTHY,
Database: api.V1DatabaseResponse{Version: "15.6.1.139"},
})
// Run test
err := checkRemoteProjectStatus(context.Background(), project, fsys)
// Check error
assert.NoError(t, err)
version, err := afero.ReadFile(fsys, utils.PostgresVersionPath)
assert.NoError(t, err)
assert.Equal(t, "15.6.1.139", string(version))
assert.Empty(t, apitest.ListUnmatchedRequests())
})
t.Run("ignores project not found", func(t *testing.T) {
// Setup in-memory fs
fsys := afero.NewMemMapFs()
// Flush pending mocks after test execution
defer gock.OffAll()
// Mock project status
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + project).
Reply(http.StatusNotFound)
// Run test
err := checkRemoteProjectStatus(context.Background(), project, fsys)
// Check error
assert.NoError(t, err)
exists, err := afero.Exists(fsys, utils.PostgresVersionPath)
assert.NoError(t, err)
assert.False(t, exists)
assert.Empty(t, apitest.ListUnmatchedRequests())
})
t.Run("throws error on project inactive", func(t *testing.T) {
// Setup in-memory fs
fsys := afero.NewMemMapFs()
// Flush pending mocks after test execution
defer gock.OffAll()
// Mock project status
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + project).
Reply(http.StatusOK).
JSON(api.V1ProjectWithDatabaseResponse{Status: api.V1ProjectWithDatabaseResponseStatusINACTIVE})
// Run test
err := checkRemoteProjectStatus(context.Background(), project, fsys)
// Check error
assert.ErrorIs(t, err, errProjectPaused)
exists, err := afero.Exists(fsys, utils.PostgresVersionPath)
assert.NoError(t, err)
assert.False(t, exists)
assert.Empty(t, apitest.ListUnmatchedRequests())
})
}
func TestLinkPostgrest(t *testing.T) {
project := "test-project"
// Setup valid access token
token := apitest.RandomAccessToken(t)
t.Setenv("SUPABASE_ACCESS_TOKEN", string(token))
t.Run("ignores matching config", func(t *testing.T) {
// Flush pending mocks after test execution
defer gock.OffAll()
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + project + "/postgrest").
Reply(200).
JSON(api.V1PostgrestConfigResponse{})
// Run test
err := linkPostgrest(context.Background(), project)
// Check error
assert.NoError(t, err)
assert.Empty(t, apitest.ListUnmatchedRequests())
})
t.Run("updates api on newer config", func(t *testing.T) {
// Flush pending mocks after test execution
defer gock.OffAll()
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + project + "/postgrest").
Reply(200).
JSON(api.V1PostgrestConfigResponse{
DbSchema: "public, graphql_public",
DbExtraSearchPath: "public, extensions",
MaxRows: 1000,
})
// Run test
err := linkPostgrest(context.Background(), project)
// Check error
assert.NoError(t, err)
assert.Empty(t, apitest.ListUnmatchedRequests())
assert.ElementsMatch(t, []string{"public", "graphql_public"}, utils.Config.Api.Schemas)
assert.ElementsMatch(t, []string{"public", "extensions"}, utils.Config.Api.ExtraSearchPath)
assert.Equal(t, uint(1000), utils.Config.Api.MaxRows)
})
t.Run("throws error on network failure", func(t *testing.T) {
// Flush pending mocks after test execution
defer gock.OffAll()
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + project + "/postgrest").
ReplyError(errors.New("network error"))
// Run test
err := linkPostgrest(context.Background(), project)
// Validate api
assert.ErrorContains(t, err, "network error")
assert.Empty(t, apitest.ListUnmatchedRequests())
})
t.Run("throws error on server unavailable", func(t *testing.T) {
// Flush pending mocks after test execution
defer gock.OffAll()
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + project + "/postgrest").
Reply(500).
JSON(map[string]string{"message": "unavailable"})
// Run test
err := linkPostgrest(context.Background(), project)
// Validate api
assert.ErrorContains(t, err, `unexpected API config status 500: {"message":"unavailable"}`)
assert.Empty(t, apitest.ListUnmatchedRequests())
})
}
func TestLinkDatabase(t *testing.T) {
t.Run("throws error on connect failure", func(t *testing.T) {
// Run test
err := linkDatabase(context.Background(), pgconn.Config{})
// Check error
assert.ErrorContains(t, err, "invalid port (outside range)")
})
t.Run("ignores missing server version", func(t *testing.T) {
// Setup mock postgres
conn := pgtest.NewWithStatus(map[string]string{
"standard_conforming_strings": "on",
})
defer conn.Close(t)
helper.MockMigrationHistory(conn)
helper.MockSeedHistory(conn)
// Run test
err := linkDatabase(context.Background(), dbConfig, conn.Intercept)
// Check error
assert.NoError(t, err)
})
t.Run("updates config to newer db version", func(t *testing.T) {
utils.Config.Db.MajorVersion = 14
// Setup mock postgres
conn := pgtest.NewWithStatus(map[string]string{
"standard_conforming_strings": "on",
"server_version": "15.0",
})
defer conn.Close(t)
helper.MockMigrationHistory(conn)
helper.MockSeedHistory(conn)
// Run test
err := linkDatabase(context.Background(), dbConfig, conn.Intercept)
// Check error
assert.NoError(t, err)
utils.Config.Db.MajorVersion = 15
assert.Equal(t, uint(15), utils.Config.Db.MajorVersion)
})
t.Run("throws error on query failure", func(t *testing.T) {
utils.Config.Db.MajorVersion = 14
// Setup mock postgres
conn := pgtest.NewConn()
defer conn.Close(t)
conn.Query(migration.SET_LOCK_TIMEOUT).
Query(migration.CREATE_VERSION_SCHEMA).
Reply("CREATE SCHEMA").
Query(migration.CREATE_VERSION_TABLE).
ReplyError(pgerrcode.InsufficientPrivilege, "permission denied for relation supabase_migrations").
Query(migration.ADD_STATEMENTS_COLUMN).
Query(migration.ADD_NAME_COLUMN)
// Run test
err := linkDatabase(context.Background(), dbConfig, conn.Intercept)
// Check error
assert.ErrorContains(t, err, "ERROR: permission denied for relation supabase_migrations (SQLSTATE 42501)")
})
}