/* * MinIO Cloud Storage, (C) 2016-2019 MinIO, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package cmd import ( "bytes" "context" "encoding/json" "io" "io/ioutil" "net/http" "net/http/httptest" "net/url" "reflect" "strings" "sync" "testing" "time" "github.com/gorilla/mux" "github.com/minio/minio/pkg/auth" "github.com/minio/minio/pkg/madmin" "github.com/tidwall/gjson" ) var ( configJSON = []byte(`{ "version": "33", "credential": { "accessKey": "minio", "secretKey": "minio123" }, "region": "us-east-1", "worm": "off", "storageclass": { "standard": "", "rrs": "" }, "cache": { "drives": [], "expiry": 90, "maxuse": 80, "exclude": [] }, "kms": { "vault": { "endpoint": "", "auth": { "type": "", "approle": { "id": "", "secret": "" } }, "key-id": { "name": "", "version": 0 } } }, "notify": { "amqp": { "1": { "enable": false, "url": "", "exchange": "", "routingKey": "", "exchangeType": "", "deliveryMode": 0, "mandatory": false, "immediate": false, "durable": false, "internal": false, "noWait": false, "autoDeleted": false, "queueDir": "", "queueLimit": 0 } }, "elasticsearch": { "1": { "enable": false, "format": "namespace", "url": "", "index": "", "queueDir": "", "queueLimit": 0 } }, "kafka": { "1": { "enable": false, "brokers": null, "topic": "", "queueDir": "", "queueLimit": 0, "tls": { "enable": false, "skipVerify": false, "clientAuth": 0 }, "sasl": { "enable": false, "username": "", "password": "" } } }, "mqtt": { "1": { "enable": false, "broker": "", "topic": "", "qos": 0, "username": "", "password": "", "reconnectInterval": 0, "keepAliveInterval": 0, "queueDir": "", "queueLimit": 0 } }, "mysql": { "1": { "enable": false, "format": "namespace", "dsnString": "", "table": "", "host": "", "port": "", "user": "", "password": "", "database": "", "queueDir": "", "queueLimit": 0 } }, "nats": { "1": { "enable": false, "address": "", "subject": "", "username": "", "password": "", "token": "", "secure": false, "pingInterval": 0, "queueDir": "", "queueLimit": 0, "streaming": { "enable": false, "clusterID": "", "async": false, "maxPubAcksInflight": 0 } } }, "nsq": { "1": { "enable": false, "nsqdAddress": "", "topic": "", "tls": { "enable": false, "skipVerify": false }, "queueDir": "", "queueLimit": 0 } }, "postgresql": { "1": { "enable": false, "format": "namespace", "connectionString": "", "table": "", "host": "", "port": "", "user": "", "password": "", "database": "", "queueDir": "", "queueLimit": 0 } }, "redis": { "1": { "enable": false, "format": "namespace", "address": "", "password": "", "key": "", "queueDir": "", "queueLimit": 0 } }, "webhook": { "1": { "enable": false, "endpoint": "", "queueDir": "", "queueLimit": 0 } } }, "logger": { "console": { "enabled": true }, "http": { "1": { "enabled": false, "endpoint": "https://username:password@example.com/api" } } }, "compress": { "enabled": false, "extensions":[".txt",".log",".csv",".json"], "mime-types":["text/csv","text/plain","application/json"] }, "openid": { "jwks": { "url": "" } }, "policy": { "opa": { "url": "", "authToken": "" } } } `) ) // adminXLTestBed - encapsulates subsystems that need to be setup for // admin-handler unit tests. type adminXLTestBed struct { xlDirs []string objLayer ObjectLayer router *mux.Router } // prepareAdminXLTestBed - helper function that setups a single-node // XL backend for admin-handler tests. func prepareAdminXLTestBed() (*adminXLTestBed, error) { // reset global variables to start afresh. resetTestGlobals() // Initializing objectLayer for HealFormatHandler. objLayer, xlDirs, xlErr := initTestXLObjLayer() if xlErr != nil { return nil, xlErr } // Initialize minio server config. if err := newTestConfig(globalMinioDefaultRegion, objLayer); err != nil { return nil, err } // Initialize boot time globalBootTime = UTCNow() globalEndpoints = mustGetNewEndpointList(xlDirs...) // Set globalIsXL to indicate that the setup uses an erasure // code backend. globalIsXL = true // initialize NSLock. isDistXL := false initNSLock(isDistXL) // Init global heal state if globalIsXL { globalAllHealState = initHealState() } globalConfigSys = NewConfigSys() globalIAMSys = NewIAMSys() globalIAMSys.Init(objLayer) globalPolicySys = NewPolicySys() globalPolicySys.Init(objLayer) globalNotificationSys = NewNotificationSys(globalServerConfig, globalEndpoints) globalNotificationSys.Init(objLayer) // Setup admin mgmt REST API handlers. adminRouter := mux.NewRouter() registerAdminRouter(adminRouter, true, true) return &adminXLTestBed{ xlDirs: xlDirs, objLayer: objLayer, router: adminRouter, }, nil } // TearDown - method that resets the test bed for subsequent unit // tests to start afresh. func (atb *adminXLTestBed) TearDown() { removeRoots(atb.xlDirs) resetTestGlobals() } // initTestObjLayer - Helper function to initialize an XL-based object // layer and set globalObjectAPI. func initTestXLObjLayer() (ObjectLayer, []string, error) { xlDirs, err := getRandomDisks(16) if err != nil { return nil, nil, err } endpoints := mustGetNewEndpointList(xlDirs...) format, err := waitForFormatXL(context.Background(), true, endpoints, 1, 16) if err != nil { removeRoots(xlDirs) return nil, nil, err } globalPolicySys = NewPolicySys() objLayer, err := newXLSets(endpoints, format, 1, 16) if err != nil { return nil, nil, err } // Make objLayer available to all internal services via globalObjectAPI. globalObjLayerMutex.Lock() globalObjectAPI = objLayer globalObjLayerMutex.Unlock() return objLayer, xlDirs, nil } // cmdType - Represents different service subcomands like status, stop // and restart. type cmdType int const ( restartCmd cmdType = iota stopCmd ) // toServiceSignal - Helper function that translates a given cmdType // value to its corresponding serviceSignal value. func (c cmdType) toServiceSignal() serviceSignal { switch c { case restartCmd: return serviceRestart case stopCmd: return serviceStop } return serviceRestart } func (c cmdType) toServiceAction() madmin.ServiceAction { switch c { case restartCmd: return madmin.ServiceActionRestart case stopCmd: return madmin.ServiceActionStop } return madmin.ServiceActionRestart } // testServiceSignalReceiver - Helper function that simulates a // go-routine waiting on service signal. func testServiceSignalReceiver(cmd cmdType, t *testing.T) { expectedCmd := cmd.toServiceSignal() serviceCmd := <-globalServiceSignalCh if serviceCmd != expectedCmd { t.Errorf("Expected service command %v but received %v", expectedCmd, serviceCmd) } } // getServiceCmdRequest - Constructs a management REST API request for service // subcommands for a given cmdType value. func getServiceCmdRequest(cmd cmdType, cred auth.Credentials) (*http.Request, error) { queryVal := url.Values{} queryVal.Set("action", string(cmd.toServiceAction())) resource := "/minio/admin/v1/service?" + queryVal.Encode() req, err := newTestRequest(http.MethodPost, resource, 0, nil) if err != nil { return nil, err } // management REST API uses signature V4 for authentication. err = signRequestV4(req, cred.AccessKey, cred.SecretKey) if err != nil { return nil, err } return req, nil } // testServicesCmdHandler - parametrizes service subcommand tests on // cmdType value. func testServicesCmdHandler(cmd cmdType, t *testing.T) { adminTestBed, err := prepareAdminXLTestBed() if err != nil { t.Fatal("Failed to initialize a single node XL backend for admin handler tests.") } defer adminTestBed.TearDown() // Initialize admin peers to make admin RPC calls. Note: In a // single node setup, this degenerates to a simple function // call under the hood. globalMinioAddr = "127.0.0.1:9000" var wg sync.WaitGroup // Setting up a go routine to simulate ServerRouter's // handleServiceSignals for stop and restart commands. if cmd == restartCmd { wg.Add(1) go func() { defer wg.Done() testServiceSignalReceiver(cmd, t) }() } credentials := globalServerConfig.GetCredential() req, err := getServiceCmdRequest(cmd, credentials) if err != nil { t.Fatalf("Failed to build service status request %v", err) } rec := httptest.NewRecorder() adminTestBed.router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { resp, _ := ioutil.ReadAll(rec.Body) t.Errorf("Expected to receive %d status code but received %d. Body (%s)", http.StatusOK, rec.Code, string(resp)) } // Wait until testServiceSignalReceiver() called in a goroutine quits. wg.Wait() } // Test for service restart management REST API. func TestServiceRestartHandler(t *testing.T) { testServicesCmdHandler(restartCmd, t) } // buildAdminRequest - helper function to build an admin API request. func buildAdminRequest(queryVal url.Values, method, path string, contentLength int64, bodySeeker io.ReadSeeker) (*http.Request, error) { req, err := newTestRequest(method, "/minio/admin/v1"+path+"?"+queryVal.Encode(), contentLength, bodySeeker) if err != nil { return nil, err } cred := globalServerConfig.GetCredential() err = signRequestV4(req, cred.AccessKey, cred.SecretKey) if err != nil { return nil, err } return req, nil } // TestGetConfigHandler - test for GetConfigHandler. func TestGetConfigHandler(t *testing.T) { adminTestBed, err := prepareAdminXLTestBed() if err != nil { t.Fatal("Failed to initialize a single node XL backend for admin handler tests.") } defer adminTestBed.TearDown() // Initialize admin peers to make admin RPC calls. globalMinioAddr = "127.0.0.1:9000" // Prepare query params for get-config mgmt REST API. queryVal := url.Values{} queryVal.Set("config", "") req, err := buildAdminRequest(queryVal, http.MethodGet, "/config", 0, nil) if err != nil { t.Fatalf("Failed to construct get-config object request - %v", err) } rec := httptest.NewRecorder() adminTestBed.router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("Expected to succeed but failed with %d", rec.Code) } } // TestSetConfigHandler - test for SetConfigHandler. func TestSetConfigHandler(t *testing.T) { adminTestBed, err := prepareAdminXLTestBed() if err != nil { t.Fatal("Failed to initialize a single node XL backend for admin handler tests.") } defer adminTestBed.TearDown() // Initialize admin peers to make admin RPC calls. globalMinioAddr = "127.0.0.1:9000" // Prepare query params for set-config mgmt REST API. queryVal := url.Values{} queryVal.Set("config", "") password := globalServerConfig.GetCredential().SecretKey econfigJSON, err := madmin.EncryptData(password, configJSON) if err != nil { t.Fatal(err) } req, err := buildAdminRequest(queryVal, http.MethodPut, "/config", int64(len(econfigJSON)), bytes.NewReader(econfigJSON)) if err != nil { t.Fatalf("Failed to construct set-config object request - %v", err) } rec := httptest.NewRecorder() adminTestBed.router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("Expected to succeed but failed with %d, body: %s", rec.Code, rec.Body) } // Check that a very large config file returns an error. { // Make a large enough config string invalidCfg := []byte(strings.Repeat("A", maxEConfigJSONSize+1)) req, err := buildAdminRequest(queryVal, http.MethodPut, "/config", int64(len(invalidCfg)), bytes.NewReader(invalidCfg)) if err != nil { t.Fatalf("Failed to construct set-config object request - %v", err) } rec := httptest.NewRecorder() adminTestBed.router.ServeHTTP(rec, req) respBody := rec.Body.String() if rec.Code != http.StatusBadRequest || !strings.Contains(respBody, "Configuration data provided exceeds the allowed maximum of") { t.Errorf("Got unexpected response code or body %d - %s", rec.Code, respBody) } } // Check that a config with duplicate keys in an object return // error. { invalidCfg := append(econfigJSON[:len(econfigJSON)-1], []byte(`, "version": "15"}`)...) req, err := buildAdminRequest(queryVal, http.MethodPut, "/config", int64(len(invalidCfg)), bytes.NewReader(invalidCfg)) if err != nil { t.Fatalf("Failed to construct set-config object request - %v", err) } rec := httptest.NewRecorder() adminTestBed.router.ServeHTTP(rec, req) respBody := rec.Body.String() if rec.Code != http.StatusBadRequest || !strings.Contains(respBody, "JSON configuration provided is of incorrect format") { t.Errorf("Got unexpected response code or body %d - %s", rec.Code, respBody) } } } func TestAdminServerInfo(t *testing.T) { adminTestBed, err := prepareAdminXLTestBed() if err != nil { t.Fatal("Failed to initialize a single node XL backend for admin handler tests.") } defer adminTestBed.TearDown() // Initialize admin peers to make admin RPC calls. globalMinioAddr = "127.0.0.1:9000" // Prepare query params for set-config mgmt REST API. queryVal := url.Values{} queryVal.Set("info", "") req, err := buildAdminRequest(queryVal, http.MethodGet, "/info", 0, nil) if err != nil { t.Fatalf("Failed to construct get-config object request - %v", err) } rec := httptest.NewRecorder() adminTestBed.router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("Expected to succeed but failed with %d", rec.Code) } results := []ServerInfo{} err = json.NewDecoder(rec.Body).Decode(&results) if err != nil { t.Fatalf("Failed to decode set config result json %v", err) } if len(results) == 0 { t.Error("Expected at least one server info result") } for _, serverInfo := range results { if serverInfo.Error != "" { t.Errorf("Unexpected error = %v\n", serverInfo.Error) } if serverInfo.Data.Properties.Region != globalMinioDefaultRegion { t.Errorf("Expected %s, got %s", globalMinioDefaultRegion, serverInfo.Data.Properties.Region) } } } // TestToAdminAPIErrCode - test for toAdminAPIErrCode helper function. func TestToAdminAPIErrCode(t *testing.T) { testCases := []struct { err error expectedAPIErr APIErrorCode }{ // 1. Server not in quorum. { err: errXLWriteQuorum, expectedAPIErr: ErrAdminConfigNoQuorum, }, // 2. No error. { err: nil, expectedAPIErr: ErrNone, }, // 3. Non-admin API specific error. { err: errDiskNotFound, expectedAPIErr: toAPIErrorCode(context.Background(), errDiskNotFound), }, } for i, test := range testCases { actualErr := toAdminAPIErrCode(context.Background(), test.err) if actualErr != test.expectedAPIErr { t.Errorf("Test %d: Expected %v but received %v", i+1, test.expectedAPIErr, actualErr) } } } func TestTopLockEntries(t *testing.T) { t1 := UTCNow() t2 := UTCNow().Add(10 * time.Second) peerLocks := []*PeerLocks{ { Addr: "1", Locks: map[string][]lockRequesterInfo{ "1": { {false, "node2", "ep2", "2", t2, t2, ""}, {true, "node1", "ep1", "1", t1, t1, ""}, }, "2": { {false, "node2", "ep2", "2", t2, t2, ""}, {true, "node1", "ep1", "1", t1, t1, ""}, }, }, }, { Addr: "2", Locks: map[string][]lockRequesterInfo{ "1": { {false, "node2", "ep2", "2", t2, t2, ""}, {true, "node1", "ep1", "1", t1, t1, ""}, }, "2": { {false, "node2", "ep2", "2", t2, t2, ""}, {true, "node1", "ep1", "1", t1, t1, ""}, }, }, }, } les := topLockEntries(peerLocks) if len(les) != 2 { t.Fatalf("Did not get 2 results") } if les[0].Timestamp.After(les[1].Timestamp) { t.Fatalf("Got wrong sorted value") } } func TestExtractHealInitParams(t *testing.T) { mkParams := func(clientToken string, forceStart, forceStop bool) url.Values { v := url.Values{} if clientToken != "" { v.Add(string(mgmtClientToken), clientToken) } if forceStart { v.Add(string(mgmtForceStart), "") } if forceStop { v.Add(string(mgmtForceStop), "") } return v } qParmsArr := []url.Values{ // Invalid cases mkParams("", true, true), mkParams("111", true, true), mkParams("111", true, false), mkParams("111", false, true), // Valid cases follow mkParams("", true, false), mkParams("", false, true), mkParams("", false, false), mkParams("111", false, false), } varsArr := []map[string]string{ // Invalid cases {string(mgmtPrefix): "objprefix"}, // Valid cases {}, {string(mgmtBucket): "bucket"}, {string(mgmtBucket): "bucket", string(mgmtPrefix): "objprefix"}, } // Body is always valid - we do not test JSON decoding. body := `{"recursive": false, "dryRun": true, "remove": false, "scanMode": 0}` // Test all combinations! for pIdx, parms := range qParmsArr { for vIdx, vars := range varsArr { _, err := extractHealInitParams(vars, parms, bytes.NewBuffer([]byte(body))) isErrCase := false if pIdx < 4 || vIdx < 1 { isErrCase = true } if err != ErrNone && !isErrCase { t.Errorf("Got unexpected error: %v %v %v", pIdx, vIdx, err) } else if err == ErrNone && isErrCase { t.Errorf("Got no error but expected one: %v %v", pIdx, vIdx) } } } } func TestNormalizeJSONKey(t *testing.T) { cases := []struct { input string correctResult string }{ {"notify.webhook.1", "notify.webhook.:1"}, {"notify.webhook.ok", "notify.webhook.ok"}, {"notify.webhook.1.2", "notify.webhook.:1.:2"}, {"abc", "abc"}, } for i, tcase := range cases { res := normalizeJSONKey(tcase.input) if res != tcase.correctResult { t.Errorf("Case %d: failed to match", i) } } } func TestConvertValueType(t *testing.T) { cases := []struct { elem []byte jt gjson.Type correctResult interface{} isError bool }{ {[]byte(""), gjson.Null, nil, false}, {[]byte("t"), gjson.False, true, false}, {[]byte("f"), gjson.False, false, false}, {[]byte(`{"a": 1}`), gjson.JSON, map[string]interface{}{"a": float64(1)}, false}, {[]byte(`abc`), gjson.String, "abc", false}, {[]byte(`1`), gjson.Number, float64(1), false}, {[]byte(`a1`), gjson.Number, nil, true}, {[]byte(`{"A": `), gjson.JSON, nil, true}, {[]byte(`2`), gjson.False, nil, true}, } for i, tc := range cases { res, err := convertValueType(tc.elem, tc.jt) if err != nil { if !tc.isError { t.Errorf("Case %d: got an error when none was expected", i) } } else if err == nil && tc.isError { t.Errorf("Case %d: got no error though we expected one", i) } else if !reflect.DeepEqual(res, tc.correctResult) { t.Errorf("Case %d: result mismatch - got %#v", i, res) } } }