Skip to content

Commit 113d941

Browse files
committed
fix: confirm cost before enabling paid mfa addons
1 parent 4811359 commit 113d941

File tree

4 files changed

+167
-4
lines changed

4 files changed

+167
-4
lines changed

internal/config/push/push.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"os"
77

8+
"github.com/go-errors/errors"
89
"github.com/spf13/afero"
910
"github.com/supabase/cli/internal/utils"
1011
"github.com/supabase/cli/internal/utils/flags"
@@ -21,10 +22,17 @@ func Run(ctx context.Context, ref string, fsys afero.Fs) error {
2122
// Use base config when no remote is declared
2223
remote.ProjectId = ref
2324
}
25+
cost, err := getCostMatrix(ctx, ref)
26+
if err != nil {
27+
return err
28+
}
2429
fmt.Fprintln(os.Stderr, "Pushing config to project:", remote.ProjectId)
2530
console := utils.NewConsole()
2631
keep := func(name string) bool {
2732
title := fmt.Sprintf("Do you want to push %s config to remote?", name)
33+
if item, exists := cost[name]; exists {
34+
title = fmt.Sprintf("Do you want to enable %s? It will cost you %s", item.Name, item.Price)
35+
}
2836
shouldPush, err := console.PromptYesNo(ctx, title, true)
2937
if err != nil {
3038
fmt.Fprintln(os.Stderr, err)
@@ -33,3 +41,27 @@ func Run(ctx context.Context, ref string, fsys afero.Fs) error {
3341
}
3442
return client.UpdateRemoteConfig(ctx, remote, keep)
3543
}
44+
45+
type CostItem struct {
46+
Name string
47+
Price string
48+
}
49+
50+
func getCostMatrix(ctx context.Context, projectRef string) (map[string]CostItem, error) {
51+
resp, err := utils.GetSupabase().V1ListProjectAddonsWithResponse(ctx, projectRef)
52+
if err != nil {
53+
return nil, errors.Errorf("failed to list addons: %w", err)
54+
} else if resp.JSON200 == nil {
55+
return nil, errors.Errorf("unexpected list addons status %d: %s", resp.StatusCode(), string(resp.Body))
56+
}
57+
costMatrix := make(map[string]CostItem, len(resp.JSON200.AvailableAddons))
58+
for _, addon := range resp.JSON200.AvailableAddons {
59+
if len(addon.Variants) == 1 {
60+
costMatrix[string(addon.Type)] = CostItem{
61+
Name: addon.Variants[0].Name,
62+
Price: addon.Variants[0].Price.Description,
63+
}
64+
}
65+
}
66+
return costMatrix, nil
67+
}

internal/config/push/push_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package push
2+
3+
import (
4+
"context"
5+
"errors"
6+
"net/http"
7+
"testing"
8+
9+
"github.com/h2non/gock"
10+
"github.com/spf13/afero"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
"github.com/supabase/cli/internal/testing/apitest"
14+
"github.com/supabase/cli/internal/utils"
15+
)
16+
17+
func TestPushConfig(t *testing.T) {
18+
project := apitest.RandomProjectRef()
19+
// Setup valid access token
20+
token := apitest.RandomAccessToken(t)
21+
t.Setenv("SUPABASE_ACCESS_TOKEN", string(token))
22+
23+
t.Run("throws error on malformed config", func(t *testing.T) {
24+
// Setup in-memory fs
25+
fsys := afero.NewMemMapFs()
26+
require.NoError(t, utils.WriteFile(utils.ConfigPath, []byte("malformed"), fsys))
27+
// Run test
28+
err := Run(context.Background(), "", fsys)
29+
// Check error
30+
assert.ErrorContains(t, err, "toml: expected = after a key, but the document ends there")
31+
})
32+
33+
t.Run("throws error on service unavailable", func(t *testing.T) {
34+
// Setup in-memory fs
35+
fsys := afero.NewMemMapFs()
36+
// Setup mock api
37+
defer gock.OffAll()
38+
gock.New(utils.DefaultApiHost).
39+
Get("/v1/projects/" + project + "/billing/addons").
40+
Reply(http.StatusServiceUnavailable)
41+
// Run test
42+
err := Run(context.Background(), project, fsys)
43+
// Check error
44+
assert.ErrorContains(t, err, "unexpected list addons status 503:")
45+
})
46+
}
47+
48+
func TestCostMatrix(t *testing.T) {
49+
project := apitest.RandomProjectRef()
50+
// Setup valid access token
51+
token := apitest.RandomAccessToken(t)
52+
t.Setenv("SUPABASE_ACCESS_TOKEN", string(token))
53+
54+
t.Run("fetches cost matrix", func(t *testing.T) {
55+
// Setup mock api
56+
defer gock.OffAll()
57+
gock.New(utils.DefaultApiHost).
58+
Get("/v1/projects/"+project+"/billing/addons").
59+
Reply(http.StatusOK).
60+
SetHeader("Content-Type", "application/json").
61+
BodyString(`{
62+
"available_addons":[{
63+
"name": "Advanced MFA - Phone",
64+
"type": "auth_mfa_phone",
65+
"variants": [{
66+
"id": "auth_mfa_phone_default",
67+
"name": "Advanced MFA - Phone",
68+
"price": {
69+
"amount": 0.1027,
70+
"description": "$75/month, then $10/month",
71+
"interval": "hourly",
72+
"type": "usage"
73+
}
74+
}]
75+
}, {
76+
"name": "Advanced MFA - WebAuthn",
77+
"type": "auth_mfa_web_authn",
78+
"variants": [{
79+
"id": "auth_mfa_web_authn_default",
80+
"name": "Advanced MFA - WebAuthn",
81+
"price": {
82+
"amount": 0.1027,
83+
"description": "$75/month, then $10/month",
84+
"interval": "hourly",
85+
"type": "usage"
86+
}
87+
}]
88+
}]
89+
}`)
90+
// Run test
91+
cost, err := getCostMatrix(context.Background(), project)
92+
// Check error
93+
assert.NoError(t, err)
94+
require.Len(t, cost, 2)
95+
assert.Equal(t, "Advanced MFA - Phone", cost["auth_mfa_phone"].Name)
96+
assert.Equal(t, "$75/month, then $10/month", cost["auth_mfa_phone"].Price)
97+
assert.Equal(t, "Advanced MFA - WebAuthn", cost["auth_mfa_web_authn"].Name)
98+
assert.Equal(t, "$75/month, then $10/month", cost["auth_mfa_web_authn"].Price)
99+
})
100+
101+
t.Run("throws error on network error", func(t *testing.T) {
102+
errNetwork := errors.New("network error")
103+
// Setup mock api
104+
defer gock.OffAll()
105+
gock.New(utils.DefaultApiHost).
106+
Get("/v1/projects/" + project + "/billing/addons").
107+
ReplyError(errNetwork)
108+
// Run test
109+
cost, err := getCostMatrix(context.Background(), project)
110+
// Check error
111+
assert.ErrorIs(t, err, errNetwork)
112+
assert.Nil(t, cost)
113+
})
114+
}

pkg/config/auth.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1326,14 +1326,31 @@ func (w *web3) fromAuthConfig(remoteConfig v1API.AuthConfigResponse) {
13261326
}
13271327
}
13281328

