github.com/go-kivik/kivik/v4@v4.3.2/couchdb/test/session.go (about)

     1  // Licensed under the Apache License, Version 2.0 (the "License"); you may not
     2  // use this file except in compliance with the License. You may obtain a copy of
     3  // the License at
     4  //
     5  //  http://www.apache.org/licenses/LICENSE-2.0
     6  //
     7  // Unless required by applicable law or agreed to in writing, software
     8  // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
     9  // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
    10  // License for the specific language governing permissions and limitations under
    11  // the License.
    12  
    13  package test
    14  
    15  import (
    16  	"context"
    17  	"encoding/base64"
    18  	"encoding/hex"
    19  	"encoding/json"
    20  	"fmt"
    21  	"net/http"
    22  	"net/url"
    23  	"strings"
    24  
    25  	kivik "github.com/go-kivik/kivik/v4"
    26  	"github.com/go-kivik/kivik/v4/couchdb/chttp"
    27  	"github.com/go-kivik/kivik/v4/int/mock"
    28  	"github.com/go-kivik/kivik/v4/kiviktest/kt"
    29  )
    30  
    31  func init() {
    32  	kt.Register("Session", session)
    33  }
    34  
    35  func session(ctx *kt.Context) {
    36  	chttpAdmin, err := chttp.New(&http.Client{}, ctx.Admin.DSN(), mock.NilOption)
    37  	if err != nil {
    38  		ctx.Fatalf("chttp.Admin failed: %s", err)
    39  	}
    40  	chttpNoAuth, err := chttp.New(&http.Client{}, ctx.NoAuth.DSN(), mock.NilOption)
    41  	if err != nil {
    42  		ctx.Fatalf("chttp.NoAuth failed: %s", err)
    43  	}
    44  	ctx.Run("Get", func(ctx *kt.Context) {
    45  		ctx.RunAdmin(func(ctx *kt.Context) {
    46  			testSession(ctx, chttpAdmin)
    47  		})
    48  		ctx.RunNoAuth(func(ctx *kt.Context) {
    49  			testSession(ctx, chttpNoAuth)
    50  		})
    51  	})
    52  	ctx.Run("Post", func(ctx *kt.Context) {
    53  		testCreateSession(ctx, chttpNoAuth)
    54  	})
    55  	ctx.Run("Delete", func(ctx *kt.Context) {
    56  		testDeleteSession(ctx, chttpNoAuth)
    57  	})
    58  }
    59  
    60  func testSession(ctx *kt.Context, client *chttp.Client) {
    61  	ctx.Parallel()
    62  	if client == nil {
    63  		ctx.Skipf("No CHTTP client")
    64  	}
    65  	uCtx := struct {
    66  		Info struct {
    67  			AuthMethod   string   `json:"authenticated"`
    68  			AuthDB       string   `json:"authentication_db"`
    69  			AuthHandlers []string `json:"authentication_handlers"`
    70  		} `json:"info"`
    71  		OK      bool `json:"ok"`
    72  		UserCtx struct {
    73  			Name  string   `json:"name"`
    74  			Roles []string `json:"roles"`
    75  		} `json:"userCtx"`
    76  	}{}
    77  	err := client.DoJSON(context.Background(), http.MethodGet, "/_session", nil, &uCtx)
    78  	if !ctx.IsExpectedSuccess(err) {
    79  		return
    80  	}
    81  	values := map[string]string{
    82  		"info.authenticated":           uCtx.Info.AuthMethod,
    83  		"info.authentication_db":       uCtx.Info.AuthDB,
    84  		"info.authentication_handlers": strings.Join(uCtx.Info.AuthHandlers, ","),
    85  		"ok":                           fmt.Sprintf("%t", uCtx.OK),
    86  		"userCtx.roles":                strings.Join(uCtx.UserCtx.Roles, ","),
    87  	}
    88  	for key, actual := range values {
    89  		expected := ctx.MustString(key)
    90  		if actual != expected {
    91  			ctx.Errorf("Unexpected value for `%s`. Expected '%s', actual '%s'", key, expected, actual)
    92  		}
    93  	}
    94  	dsn, _ := url.Parse(client.DSN())
    95  	var expected string
    96  	if dsn.User != nil {
    97  		expected = dsn.User.Username()
    98  	}
    99  	actual := uCtx.UserCtx.Name
   100  	if actual != expected {
   101  		ctx.Errorf("Unexpected value for `%s`. Expected '%s', actual '%s'", "userCtx.name", expected, actual)
   102  	}
   103  }
   104  
   105  type sessionPostTest struct {
   106  	Name    string
   107  	Query   string
   108  	Options *chttp.Options
   109  	// True if the test requires valid credentials
   110  	Creds bool
   111  }
   112  
   113  func testCreateSession(ctx *kt.Context, client *chttp.Client) {
   114  	if client == nil {
   115  		ctx.Skipf("No CHTTP client")
   116  	}
   117  	// Re-create client, so we can override defaults
   118  	client, _ = chttp.New(&http.Client{}, client.DSN(), mock.NilOption)
   119  	// Don't follow redirect
   120  	client.CheckRedirect = func(*http.Request, []*http.Request) error {
   121  		return http.ErrUseLastResponse
   122  	}
   123  	var name, password string
   124  	if ctx.Admin != nil {
   125  		if dsn, _ := url.Parse(ctx.Admin.DSN()); dsn.User != nil {
   126  			name = dsn.User.Username()
   127  			password, _ = dsn.User.Password()
   128  		}
   129  	}
   130  	tests := []sessionPostTest{
   131  		{Name: "EmptyJSON", Options: &chttp.Options{ContentType: "application/json"}},
   132  		{Name: "BadJSON", Options: &chttp.Options{
   133  			ContentType: "application/json",
   134  			Body:        kt.Body("oink"),
   135  		}},
   136  		{Name: "BogusTypeJSON", Creds: true, Options: &chttp.Options{
   137  			ContentType: "image/gif",
   138  			Body:        kt.Body(`{"name":"%s","password":"%s"}`, name, password),
   139  		}},
   140  		{Name: "BogusTypeForm", Creds: true, Options: &chttp.Options{
   141  			ContentType: "image/gif",
   142  			Body:        kt.Body(`name=%s&password=%s`, name, password),
   143  		}},
   144  		{Name: "EmptyForm", Options: &chttp.Options{ContentType: "application/x-www-form-urlencoded"}},
   145  		{Name: "BadForm", Options: &chttp.Options{
   146  			ContentType: "application/x-www-form-urlencoded",
   147  			Body:        kt.Body("o\\ink"),
   148  		}},
   149  		{Name: "MeaninglessJSON", Options: &chttp.Options{
   150  			ContentType: "application/json",
   151  			Body:        kt.Body(`{"ok":true}`),
   152  		}},
   153  		{Name: "MeaninglessForm", Options: &chttp.Options{
   154  			ContentType: "application/x-www-form-urlencoded",
   155  			Body:        kt.Body("ok=true"),
   156  		}},
   157  		{Name: "GoodJSON", Options: &chttp.Options{
   158  			ContentType: "application/json",
   159  			Body:        kt.Body(`{"name":"bob","password":"abc123"}`),
   160  		}},
   161  		{Name: "BadQueryParam", Query: "foobarbaz!", Options: &chttp.Options{
   162  			ContentType: "application/json",
   163  			Body:        kt.Body(`{"name":"bob","password":"abc123"}`),
   164  		}},
   165  		{Name: "GoodCredsJSON", Creds: true, Options: &chttp.Options{
   166  			ContentType: "application/json",
   167  			Body:        kt.Body(fmt.Sprintf(`{"name":"%s","password":"%s"}`, name, password)),
   168  		}},
   169  		{Name: "GoodCredsForm", Creds: true, Options: &chttp.Options{
   170  			ContentType: "application/x-www-form-urlencoded",
   171  			Body:        kt.Body(fmt.Sprintf(`name=%s&password=%s`, name, password)),
   172  		}},
   173  		{Name: "BadCredsJSON", Creds: true, Options: &chttp.Options{
   174  			ContentType: "application/json",
   175  			Body:        kt.Body(fmt.Sprintf(`{"name":"%s","password":"%sxxx"}`, name, password)),
   176  		}},
   177  		{Name: "BadCredsForm", Creds: true, Options: &chttp.Options{
   178  			ContentType: "application/x-www-form-urlencoded",
   179  			Body:        kt.Body(`name=%s&password=%sxxx`, name, password),
   180  		}},
   181  		{Name: "GoodCredsJSONRedirEmpty", Creds: true, Query: "next=", Options: &chttp.Options{
   182  			ContentType: "application/json",
   183  			Body:        kt.Body(`{"name":"%s","password":"%s"}`, name, password),
   184  		}},
   185  		{Name: "GoodCredsJSONRedirRelative", Creds: true, Query: "next=/_session", Options: &chttp.Options{
   186  			ContentType: "application/json",
   187  			Body:        kt.Body(`{"name":"%s","password":"%s"}`, name, password),
   188  		}},
   189  		{Name: "GoodCredsJSONRedirSchemaless", Creds: true, Query: "next=//_session", Options: &chttp.Options{
   190  			ContentType: "application/json",
   191  			Body:        kt.Body(`{"name":"%s","password":"%s"}`, name, password),
   192  		}},
   193  		{Name: "GoodCredsJSONRedirRelativeNoSlash", Creds: true, Query: "next=foobar", Options: &chttp.Options{
   194  			ContentType: "application/json",
   195  			Body:        kt.Body(`{"name":"%s","password":"%s"}`, name, password),
   196  		}},
   197  		{Name: "GoodCredsJSONRemoteRedirAbsolute", Creds: true, Query: "next=http://google.com/", Options: &chttp.Options{
   198  			ContentType: "application/json",
   199  			Body:        kt.Body(`{"name":"%s","password":"%s"}`, name, password),
   200  		}},
   201  		{Name: "GoodCredsJSONRemoteRedirInvalidURL", Creds: true, Query: "next=/session%25%26%26", Options: &chttp.Options{
   202  			ContentType: "application/json",
   203  			Body:        kt.Body(`{"name":"%s","password":"%s"}`, name, password),
   204  		}},
   205  		{Name: "GoodCredsJSONRemoteRedirHeaderInjection", Creds: true, Query: "next=/foo\nX-Injected: oink", Options: &chttp.Options{
   206  			ContentType: "application/json",
   207  			Body:        kt.Body(`{"name":"%s","password":"%s"}`, name, password),
   208  		}},
   209  		{Name: "AcceptPlain", Creds: true, Options: &chttp.Options{
   210  			ContentType: "application/json", Accept: "text/plain",
   211  			Body: kt.Body(`{"name":"%s","password":"%s"}`, name, password),
   212  		}},
   213  		{Name: "AcceptImage", Creds: true, Options: &chttp.Options{
   214  			ContentType: "application/json", Accept: "image/gif",
   215  			Body: kt.Body(`{"name":"%s","password":"%s"}`, name, password),
   216  		}},
   217  	}
   218  	for _, postTest := range tests {
   219  		func(test sessionPostTest) {
   220  			ctx.Run(test.Name, func(ctx *kt.Context) {
   221  				if test.Creds && name == "" {
   222  					ctx.Skipf("Credentials required but missing, skipping test.")
   223  				}
   224  				ctx.Parallel()
   225  				reqURL := "/_session"
   226  				if test.Query != "" {
   227  					reqURL += "?" + test.Query
   228  				}
   229  				r, err := client.DoReq(context.Background(), http.MethodPost, reqURL, test.Options)
   230  				if err == nil {
   231  					err = chttp.ResponseError(r)
   232  				}
   233  				if !ctx.IsExpectedSuccess(err) {
   234  					return
   235  				}
   236  				defer r.Body.Close() // nolint: errcheck
   237  				if _, ok := r.Header["Cache-Control"]; !ok {
   238  					ctx.Errorf("No Cache-Control set in response.")
   239  				} else {
   240  					cc := r.Header.Get("Cache-Control")
   241  					if strings.ToLower(cc) != "must-revalidate" {
   242  						ctx.Errorf("Expected Cache-Control: must-revalidate, but got'%s", cc)
   243  					}
   244  				}
   245  				if strings.HasPrefix(test.Query, "next=") {
   246  					if r.StatusCode != http.StatusFound {
   247  						ctx.Errorf("Expected redirect")
   248  					} else {
   249  						q, _ := url.ParseQuery(test.Query)
   250  						loc := r.Header.Get("Location")
   251  						next := q.Get("next")
   252  						if !strings.HasSuffix(loc, next) {
   253  							ctx.Errorf("Expected Location: ...%s, got: %s", next, loc)
   254  						}
   255  					}
   256  				}
   257  				cookies := r.Cookies()
   258  				if len(cookies) != 1 {
   259  					ctx.Errorf("Expected 1 cookie, got %d", len(cookies))
   260  				}
   261  				if cookies[0].Name != kivik.SessionCookieName {
   262  					ctx.Errorf("Server set cookie '%s', expected '%s'", cookies[0].Name, kivik.SessionCookieName)
   263  				}
   264  				if !cookies[0].HttpOnly {
   265  					ctx.Errorf("Cookie is not set HttpOnly")
   266  				}
   267  				if cookies[0].Path != "/" {
   268  					ctx.Errorf("Unexpected cookie path. Got '%s', expected '/'", cookies[0].Path)
   269  				}
   270  				val, err := base64.RawURLEncoding.DecodeString(cookies[0].Value)
   271  				if err != nil {
   272  					ctx.Fatalf("Failed to decode cookie value: %s", err)
   273  				}
   274  				parts := strings.SplitN(string(val), ":", 3) // nolint:gomnd
   275  				if parts[0] != name {
   276  					ctx.Errorf("Cookie does not match username. Want '%s', got '%s'", name, parts[0])
   277  				}
   278  				if _, err := hex.DecodeString(parts[1]); err != nil {
   279  					ctx.Errorf("Failed to decode cookie timestamp: %s", err)
   280  				}
   281  				response := struct {
   282  					OK    bool     `json:"ok"`
   283  					Name  *string  `json:"name"`
   284  					Roles []string `json:"roles"`
   285  				}{}
   286  				if err := json.NewDecoder(r.Body).Decode(&response); err != nil {
   287  					ctx.Fatalf("Failed to decode response: %s", err)
   288  				}
   289  				if !response.OK {
   290  					ctx.Errorf("Expected OK response")
   291  				}
   292  				if response.Name != nil && *response.Name != name {
   293  					ctx.Errorf("Unexpected name in response. Expected '%s', got '%s'", name, *response.Name)
   294  				}
   295  			})
   296  		}(postTest)
   297  	}
   298  }
   299  
   300  type deleteSessionTest struct {
   301  	Name   string
   302  	Creds  bool
   303  	Cookie *http.Cookie
   304  }
   305  
   306  func testDeleteSession(ctx *kt.Context, client *chttp.Client) {
   307  	ctx.Parallel()
   308  	if client == nil {
   309  		ctx.Skipf("No CHTTP client")
   310  	}
   311  	// Re-create client, so we can override defaults
   312  	client, _ = chttp.New(&http.Client{}, client.DSN(), mock.NilOption)
   313  	// Don't save sessions
   314  	client.Jar = nil
   315  	var cookie *http.Cookie
   316  	if ctx.Admin != nil {
   317  		if dsn, _ := url.Parse(ctx.Admin.DSN()); dsn.User != nil {
   318  			name := dsn.User.Username()
   319  			password, _ := dsn.User.Password()
   320  			r, err := client.DoReq(context.Background(), http.MethodPost, "/_session", &chttp.Options{
   321  				Body: kt.Body(`{"name":"%s","password":"%s"}`, name, password),
   322  			})
   323  			if err != nil {
   324  				ctx.Errorf("Failed to establish session: %s", err)
   325  				return
   326  			}
   327  			for _, c := range r.Cookies() {
   328  				if c.Name == kivik.SessionCookieName {
   329  					cookie = c
   330  					break
   331  				}
   332  			}
   333  		}
   334  	}
   335  	tests := []deleteSessionTest{
   336  		{Name: "ValidSession", Creds: true, Cookie: cookie},
   337  		{Name: "NoSession"},
   338  		// {Name: "InvalidSession"},
   339  		// {Name: "ExpiredSession"},
   340  	}
   341  	for _, test := range tests {
   342  		func(test deleteSessionTest) {
   343  			ctx.Run(test.Name, func(ctx *kt.Context) {
   344  				if test.Creds && cookie == nil {
   345  					ctx.Skipf("Credentials required but missing, skipping test.")
   346  				}
   347  				response := struct {
   348  					OK bool `json:"ok"`
   349  				}{}
   350  				req, err := client.NewRequest(context.Background(), http.MethodDelete, "/_session", nil, nil)
   351  				if err != nil {
   352  					ctx.Fatalf("Failed to create request: %s", err)
   353  				}
   354  				if test.Cookie != nil {
   355  					req.AddCookie(test.Cookie)
   356  				}
   357  				r, err := client.Do(req)
   358  				if err == nil {
   359  					err = chttp.ResponseError(r)
   360  				}
   361  				if err == nil {
   362  					defer r.Body.Close() // nolint: errcheck
   363  					err = json.NewDecoder(r.Body).Decode(&response)
   364  				}
   365  				if !ctx.IsExpectedSuccess(err) {
   366  					return
   367  				}
   368  				if _, ok := r.Header["Cache-Control"]; !ok {
   369  					ctx.Errorf("No Cache-Control set in response.")
   370  				} else {
   371  					cc := r.Header.Get("Cache-Control")
   372  					if strings.ToLower(cc) != "must-revalidate" {
   373  						ctx.Errorf("Expected Cache-Control: must-revalidate, but got'%s", cc)
   374  					}
   375  				}
   376  				for _, c := range r.Cookies() {
   377  					if c.Name == kivik.SessionCookieName {
   378  						if c.Value != "" {
   379  							ctx.Errorf("Expected empty cookie value, got '%s'", c.Value)
   380  						}
   381  						break
   382  					}
   383  				}
   384  			})
   385  		}(test)
   386  	}
   387  }