]> git.arvados.org - arvados.git/blob - lib/controller/localdb/login_docker_test.go
22958: Add missing `become`
[arvados.git] / lib / controller / localdb / login_docker_test.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 package localdb
6
7 import (
8         "bytes"
9         "encoding/json"
10         "fmt"
11         "io"
12         "net"
13         "net/http"
14         "net/url"
15         "os"
16         "os/exec"
17         "path"
18         "path/filepath"
19         "slices"
20         "strings"
21
22         "git.arvados.org/arvados.git/sdk/go/arvados"
23         check "gopkg.in/check.v1"
24 )
25
26 var _ = check.Suite(&LoginDockerSuite{})
27
28 // LoginDockerSuite is an integration test of controller's different Login
29 // methods.  Each test creates a different Login configuration and runs
30 // controller in a Docker container with it. It runs other Docker containers
31 // for supporting services.
32 type LoginDockerSuite struct {
33         localdbSuite
34         tmpdir     string
35         netName    string
36         netAddr    string
37         pgProxy    *tcpProxy
38         railsProxy *tcpProxy
39 }
40
41 func (s *LoginDockerSuite) setUpDockerNetwork() (string, error) {
42         netName := "arvados-net-" + path.Base(path.Dir(s.tmpdir))
43         cmd := exec.Command("docker", "network", "create", netName)
44         cmd.Stderr = os.Stderr
45         if err := cmd.Run(); err != nil {
46                 return "", err
47         }
48         return netName, nil
49 }
50
51 // Run cmd and read stdout looking for an IP address on a line by itself.
52 // Return the last one found.
53 func (s *LoginDockerSuite) ipFromCmd(cmd *exec.Cmd) (string, error) {
54         cmd.Stderr = os.Stderr
55         out, err := cmd.Output()
56         if err != nil {
57                 return "", err
58         }
59         lines := bytes.Split(out, []byte{'\n'})
60         slices.Reverse(lines)
61         for _, line := range lines {
62                 if ip := net.ParseIP(string(line)); ip != nil {
63                         return ip.String(), nil
64                 }
65         }
66         return "", fmt.Errorf("no IP address found in the output of %v", cmd)
67 }
68
69 // SetUpSuite creates a Docker network, starts an openldap server in it, and
70 // creates user account fixtures in LDAP.
71 // We used to use the LDAP server for multiple tests. We don't currently, but
72 // there are pros and cons to starting it here vs. in each individaul test, so
73 // it's staying here for now.
74 func (s *LoginDockerSuite) SetUpSuite(c *check.C) {
75         s.localdbSuite.SetUpSuite(c)
76         s.tmpdir = c.MkDir()
77         var err error
78         s.netName, err = s.setUpDockerNetwork()
79         c.Assert(err, check.IsNil)
80         s.netAddr, err = s.ipFromCmd(exec.Command("docker", "network", "inspect",
81                 "--format", "{{(index .IPAM.Config 0).Gateway}}", s.netName))
82         c.Assert(err, check.IsNil)
83         setup := exec.Command("login_docker_test/setup_suite.sh", s.netName, s.tmpdir)
84         setup.Stderr = os.Stderr
85         err = setup.Run()
86         c.Assert(err, check.IsNil)
87 }
88
89 // TearDownSuite stops all containers running on the Docker network we set up,
90 // then deletes the network itself.
91 func (s *LoginDockerSuite) TearDownSuite(c *check.C) {
92         if s.netName != "" {
93                 cmd := exec.Command("login_docker_test/teardown_suite.sh", s.netName)
94                 cmd.Stderr = os.Stderr
95                 err := cmd.Run()
96                 c.Check(err, check.IsNil)
97         }
98         s.localdbSuite.TearDownSuite(c)
99 }
100
101 // Create a test cluster configuration in the test temporary directory.
102 // Update it to use the current PostgreSQL and RailsAPI proxies.
103 func (s *LoginDockerSuite) setUpConfig(c *check.C) {
104         src, err := os.Open(os.Getenv("ARVADOS_CONFIG"))
105         c.Assert(err, check.IsNil)
106         defer src.Close()
107         dst, err := os.Create(path.Join(s.tmpdir, "arvados.yml"))
108         c.Assert(err, check.IsNil)
109         _, err = io.Copy(dst, src)
110         closeErr := dst.Close()
111         c.Assert(err, check.IsNil)
112         c.Assert(closeErr, check.IsNil)
113
114         pgconn := map[string]interface{}{
115                 "host": s.netAddr,
116                 "port": s.pgProxy.Port(),
117         }
118         err = s.updateConfig(".Clusters.zzzzz.PostgreSQL.Connection |= (. * $arg)", pgconn)
119         c.Assert(err, check.IsNil)
120         intVal := make(map[string]string)
121         intURLs := make(map[string]interface{})
122         railsURL := "https://" + net.JoinHostPort(s.netAddr, s.railsProxy.Port())
123         intURLs[railsURL] = intVal
124         err = s.updateConfig(".Clusters.zzzzz.Services.RailsAPI.InternalURLs = $arg", intURLs)
125         c.Assert(err, check.IsNil)
126         intURLs = make(map[string]interface{})
127         intURLs["http://0.0.0.0:80"] = intVal
128         err = s.updateConfig(".Clusters.zzzzz.Services.Controller.InternalURLs = $arg", intURLs)
129         c.Assert(err, check.IsNil)
130 }
131
132 // Update the test cluster configuration with the given yq expression.
133 // The expression can use `$arg` to refer to the object passed in as `arg`.
134 func (s *LoginDockerSuite) updateConfig(expr string, arg map[string]interface{}) error {
135         jsonArg, err := json.Marshal(arg)
136         if err != nil {
137                 return err
138         }
139         cmd := exec.Command("yq", "-yi",
140                 "--argjson", "arg", string(jsonArg),
141                 expr, path.Join(s.tmpdir, "arvados.yml"))
142         cmd.Stderr = os.Stderr
143         return cmd.Run()
144 }
145
146 // Update the test cluster configuration to use the named login method.
147 func (s *LoginDockerSuite) enableLogin(key string) error {
148         login := make(map[string]interface{})
149         login["Test"] = map[string]bool{"Enable": false}
150         login[key] = map[string]bool{"Enable": true}
151         return s.updateConfig(".Clusters.zzzzz.Login |= (. * $arg)", login)
152 }
153
154 // SetUpTest does all the common preparation for a controller test container:
155 // it creates TCP proxies for PostgreSQL and RailsAPI on the test host, then
156 // writes a new Arvados cluster configuration pointed at those for servers to
157 // use.
158 func (s *LoginDockerSuite) SetUpTest(c *check.C) {
159         s.localdbSuite.SetUpTest(c)
160         s.pgProxy = newPgProxy(c, s.cluster, s.netAddr)
161         s.railsProxy = newInternalProxy(c, s.cluster.Services.RailsAPI, s.netAddr)
162         s.setUpConfig(c)
163 }
164
165 // TearDownTest looks for the `controller.cid` file created when we start the
166 // test container. If found, it stops that container and deletes the file.
167 // Then it closes the TCP proxies created by SetUpTest.
168 func (s *LoginDockerSuite) TearDownTest(c *check.C) {
169         cidPath := path.Join(s.tmpdir, "controller.cid")
170         if cid, err := os.ReadFile(cidPath); err == nil {
171                 cmd := exec.Command("docker", "stop", strings.TrimSpace(string(cid)))
172                 cmd.Stderr = os.Stderr
173                 err := cmd.Run()
174                 c.Check(err, check.IsNil)
175         }
176         if err := os.Remove(cidPath); err != nil {
177                 c.Check(os.IsNotExist(err), check.Equals, true)
178         }
179         s.railsProxy.Close()
180         s.pgProxy.Close()
181         s.localdbSuite.TearDownTest(c)
182 }
183
184 func (s *LoginDockerSuite) startController(args ...string) (*url.URL, error) {
185         args = append([]string{s.netName, s.tmpdir}, args...)
186         cmd := exec.Command("login_docker_test/start_controller_container.sh", args...)
187         ip, err := s.ipFromCmd(cmd)
188         if err != nil {
189                 return nil, err
190         }
191         return &url.URL{
192                 Scheme: "http",
193                 Host:   ip,
194         }, nil
195 }
196
197 func (s *LoginDockerSuite) parseResponse(resp *http.Response, body any) error {
198         defer resp.Body.Close()
199         respBody, err := io.ReadAll(resp.Body)
200         if err != nil {
201                 return err
202         }
203         if resp.StatusCode < 400 {
204                 return json.Unmarshal(respBody, body)
205         }
206         var errResp struct {
207                 Errors []string
208         }
209         err = json.Unmarshal(respBody, &errResp)
210         if err != nil {
211                 return fmt.Errorf("%s with malformed JSON response: %w", resp.Status, err)
212         } else if len(errResp.Errors) == 0 {
213                 return fmt.Errorf("%s with no Errors in response", resp.Status)
214         } else {
215                 return fmt.Errorf("%s: %s", resp.Status, strings.Join(errResp.Errors, ":"))
216         }
217 }
218
219 func (s *LoginDockerSuite) authenticate(server *url.URL, username, password string) (*arvados.APIClientAuthorization, error) {
220         reqURL := server.JoinPath("/arvados/v1/users/authenticate").String()
221         reqValues := url.Values{
222                 "username": {username},
223                 "password": {password},
224         }
225         resp, err := http.PostForm(reqURL, reqValues)
226         if err != nil {
227                 return nil, err
228         }
229         token := &arvados.APIClientAuthorization{}
230         err = s.parseResponse(resp, token)
231         return token, err
232 }
233
234 func (s *LoginDockerSuite) getCurrentUser(server *url.URL, token string) (*arvados.User, error) {
235         reqURL := server.JoinPath("/arvados/v1/users/current").String()
236         req, err := http.NewRequest("GET", reqURL, nil)
237         if err != nil {
238                 return nil, err
239         }
240         req.Header.Add("Authorization", "Bearer "+token)
241         resp, err := http.DefaultClient.Do(req)
242         if err != nil {
243                 return nil, err
244         }
245         user := &arvados.User{}
246         err = s.parseResponse(resp, user)
247         return user, err
248 }
249
250 func (s *LoginDockerSuite) TestLoginPAM(c *check.C) {
251         err := s.enableLogin("PAM")
252         c.Assert(err, check.IsNil)
253         setupPath, err := filepath.Abs("login_docker_test/setup_pam_test.sh")
254         c.Assert(err, check.IsNil)
255         arvURL, err := s.startController("-v", setupPath+":/setup.sh:ro")
256         c.Assert(err, check.IsNil)
257
258         _, err = s.authenticate(arvURL, "foo-bar", "nosecret")
259         c.Check(err, check.ErrorMatches,
260                 `401 Unauthorized: PAM: Authentication failure \(with username "foo-bar" and password\)`)
261
262         _, err = s.authenticate(arvURL, "expired", "secret")
263         c.Check(err, check.ErrorMatches,
264                 `401 Unauthorized: PAM: Authentication failure; "Your account has expired; please contact your system administrator\."`)
265
266         aca, err := s.authenticate(arvURL, "foo-bar", "secret")
267         if c.Check(err, check.IsNil) {
268                 user, err := s.getCurrentUser(arvURL, aca.TokenV2())
269                 if c.Check(err, check.IsNil) {
270                         // Check PAMDefaultEmailDomain was propagated as expected
271                         c.Check(user.Email, check.Equals, "foo-bar@example.com")
272                 }
273         }
274 }
275
276 func (s *LoginDockerSuite) TestLoginLDAPBuiltin(c *check.C) {
277         err := s.enableLogin("LDAP")
278         c.Assert(err, check.IsNil)
279         arvURL, err := s.startController()
280         c.Assert(err, check.IsNil)
281
282         _, err = s.authenticate(arvURL, "foo-bar", "nosecret")
283         c.Check(err, check.ErrorMatches,
284                 `401 Unauthorized: LDAP: Authentication failure \(with username "foo-bar" and password\)`)
285
286         aca, err := s.authenticate(arvURL, "foo-bar", "secret")
287         if c.Check(err, check.IsNil) {
288                 user, err := s.getCurrentUser(arvURL, aca.TokenV2())
289                 if c.Check(err, check.IsNil) {
290                         // User fields come from LDAP attributes
291                         c.Check(user.FirstName, check.Equals, "Foo")
292                         c.Check(user.LastName, check.Equals, "Bar")
293                         // "-" character removed by RailsAPI
294                         c.Check(user.Username, check.Equals, "foobar")
295                         c.Check(user.Email, check.Equals, "foo-bar-baz@example.com")
296                 }
297         }
298 }