1329-
func (a *auth) DiffWithRemote(remoteConfig v1API.AuthConfigResponse) ([]byte, error) {
1329+
func (a *auth) DiffWithRemote(remoteConfig v1API.AuthConfigResponse, filter ...func(string) bool) ([]byte, error) {
13301330
copy := a.Clone()
1331+
copy.FromRemoteAuthConfig(remoteConfig)
1332+
// Confirm cost before enabling addons
1333+
for _, keep := range filter {
1334+
if a.MFA.Phone.VerifyEnabled && !copy.MFA.Phone.VerifyEnabled {
1335+
if !keep(string(v1API.ListProjectAddonsResponseAvailableAddonsTypeAuthMfaPhone)) {
1336+
a.MFA.Phone.VerifyEnabled = false
1337+
// Enroll cannot be enabled on its own
1338+
a.MFA.Phone.EnrollEnabled = false
1339+
}
1340+
}
1341+
if a.MFA.WebAuthn.VerifyEnabled && !copy.MFA.WebAuthn.VerifyEnabled {
1342+
if !keep(string(v1API.ListProjectAddonsResponseAvailableAddonsTypeAuthMfaWebAuthn)) {
1343+
a.MFA.WebAuthn.VerifyEnabled = false
1344+
// Enroll cannot be enabled on its own
1345+
a.MFA.WebAuthn.EnrollEnabled = false
1346+
}
1347+
}
1348+
}
13311349
// Convert the config values into easily comparable remoteConfig values
1332-
currentValue, err := ToTomlBytes(copy)
1350+
currentValue, err := ToTomlBytes(a)
13331351
if err != nil {
13341352
return nil, err
13351353
}
1336-
copy.FromRemoteAuthConfig(remoteConfig)
13371354
remoteCompare, err := ToTomlBytes(copy)
13381355
if err != nil {
13391356
return nil, err

pkg/config/updater.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ func (u *ConfigUpdater) UpdateAuthConfig(ctx context.Context, projectRef string,
143143
} else if authConfig.JSON200 == nil {
144144
return errors.Errorf("unexpected status %d: %s", authConfig.StatusCode(), string(authConfig.Body))
145145
}
146-
authDiff, err := c.DiffWithRemote(*authConfig.JSON200)
146+
authDiff, err := c.DiffWithRemote(*authConfig.JSON200, filter...)
147147
if err != nil {
148148
return err
149149
} else if len(authDiff) == 0 {

0 commit comments

Comments
 (0)