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
37 changes: 37 additions & 0 deletions __tests__/commands/phpmyadmin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,43 @@ describe( 'commands/PhpMyAdminCommand', () => {
openUrl.mockReset();
} );

it.each(
[
{
platform: { isK8sResident: true },
note: 'Note: phpMyAdmin sessions are read-only. If you run a query that writes to DB, it will fail.',
},
{
platform: { isK8sResident: false },
note: '',
},
{
platform: { isK8sResident: null },
note: 'Note: phpMyAdmin sessions are read-only on VIP Kubernetes and read-write on WP Cloud.',
},
{
platform: {},
note: 'Note: phpMyAdmin sessions are read-only on VIP Kubernetes and read-write on WP Cloud.',
},
].flatMap( testCase => [ false, true ].map( silent => ( { ...testCase, silent } ) ) )
)(
'prints the appropriate access note for $platform with silent=$silent',
async ( { platform, note, silent } ) => {
const noteCmd = new PhpMyAdminCommand( app, { ...env, ...platform }, tracker, silent );
const consoleSpy = jest.spyOn( console, 'log' ).mockImplementation( () => {} );
try {
await noteCmd.run( { print: true } );
const notes = consoleSpy.mock.calls.filter(
( [ message ] ) => typeof message === 'string' && message.includes( 'Note:' )
);
const expectedNote = expect.stringContaining( note );
expect( notes ).toEqual( silent || ! note ? [] : [ [ expectedNote ] ] );
} finally {
consoleSpy.mockRestore();
}
}
);

