501 lines
16 KiB
Go
501 lines
16 KiB
Go
package config
|
|
|
|
import (
|
|
"bytes"
|
|
_ "embed"
|
|
"path"
|
|
"strings"
|
|
"testing"
|
|
fs "testing/fstest"
|
|
|
|
"github.com/BurntSushi/toml"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
//go:embed testdata/config.toml
|
|
var testInitConfigEmbed []byte
|
|
|
|
func TestConfigParsing(t *testing.T) {
|
|
t.Run("classic config file", func(t *testing.T) {
|
|
config := NewConfig()
|
|
// Run test
|
|
var buf bytes.Buffer
|
|
require.NoError(t, config.Eject(&buf))
|
|
file := fs.MapFile{Data: buf.Bytes()}
|
|
fsys := fs.MapFS{"config.toml": &file}
|
|
// Check error
|
|
assert.NoError(t, config.Load("config.toml", fsys))
|
|
})
|
|
|
|
t.Run("optional config file", func(t *testing.T) {
|
|
config := NewConfig()
|
|
// Run test
|
|
err := config.Load("", fs.MapFS{})
|
|
// Check error
|
|
assert.NoError(t, err)
|
|
})
|
|
|
|
t.Run("config file with environment variables", func(t *testing.T) {
|
|
config := NewConfig()
|
|
// Setup in-memory fs
|
|
fsys := fs.MapFS{
|
|
"supabase/config.toml": &fs.MapFile{Data: testInitConfigEmbed},
|
|
"supabase/templates/invite.html": &fs.MapFile{},
|
|
}
|
|
// Run test
|
|
t.Setenv("TWILIO_AUTH_TOKEN", "token")
|
|
t.Setenv("AZURE_CLIENT_ID", "hello")
|
|
t.Setenv("AZURE_SECRET", "this is cool")
|
|
t.Setenv("AUTH_SEND_SMS_SECRETS", "v1,whsec_aWxpa2VzdXBhYmFzZXZlcnltdWNoYW5kaWhvcGV5b3Vkb3Rvbw==")
|
|
t.Setenv("SENDGRID_API_KEY", "sendgrid")
|
|
t.Setenv("AUTH_CALLBACK_URL", "http://localhost:3000/auth/callback")
|
|
assert.NoError(t, config.Load("", fsys))
|
|
// Check error
|
|
assert.Equal(t, "hello", config.Auth.External["azure"].ClientId)
|
|
assert.Equal(t, "this is cool", config.Auth.External["azure"].Secret.Value)
|
|
assert.Equal(t, []string{
|
|
"https://127.0.0.1:3000",
|
|
"http://localhost:3000/auth/callback",
|
|
}, config.Auth.AdditionalRedirectUrls)
|
|
})
|
|
|
|
t.Run("config file with environment variables fails when unset", func(t *testing.T) {
|
|
config := NewConfig()
|
|
// Setup in-memory fs
|
|
fsys := fs.MapFS{
|
|
"supabase/config.toml": &fs.MapFile{Data: testInitConfigEmbed},
|
|
}
|
|
// Run test
|
|
assert.Error(t, config.Load("", fsys))
|
|
})
|
|
|
|
t.Run("config file with remotes", func(t *testing.T) {
|
|
config := NewConfig()
|
|
// Setup in-memory fs
|
|
fsys := fs.MapFS{
|
|
"supabase/config.toml": &fs.MapFile{Data: testInitConfigEmbed},
|
|
"supabase/templates/invite.html": &fs.MapFile{},
|
|
}
|
|
// Run test
|
|
t.Setenv("TWILIO_AUTH_TOKEN", "token")
|
|
t.Setenv("AZURE_CLIENT_ID", "hello")
|
|
t.Setenv("AZURE_SECRET", "this is cool")
|
|
t.Setenv("AUTH_SEND_SMS_SECRETS", "v1,whsec_aWxpa2VzdXBhYmFzZXZlcnltdWNoYW5kaWhvcGV5b3Vkb3Rvbw==")
|
|
t.Setenv("SENDGRID_API_KEY", "sendgrid")
|
|
t.Setenv("AUTH_CALLBACK_URL", "http://localhost:3000/auth/callback")
|
|
assert.NoError(t, config.Load("", fsys))
|
|
// Check the default value in the config
|
|
assert.Equal(t, "http://127.0.0.1:3000", config.Auth.SiteUrl)
|
|
assert.Equal(t, true, config.Auth.EnableSignup)
|
|
assert.Equal(t, true, config.Auth.External["azure"].Enabled)
|
|
assert.Equal(t, []string{"image/png", "image/jpeg"}, config.Storage.Buckets["images"].AllowedMimeTypes)
|
|
// Check the values for remotes override
|
|
production, ok := config.Remotes["production"]
|
|
assert.True(t, ok)
|
|
staging, ok := config.Remotes["staging"]
|
|
assert.True(t, ok)
|
|
// Check the values for production override
|
|
assert.Equal(t, "vpefcjyosynxeiebfscx", production.ProjectId)
|
|
assert.Equal(t, "http://feature-auth-branch.com/", production.Auth.SiteUrl)
|
|
assert.Equal(t, false, production.Auth.EnableSignup)
|
|
assert.Equal(t, false, production.Auth.External["azure"].Enabled)
|
|
assert.Equal(t, "nope", production.Auth.External["azure"].ClientId)
|
|
// Check seed should be disabled by default for remote configs
|
|
assert.Equal(t, false, production.Db.Seed.Enabled)
|
|
// Check the values for the staging override
|
|
assert.Equal(t, "bvikqvbczudanvggcord", staging.ProjectId)
|
|
assert.Equal(t, []string{"image/png"}, staging.Storage.Buckets["images"].AllowedMimeTypes)
|
|
assert.Equal(t, true, staging.Db.Seed.Enabled)
|
|
})
|
|
}
|
|
|
|
func TestFileSizeLimitConfigParsing(t *testing.T) {
|
|
t.Run("test file size limit parsing number", func(t *testing.T) {
|
|
var testConfig config
|
|
_, err := toml.Decode(`
|
|
[storage]
|
|
file_size_limit = 5000000
|
|
`, &testConfig)
|
|
if assert.NoError(t, err) {
|
|
assert.Equal(t, sizeInBytes(5000000), testConfig.Storage.FileSizeLimit)
|
|
}
|
|
})
|
|
|
|
t.Run("test file size limit parsing bytes unit", func(t *testing.T) {
|
|
var testConfig config
|
|
_, err := toml.Decode(`
|
|
[storage]
|
|
file_size_limit = "5MB"
|
|
`, &testConfig)
|
|
if assert.NoError(t, err) {
|
|
assert.Equal(t, sizeInBytes(5242880), testConfig.Storage.FileSizeLimit)
|
|
}
|
|
})
|
|
|
|
t.Run("test file size limit parsing binary bytes unit", func(t *testing.T) {
|
|
var testConfig config
|
|
_, err := toml.Decode(`
|
|
[storage]
|
|
file_size_limit = "5MiB"
|
|
`, &testConfig)
|
|
if assert.NoError(t, err) {
|
|
assert.Equal(t, sizeInBytes(5242880), testConfig.Storage.FileSizeLimit)
|
|
}
|
|
})
|
|
|
|
t.Run("test file size limit parsing string number", func(t *testing.T) {
|
|
var testConfig config
|
|
_, err := toml.Decode(`
|
|
[storage]
|
|
file_size_limit = "5000000"
|
|
`, &testConfig)
|
|
if assert.NoError(t, err) {
|
|
assert.Equal(t, sizeInBytes(5000000), testConfig.Storage.FileSizeLimit)
|
|
}
|
|
})
|
|
|
|
t.Run("test file size limit parsing bad datatype", func(t *testing.T) {
|
|
var testConfig config
|
|
_, err := toml.Decode(`
|
|
[storage]
|
|
file_size_limit = []
|
|
`, &testConfig)
|
|
assert.Error(t, err)
|
|
assert.Equal(t, sizeInBytes(0), testConfig.Storage.FileSizeLimit)
|
|
})
|
|
|
|
t.Run("test file size limit parsing bad string data", func(t *testing.T) {
|
|
var testConfig config
|
|
_, err := toml.Decode(`
|
|
[storage]
|
|
file_size_limit = "foobar"
|
|
`, &testConfig)
|
|
assert.Error(t, err)
|
|
assert.Equal(t, sizeInBytes(0), testConfig.Storage.FileSizeLimit)
|
|
})
|
|
}
|
|
|
|
func TestSanitizeProjectI(t *testing.T) {
|
|
// Preserves valid consecutive characters
|
|
assert.Equal(t, "abc", sanitizeProjectId("abc"))
|
|
assert.Equal(t, "a..b_c", sanitizeProjectId("a..b_c"))
|
|
// Removes leading special characters
|
|
assert.Equal(t, "abc", sanitizeProjectId("_abc"))
|
|
assert.Equal(t, "abc", sanitizeProjectId("_@abc"))
|
|
// Replaces consecutive invalid characters with a single _
|
|
assert.Equal(t, "a_bc-", sanitizeProjectId("a@@bc-"))
|
|
// Truncates to less than 40 characters
|
|
sanitized := strings.Repeat("a", maxProjectIdLength)
|
|
assert.Equal(t, sanitized, sanitizeProjectId(sanitized+"bb"))
|
|
}
|
|
|
|
const (
|
|
defaultAnonKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0"
|
|
defaultServiceRoleKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU"
|
|
)
|
|
|
|
func TestSigningJWT(t *testing.T) {
|
|
t.Run("signs default anon key", func(t *testing.T) {
|
|
anonToken := CustomClaims{Role: "anon"}.NewToken()
|
|
signed, err := anonToken.SignedString([]byte(defaultJwtSecret))
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, defaultAnonKey, signed)
|
|
})
|
|
|
|
t.Run("signs default service_role key", func(t *testing.T) {
|
|
serviceToken := CustomClaims{Role: "service_role"}.NewToken()
|
|
signed, err := serviceToken.SignedString([]byte(defaultJwtSecret))
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, defaultServiceRoleKey, signed)
|
|
})
|
|
}
|
|
|
|
func TestValidateHookURI(t *testing.T) {
|
|
tests := []struct {
|
|
hookConfig
|
|
name string
|
|
errorMsg string
|
|
}{
|
|
{
|
|
name: "valid http URL",
|
|
hookConfig: hookConfig{
|
|
Enabled: true,
|
|
URI: "http://example.com",
|
|
Secrets: Secret{Value: "v1,whsec_aWxpa2VzdXBhYmFzZXZlcnltdWNoYW5kaWhvcGV5b3Vkb3Rvbw=="},
|
|
},
|
|
},
|
|
{
|
|
name: "valid https URL",
|
|
hookConfig: hookConfig{
|
|
Enabled: true,
|
|
URI: "https://example.com",
|
|
Secrets: Secret{Value: "v1,whsec_aWxpa2VzdXBhYmFzZXZlcnltdWNoYW5kaWhvcGV5b3Vkb3Rvbw=="},
|
|
},
|
|
},
|
|
{
|
|
name: "valid pg-functions URI",
|
|
hookConfig: hookConfig{
|
|
Enabled: true,
|
|
URI: "pg-functions://functionName",
|
|
},
|
|
},
|
|
{
|
|
name: "invalid URI with unsupported scheme",
|
|
hookConfig: hookConfig{
|
|
Enabled: true,
|
|
URI: "ftp://example.com",
|
|
Secrets: Secret{Value: "v1,whsec_aWxpa2VzdXBhYmFzZXZlcnltdWNoYW5kaWhvcGV5b3Vkb3Rvbw=="},
|
|
},
|
|
errorMsg: "Invalid hook config: auth.hook.invalid URI with unsupported scheme.uri should be a HTTP, HTTPS, or pg-functions URI",
|
|
},
|
|
{
|
|
name: "invalid URI with parsing error",
|
|
hookConfig: hookConfig{
|
|
Enabled: true,
|
|
URI: "http://a b.com",
|
|
Secrets: Secret{Value: "v1,whsec_aWxpa2VzdXBhYmFzZXZlcnltdWNoYW5kaWhvcGV5b3Vkb3Rvbw=="},
|
|
},
|
|
errorMsg: "failed to parse template url: parse \"http://a b.com\": invalid character \" \" in host name",
|
|
},
|
|
{
|
|
name: "valid http URL with missing secrets",
|
|
hookConfig: hookConfig{
|
|
Enabled: true,
|
|
URI: "http://example.com",
|
|
},
|
|
errorMsg: "Missing required field in config: auth.hook.valid http URL with missing secrets.secrets",
|
|
},
|
|
{
|
|
name: "valid pg-functions URI with unsupported secrets",
|
|
hookConfig: hookConfig{
|
|
Enabled: true,
|
|
URI: "pg-functions://functionName",
|
|
Secrets: Secret{Value: "test-secret"},
|
|
},
|
|
errorMsg: "Invalid hook config: auth.hook.valid pg-functions URI with unsupported secrets.secrets is unsupported for pg-functions URI",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := tt.hookConfig.validate(tt.name)
|
|
if len(tt.errorMsg) > 0 {
|
|
assert.Error(t, err, "Expected an error for %v", tt.name)
|
|
assert.EqualError(t, err, tt.errorMsg, "Expected error message does not match for %v", tt.name)
|
|
} else {
|
|
assert.NoError(t, err, "Expected no error for %v", tt.name)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGlobFiles(t *testing.T) {
|
|
t.Run("returns seed files matching patterns", func(t *testing.T) {
|
|
// Setup in-memory fs
|
|
fsys := fs.MapFS{
|
|
"supabase/seeds/seed1.sql": &fs.MapFile{Data: []byte("INSERT INTO table1 VALUES (1);")},
|
|
"supabase/seeds/seed2.sql": &fs.MapFile{Data: []byte("INSERT INTO table2 VALUES (2);")},
|
|
"supabase/seeds/seed3.sql": &fs.MapFile{Data: []byte("INSERT INTO table2 VALUES (2);")},
|
|
"supabase/seeds/another.sql": &fs.MapFile{Data: []byte("INSERT INTO table2 VALUES (2);")},
|
|
"supabase/seeds/ignore.sql": &fs.MapFile{Data: []byte("INSERT INTO table3 VALUES (3);")},
|
|
}
|
|
// Mock config patterns
|
|
g := Glob{
|
|
"supabase/seeds/seed[12].sql",
|
|
"supabase/seeds/ano*.sql",
|
|
}
|
|
// Run test
|
|
files, err := g.Files(fsys)
|
|
// Check error
|
|
assert.NoError(t, err)
|
|
// Validate files
|
|
assert.ElementsMatch(t, []string{
|
|
"supabase/seeds/seed1.sql",
|
|
"supabase/seeds/seed2.sql",
|
|
"supabase/seeds/another.sql",
|
|
}, files)
|
|
})
|
|
|
|
t.Run("returns seed files matching patterns skip duplicates", func(t *testing.T) {
|
|
// Setup in-memory fs
|
|
fsys := fs.MapFS{
|
|
"supabase/seeds/seed1.sql": &fs.MapFile{Data: []byte("INSERT INTO table1 VALUES (1);")},
|
|
"supabase/seeds/seed2.sql": &fs.MapFile{Data: []byte("INSERT INTO table2 VALUES (2);")},
|
|
"supabase/seeds/seed3.sql": &fs.MapFile{Data: []byte("INSERT INTO table2 VALUES (2);")},
|
|
"supabase/seeds/another.sql": &fs.MapFile{Data: []byte("INSERT INTO table2 VALUES (2);")},
|
|
"supabase/seeds/ignore.sql": &fs.MapFile{Data: []byte("INSERT INTO table3 VALUES (3);")},
|
|
}
|
|
// Mock config patterns
|
|
g := Glob{
|
|
"supabase/seeds/seed[12].sql",
|
|
"supabase/seeds/ano*.sql",
|
|
"supabase/seeds/seed*.sql",
|
|
}
|
|
// Run test
|
|
files, err := g.Files(fsys)
|
|
// Check error
|
|
assert.NoError(t, err)
|
|
// Validate files
|
|
assert.ElementsMatch(t, []string{
|
|
"supabase/seeds/seed1.sql",
|
|
"supabase/seeds/seed2.sql",
|
|
"supabase/seeds/another.sql",
|
|
"supabase/seeds/seed3.sql",
|
|
}, files)
|
|
})
|
|
|
|
t.Run("returns error on invalid pattern", func(t *testing.T) {
|
|
// Setup in-memory fs
|
|
fsys := fs.MapFS{}
|
|
// Mock config patterns
|
|
g := Glob{"[*!#@D#"}
|
|
// Run test
|
|
files, err := g.Files(fsys)
|
|
// Check error
|
|
assert.ErrorIs(t, err, path.ErrBadPattern)
|
|
// The resuling seed list should be empty
|
|
assert.Empty(t, files)
|
|
})
|
|
|
|
t.Run("returns empty list if no files match", func(t *testing.T) {
|
|
// Setup in-memory fs
|
|
fsys := fs.MapFS{}
|
|
// Mock config patterns
|
|
g := Glob{"seeds/*.sql"}
|
|
// Run test
|
|
files, err := g.Files(fsys)
|
|
// Check error
|
|
assert.ErrorContains(t, err, "no files matched")
|
|
// Validate files
|
|
assert.Empty(t, files)
|
|
})
|
|
}
|
|
|
|
func TestLoadEnv(t *testing.T) {
|
|
t.Setenv("SUPABASE_AUTH_JWT_SECRET", "test-secret")
|
|
t.Setenv("SUPABASE_DB_ROOT_KEY", "test-root-key")
|
|
config := NewConfig()
|
|
// Run test
|
|
err := config.loadFromEnv()
|
|
// Check error
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "test-secret", config.Auth.JwtSecret)
|
|
assert.Equal(t, "test-root-key", config.Db.RootKey)
|
|
}
|
|
|
|
func TestLoadFunctionImportMap(t *testing.T) {
|
|
t.Run("uses deno.json as import map when present", func(t *testing.T) {
|
|
config := NewConfig()
|
|
fsys := fs.MapFS{
|
|
"supabase/config.toml": &fs.MapFile{Data: []byte(`
|
|
project_id = "bvikqvbczudanvggcord"
|
|
[functions.hello]
|
|
`)},
|
|
"supabase/functions/hello/deno.json": &fs.MapFile{},
|
|
"supabase/functions/hello/index.ts": &fs.MapFile{},
|
|
}
|
|
// Run test
|
|
assert.NoError(t, config.Load("", fsys))
|
|
// Check that deno.json was set as import map
|
|
assert.Equal(t, "supabase/functions/hello/deno.json", config.Functions["hello"].ImportMap)
|
|
})
|
|
|
|
t.Run("uses deno.jsonc as import map when present", func(t *testing.T) {
|
|
config := NewConfig()
|
|
fsys := fs.MapFS{
|
|
"supabase/config.toml": &fs.MapFile{Data: []byte(`
|
|
project_id = "bvikqvbczudanvggcord"
|
|
[functions.hello]
|
|
`)},
|
|
"supabase/functions/hello/deno.jsonc": &fs.MapFile{},
|
|
"supabase/functions/hello/index.ts": &fs.MapFile{},
|
|
}
|
|
// Run test
|
|
assert.NoError(t, config.Load("", fsys))
|
|
// Check that deno.jsonc was set as import map
|
|
assert.Equal(t, "supabase/functions/hello/deno.jsonc", config.Functions["hello"].ImportMap)
|
|
})
|
|
|
|
t.Run("config.toml takes precedence over deno.json", func(t *testing.T) {
|
|
config := NewConfig()
|
|
fsys := fs.MapFS{
|
|
"supabase/config.toml": &fs.MapFile{Data: []byte(`
|
|
project_id = "bvikqvbczudanvggcord"
|
|
[functions]
|
|
hello.import_map = "custom_import_map.json"
|
|
`)},
|
|
"supabase/functions/hello/deno.json": &fs.MapFile{},
|
|
"supabase/functions/hello/index.ts": &fs.MapFile{},
|
|
}
|
|
// Run test
|
|
assert.NoError(t, config.Load("", fsys))
|
|
// Check that config.toml takes precedence over deno.json
|
|
assert.Equal(t, "supabase/custom_import_map.json", config.Functions["hello"].ImportMap)
|
|
})
|
|
}
|
|
|
|
func TestLoadFunctionErrorMessageParsing(t *testing.T) {
|
|
t.Run("returns error for array-style function config", func(t *testing.T) {
|
|
config := NewConfig()
|
|
fsys := fs.MapFS{
|
|
"supabase/config.toml": &fs.MapFile{Data: []byte(`
|
|
project_id = "bvikqvbczudanvggcord"
|
|
[[functions]]
|
|
name = "hello"
|
|
verify_jwt = true
|
|
`)},
|
|
}
|
|
// Run test
|
|
err := config.Load("", fsys)
|
|
// Check error contains both decode errors
|
|
assert.ErrorContains(t, err, invalidFunctionsConfigFormat)
|
|
})
|
|
|
|
t.Run("returns error with function slug for invalid non-existent field", func(t *testing.T) {
|
|
config := NewConfig()
|
|
fsys := fs.MapFS{
|
|
"supabase/config.toml": &fs.MapFile{Data: []byte(`
|
|
project_id = "bvikqvbczudanvggcord"
|
|
[functions.hello]
|
|
unknown_field = true
|
|
`)},
|
|
}
|
|
// Run test
|
|
err := config.Load("", fsys)
|
|
// Check error contains both decode errors
|
|
assert.ErrorContains(t, err, "* 'functions[hello]' has invalid keys: unknown_field")
|
|
})
|
|
|
|
t.Run("returns error with function slug for invalid field value", func(t *testing.T) {
|
|
config := NewConfig()
|
|
fsys := fs.MapFS{
|
|
"supabase/config.toml": &fs.MapFile{Data: []byte(`
|
|
project_id = "bvikqvbczudanvggcord"
|
|
[functions.hello]
|
|
verify_jwt = "not-a-bool"
|
|
`)},
|
|
}
|
|
// Run test
|
|
err := config.Load("", fsys)
|
|
// Check error contains both decode errors
|
|
assert.ErrorContains(t, err, `* cannot parse 'functions[hello].verify_jwt' as bool: strconv.ParseBool: parsing "not-a-bool"`)
|
|
})
|
|
|
|
t.Run("returns error for unknown function fields", func(t *testing.T) {
|
|
config := NewConfig()
|
|
fsys := fs.MapFS{
|
|
"supabase/config.toml": &fs.MapFile{Data: []byte(`
|
|
project_id = "bvikqvbczudanvggcord"
|
|
[functions]
|
|
name = "hello"
|
|
verify_jwt = true
|
|
`)},
|
|
}
|
|
// Run test
|
|
err := config.Load("", fsys)
|
|
assert.ErrorContains(t, err, `* 'functions[name]' expected a map, got 'string'`)
|
|
assert.ErrorContains(t, err, `* 'functions[verify_jwt]' expected a map, got 'bool'`)
|
|
})
|
|
}
|