// Copyright (C) The Arvados Authors. All rights reserved. // // SPDX-License-Identifier: AGPL-3.0 package localdb import ( "bytes" "encoding/json" "fmt" "io" "net" "net/http" "net/url" "os" "os/exec" "path" "path/filepath" "slices" "strings" "git.arvados.org/arvados.git/sdk/go/arvados" check "gopkg.in/check.v1" ) var _ = check.Suite(&LoginDockerSuite{}) // LoginDockerSuite is an integration test of controller's different Login // methods. Each test creates a different Login configuration and runs // controller in a Docker container with it. It runs other Docker containers // for supporting services. type LoginDockerSuite struct { localdbSuite tmpdir string netName string netAddr string pgProxy *tcpProxy railsProxy *tcpProxy } func (s *LoginDockerSuite) setUpDockerNetwork() (string, error) { netName := "arvados-net-" + path.Base(path.Dir(s.tmpdir)) cmd := exec.Command("docker", "network", "create", netName) cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return "", err } return netName, nil } // Run cmd and read stdout looking for an IP address on a line by itself. // Return the last one found. func (s *LoginDockerSuite) ipFromCmd(cmd *exec.Cmd) (string, error) { cmd.Stderr = os.Stderr out, err := cmd.Output() if err != nil { return "", err } lines := bytes.Split(out, []byte{'\n'}) slices.Reverse(lines) for _, line := range lines { if ip := net.ParseIP(string(line)); ip != nil { return ip.String(), nil } } return "", fmt.Errorf("no IP address found in the output of %v", cmd) } // SetUpSuite creates a Docker network, starts an openldap server in it, and // creates user account fixtures in LDAP. // We used to use the LDAP server for multiple tests. We don't currently, but // there are pros and cons to starting it here vs. in each individaul test, so // it's staying here for now. func (s *LoginDockerSuite) SetUpSuite(c *check.C) { s.localdbSuite.SetUpSuite(c) s.tmpdir = c.MkDir() var err error s.netName, err = s.setUpDockerNetwork() c.Assert(err, check.IsNil) s.netAddr, err = s.ipFromCmd(exec.Command("docker", "network", "inspect", "--format", "{{(index .IPAM.Config 0).Gateway}}", s.netName)) c.Assert(err, check.IsNil) setup := exec.Command("login_docker_test/setup_suite.sh", s.netName, s.tmpdir) setup.Stderr = os.Stderr err = setup.Run() c.Assert(err, check.IsNil) } // TearDownSuite stops all containers running on the Docker network we set up, // then deletes the network itself. func (s *LoginDockerSuite) TearDownSuite(c *check.C) { if s.netName != "" { cmd := exec.Command("login_docker_test/teardown_suite.sh", s.netName) cmd.Stderr = os.Stderr err := cmd.Run() c.Check(err, check.IsNil) } s.localdbSuite.TearDownSuite(c) } // Create a test cluster configuration in the test temporary directory. // Update it to use the current PostgreSQL and RailsAPI proxies. func (s *LoginDockerSuite) setUpConfig(c *check.C) { src, err := os.Open(os.Getenv("ARVADOS_CONFIG")) c.Assert(err, check.IsNil) defer src.Close() dst, err := os.Create(path.Join(s.tmpdir, "arvados.yml")) c.Assert(err, check.IsNil) _, err = io.Copy(dst, src) closeErr := dst.Close() c.Assert(err, check.IsNil) c.Assert(closeErr, check.IsNil) pgconn := map[string]interface{}{ "host": s.netAddr, "port": s.pgProxy.Port(), } err = s.updateConfig(".Clusters.zzzzz.PostgreSQL.Connection |= (. * $arg)", pgconn) c.Assert(err, check.IsNil) intVal := make(map[string]string) intURLs := make(map[string]interface{}) railsURL := "https://" + net.JoinHostPort(s.netAddr, s.railsProxy.Port()) intURLs[railsURL] = intVal err = s.updateConfig(".Clusters.zzzzz.Services.RailsAPI.InternalURLs = $arg", intURLs) c.Assert(err, check.IsNil) intURLs = make(map[string]interface{}) intURLs["http://0.0.0.0:80"] = intVal err = s.updateConfig(".Clusters.zzzzz.Services.Controller.InternalURLs = $arg", intURLs) c.Assert(err, check.IsNil) } // Update the test cluster configuration with the given yq expression. // The expression can use `$arg` to refer to the object passed in as `arg`. func (s *LoginDockerSuite) updateConfig(expr string, arg map[string]interface{}) error { jsonArg, err := json.Marshal(arg) if err != nil { return err } cmd := exec.Command("yq", "-yi", "--argjson", "arg", string(jsonArg), expr, path.Join(s.tmpdir, "arvados.yml")) cmd.Stderr = os.Stderr return cmd.Run() } // Update the test cluster configuration to use the named login method. func (s *LoginDockerSuite) enableLogin(key string) error { login := make(map[string]interface{}) login["Test"] = map[string]bool{"Enable": false} login[key] = map[string]bool{"Enable": true} return s.updateConfig(".Clusters.zzzzz.Login |= (. * $arg)", login) } // SetUpTest does all the common preparation for a controller test container: // it creates TCP proxies for PostgreSQL and RailsAPI on the test host, then // writes a new Arvados cluster configuration pointed at those for servers to // use. func (s *LoginDockerSuite) SetUpTest(c *check.C) { s.localdbSuite.SetUpTest(c) s.pgProxy = newPgProxy(c, s.cluster, s.netAddr) s.railsProxy = newInternalProxy(c, s.cluster.Services.RailsAPI, s.netAddr) s.setUpConfig(c) } // TearDownTest looks for the `controller.cid` file created when we start the // test container. If found, it stops that container and deletes the file. // Then it closes the TCP proxies created by SetUpTest. func (s *LoginDockerSuite) TearDownTest(c *check.C) { cidPath := path.Join(s.tmpdir, "controller.cid") if cid, err := os.ReadFile(cidPath); err == nil { cmd := exec.Command("docker", "stop", strings.TrimSpace(string(cid))) cmd.Stderr = os.Stderr err := cmd.Run() c.Check(err, check.IsNil) } if err := os.Remove(cidPath); err != nil { c.Check(os.IsNotExist(err), check.Equals, true) } s.railsProxy.Close() s.pgProxy.Close() s.localdbSuite.TearDownTest(c) } func (s *LoginDockerSuite) startController(args ...string) (*url.URL, error) { args = append([]string{s.netName, s.tmpdir}, args...) cmd := exec.Command("login_docker_test/start_controller_container.sh", args...) ip, err := s.ipFromCmd(cmd) if err != nil { return nil, err } return &url.URL{ Scheme: "http", Host: ip, }, nil } func (s *LoginDockerSuite) parseResponse(resp *http.Response, body any) error { defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return err } if resp.StatusCode < 400 { return json.Unmarshal(respBody, body) } var errResp struct { Errors []string } err = json.Unmarshal(respBody, &errResp) if err != nil { return fmt.Errorf("%s with malformed JSON response: %w", resp.Status, err) } else if len(errResp.Errors) == 0 { return fmt.Errorf("%s with no Errors in response", resp.Status) } else { return fmt.Errorf("%s: %s", resp.Status, strings.Join(errResp.Errors, ":")) } } func (s *LoginDockerSuite) authenticate(server *url.URL, username, password string) (*arvados.APIClientAuthorization, error) { reqURL := server.JoinPath("/arvados/v1/users/authenticate").String() reqValues := url.Values{ "username": {username}, "password": {password}, } resp, err := http.PostForm(reqURL, reqValues) if err != nil { return nil, err } token := &arvados.APIClientAuthorization{} err = s.parseResponse(resp, token) return token, err } func (s *LoginDockerSuite) getCurrentUser(server *url.URL, token string) (*arvados.User, error) { reqURL := server.JoinPath("/arvados/v1/users/current").String() req, err := http.NewRequest("GET", reqURL, nil) if err != nil { return nil, err } req.Header.Add("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } user := &arvados.User{} err = s.parseResponse(resp, user) return user, err } func (s *LoginDockerSuite) TestLoginPAM(c *check.C) { err := s.enableLogin("PAM") c.Assert(err, check.IsNil) setupPath, err := filepath.Abs("login_docker_test/setup_pam_test.sh") c.Assert(err, check.IsNil) arvURL, err := s.startController("-v", setupPath+":/setup.sh:ro") c.Assert(err, check.IsNil) _, err = s.authenticate(arvURL, "foo-bar", "nosecret") c.Check(err, check.ErrorMatches, `401 Unauthorized: PAM: Authentication failure \(with username "foo-bar" and password\)`) _, err = s.authenticate(arvURL, "expired", "secret") c.Check(err, check.ErrorMatches, `401 Unauthorized: PAM: Authentication failure; "Your account has expired; please contact your system administrator\."`) aca, err := s.authenticate(arvURL, "foo-bar", "secret") if c.Check(err, check.IsNil) { user, err := s.getCurrentUser(arvURL, aca.TokenV2()) if c.Check(err, check.IsNil) { // Check PAMDefaultEmailDomain was propagated as expected c.Check(user.Email, check.Equals, "foo-bar@example.com") } } } func (s *LoginDockerSuite) TestLoginLDAPBuiltin(c *check.C) { err := s.enableLogin("LDAP") c.Assert(err, check.IsNil) arvURL, err := s.startController() c.Assert(err, check.IsNil) _, err = s.authenticate(arvURL, "foo-bar", "nosecret") c.Check(err, check.ErrorMatches, `401 Unauthorized: LDAP: Authentication failure \(with username "foo-bar" and password\)`) aca, err := s.authenticate(arvURL, "foo-bar", "secret") if c.Check(err, check.IsNil) { user, err := s.getCurrentUser(arvURL, aca.TokenV2()) if c.Check(err, check.IsNil) { // User fields come from LDAP attributes c.Check(user.FirstName, check.Equals, "Foo") c.Check(user.LastName, check.Equals, "Bar") // "-" character removed by RailsAPI c.Check(user.Username, check.Equals, "foobar") c.Check(user.Email, check.Equals, "foo-bar-baz@example.com") } } }