it( 'should open the generated URL in browser', async () => {
await cmd.run();
expect( pmaEnabledQueryMockTrue ).toHaveBeenCalledWith( {
Expand Down
15 changes: 9 additions & 6 deletions cmd/vip-next/commands/db_phpmyadmin.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ var openURLFn = browser.OpenURL
func DBPhpmyadminCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "phpmyadmin",
Short: "Generate access to a read-only phpMyAdmin web interface",
Long: "Generate access to a read-only phpMyAdmin web interface for the environment's database.\n\n" +
Short: "Generate access to a phpMyAdmin web interface",
Long: "Generate access to a phpMyAdmin web interface for the environment's database.\n\n" +
"By default the URL is opened in your browser. Use --print to write it to stdout instead.",
}
cmd.Flags().BoolP("print", "p", false, "Print the phpMyAdmin URL to stdout instead of opening it in a browser.")
Expand Down Expand Up @@ -90,12 +90,15 @@ func runDBPhpmyadmin(cmd *cobra.Command, args []string) error {
"env": ae.Env.ID,
})

// Node prints a yellow warning that PMA sessions are read-only before
// Node prints a yellow note describing platform-specific access before
// kicking the progress tracker. We match that here, but on stderr to
// keep stdout clean for --print consumers.
if !silent {
fmt.Fprintln(cmd.ErrOrStderr(), color.YellowString(
"Note: PHPMyAdmin sessions are read-only. If you run a query that writes to DB, it will fail."))
if !silent && (ae.Env.IsK8sResident == nil || *ae.Env.IsK8sResident) {
note := "Note: phpMyAdmin sessions are read-only on VIP Kubernetes and read-write on WP Cloud."
if ae.Env.IsK8sResident != nil {
note = "Note: phpMyAdmin sessions are read-only. If you run a query that writes to DB, it will fail."
}
fmt.Fprintln(cmd.ErrOrStderr(), color.YellowString(note))
}

res, err := phpmyadmin.Run(cmd.Context(), cfg.GQLClient, ae.App.ID, ae.Env.ID, phpmyadmin.RunOpts{
Expand Down
62 changes: 62 additions & 0 deletions cmd/vip-next/commands/db_phpmyadmin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package commands

import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
Expand All @@ -12,6 +13,7 @@ import (

"github.com/Khan/genqlient/graphql"

"github.com/Automattic/vip/internal/appctx"
"github.com/Automattic/vip/internal/phpmyadmin"
)

Expand Down Expand Up @@ -102,6 +104,66 @@ func TestDBPhpmyadminPrintWritesURLToStdout(t *testing.T) {
if opened != "" {
t.Errorf("--print must not open browser; got %q", opened)
}
if !strings.Contains(stderr.String(), "Note: phpMyAdmin sessions are read-only on VIP Kubernetes and read-write on WP Cloud.") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we shouldn't expose this implementation detail.

t.Errorf("missing platform-specific access note: %q", stderr.String())
}
}

func TestDBPhpmyadminNoteFollowsK8sResidence(t *testing.T) {
trueValue, falseValue := true, false
for _, tc := range []struct {
name string
k8s *bool
want string
}{
{"kubernetes", &trueValue, "Note: phpMyAdmin sessions are read-only. If you run a query that writes to DB, it will fail."},
{"wp-cloud", &falseValue, ""},
{"unknown", nil, "Note: phpMyAdmin sessions are read-only on VIP Kubernetes and read-write on WP Cloud."},
} {
t.Run(tc.name, func(t *testing.T) {
stub := successStub()
srv := httptest.NewServer(stub.handler(t))
defer srv.Close()
cleanup := setupPhpmyadminConfig(srv, nil)
defer cleanup()

cmd := DBPhpmyadminCmd()
_ = cmd.Flags().Set("print", "true")
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetContext(appctx.WithAppEnv(context.Background(), &appctx.AppEnv{
App: appctx.App{ID: 1},
Env: appctx.Env{ID: 2, IsK8sResident: tc.k8s},
}))

if err := runDBPhpmyadmin(cmd, nil); err != nil {
t.Fatalf("runDBPhpmyadmin: %v", err)
}
if strings.TrimSpace(stdout.String()) != "https://pma.example/abc" {
t.Errorf("stdout = %q, want URL", stdout.String())
}
var got string
for _, line := range strings.Split(stderr.String(), "\n") {
if strings.HasPrefix(line, "Note:") {
got = line
break
}
}
if got != tc.want {
t.Errorf("note = %q, want %q", got, tc.want)
}
})
}
}

func TestDBPhpmyadminHelpIsPlatformNeutral(t *testing.T) {
cmd := DBPhpmyadminCmd()
for _, description := range []string{cmd.Short, cmd.Long} {
if strings.Contains(description, "read-only") || !strings.Contains(description, "phpMyAdmin web interface") {
t.Errorf("misleading command description: %q", description)
}
}
}

func TestDBPhpmyadminPrintSilentSuppressesStderr(t *testing.T) {
Expand Down
2 changes: 2 additions & 0 deletions internal/appctx/app_resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ type envGetter interface {
GetUniqueLabel() *string
GetDefaultDomain() *string
GetIsMultisite() *bool
GetIsK8sResident() *bool
}

func resolveApp(cmd *cobra.Command, client graphql.Client, appKey, envKey string) (App, []Env, error) {
Expand Down Expand Up @@ -168,6 +169,7 @@ func envsFromGetters(getters []envGetter) []Env {
if multisite := g.GetIsMultisite(); multisite != nil {
e.IsMultisite = *multisite
}
e.IsK8sResident = g.GetIsK8sResident()
if e.ID == 0 && e.Name == "" && e.Type == "" {
continue
}
Expand Down
62 changes: 62 additions & 0 deletions internal/appctx/app_resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package appctx

import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
Expand Down Expand Up @@ -97,6 +98,67 @@ func TestWithAppContextResolvesByID(t *testing.T) {
}
}

func TestWithAppContextCarriesNullableK8sResidence(t *testing.T) {
for _, lookup := range []struct {
name string
appKey string
data func(string) string
}{
{"name", "myapp", func(env string) string {
return `{"data":{"apps":{"edges":[{"id":42,"name":"myapp","environments":[` + env + `]}]}}}`
}},
{"id", "42", func(env string) string {
return `{"data":{"app":{"id":42,"name":"myapp","environments":[` + env + `]}}}`
}},
} {
for _, field := range []struct {
name string
json string
want *bool
}{
{"true", `,"isK8sResident":true`, func() *bool { v := true; return &v }()},
{"false", `,"isK8sResident":false`, func() *bool { v := false; return &v }()},
{"null", `,"isK8sResident":null`, nil},
{"missing", "", nil},
} {
t.Run(lookup.name+"/"+field.name, func(t *testing.T) {
env := `{"id":7,"name":"develop","type":"develop"` + field.json + `}`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read request: %v", err)
}
if !strings.Contains(string(body), "isK8sResident") {
t.Errorf("request did not select isK8sResident: %s", body)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(lookup.data(env)))
}))
defer srv.Close()

cmd := makeAppCmd(lookup.appKey, "", true)
run := WithAppContext(AppContextConfig{Client: gqlClientForServer(srv)})(func(cmd *cobra.Command, _ []string) error {
envs := FromContext(cmd.Context()).AvailableEnvs()
if len(envs) != 1 {
t.Fatalf("AvailableEnvs = %+v, want one env", envs)
}
got := envs[0].IsK8sResident
if got == nil && field.want == nil {
return nil
}
if got == nil || field.want == nil || *got != *field.want {
t.Errorf("IsK8sResident = %v, want %v", got, field.want)
}
return nil
})
if err := run(cmd, nil); err != nil {
t.Fatalf("run: %v", err)
}
})
}
}
}

func TestWithAppContextMissingAppNonInteractive(t *testing.T) {
cmd := makeAppCmd("", "", true)
mw := WithAppContext(AppContextConfig{Client: nil})
Expand Down
5 changes: 3 additions & 2 deletions internal/appctx/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ type Env struct {
DefaultDomain string
// UniqueLabel is the env's dashboard slug (e.g. "develop"); used in
// dashboard URLs by export sql and app deploy.
UniqueLabel string
IsMultisite bool
UniqueLabel string
IsMultisite bool
IsK8sResident *bool
}

// AppEnv pairs the resolved App with its target Env. Either field may be
Expand Down
16 changes: 16 additions & 0 deletions internal/gql/generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions internal/gql/operations/app_resolve.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ query ResolveAppByName($name: String!) {
uniqueLabel
defaultDomain
isMultisite
isK8sResident
}
}
}
Expand All @@ -32,6 +33,7 @@ query ResolveAppByID($id: Int!) {
uniqueLabel
defaultDomain
isMultisite
isK8sResident
}
}
}
Loading
Loading