github.com/derat/nup@v0.0.0-20230418113745-15592ba7c620/server/http_test.go (about)

     1  // Copyright 2021 Daniel Erat.
     2  // All rights reserved.
     3  
     4  package main
     5  
     6  import (
     7  	"context"
     8  	"encoding/json"
     9  	"fmt"
    10  	"net/http"
    11  	"net/http/httptest"
    12  	"reflect"
    13  	"strings"
    14  	"testing"
    15  
    16  	"github.com/derat/nup/server/config"
    17  
    18  	"google.golang.org/appengine/v2"
    19  	"google.golang.org/appengine/v2/aetest"
    20  	"google.golang.org/appengine/v2/datastore"
    21  	"google.golang.org/appengine/v2/user"
    22  )
    23  
    24  // This test also exercises a lot of code from the config package, but the aetest package is slow
    25  // (4+ seconds to start dev_appserver.py) so I'm minimizing the number of places I use it.
    26  func TestAddHandler(t *testing.T) {
    27  	const (
    28  		normEmail = "normal@example.org"
    29  		badEmail  = "bad@example.org"
    30  
    31  		normUser = "norm"
    32  		normPass = "normPass"
    33  
    34  		adminUser = "admin"
    35  		adminPass = "adminPass"
    36  
    37  		guestUser = "guest"
    38  		guestPass = "guestPass"
    39  
    40  		badUser = "bad"
    41  		badPass = "badPass"
    42  	)
    43  
    44  	// Start dev_appserver.py.
    45  	inst, err := aetest.NewInstance(&aetest.Options{SuppressDevAppServerLog: true})
    46  	if err != nil {
    47  		t.Fatal("Failed starting dev_appserver: ", err)
    48  	}
    49  	defer inst.Close()
    50  
    51  	// Save a config to Datastore.
    52  	origCfg := &config.Config{
    53  		Users: []config.User{
    54  			{Username: normUser, Password: normPass},
    55  			{Username: adminUser, Password: adminPass, Admin: true},
    56  			{Username: guestUser, Password: guestPass, Guest: true},
    57  			{Email: normEmail},
    58  		},
    59  		SongBucket:  "test-songs",
    60  		CoverBucket: "test-covers",
    61  	}
    62  	b, err := json.Marshal(origCfg)
    63  	if err != nil {
    64  		t.Fatal("Failed marshaling config: ", err)
    65  	}
    66  
    67  	// The aetest package makes no sense. It looks like I need to call NewRequest
    68  	// just to get a context.
    69  	req, err := inst.NewRequest("GET", "/", nil)
    70  	if err != nil {
    71  		t.Fatal("Failed creating request: ", err)
    72  	}
    73  	ctx := appengine.NewContext(req)
    74  	scfg := config.SavedConfig{JSON: string(b)}
    75  	key := datastore.NewKey(ctx, config.DatastoreKind, config.DatastoreKeyName, 0, nil)
    76  	if _, err := datastore.Put(ctx, key, &scfg); err != nil {
    77  		t.Fatal("Failed saving config: ", err)
    78  	}
    79  
    80  	// Set up some HTTP handlers.
    81  	var lastMethod, lastPath string // method and path from last accepted request
    82  	handleReq := func(ctx context.Context, cfg *config.Config, w http.ResponseWriter, r *http.Request) {
    83  		if !reflect.DeepEqual(cfg, origCfg) {
    84  			t.Fatalf("Got config %+v; want %+v", cfg, origCfg)
    85  		}
    86  		lastMethod = r.Method
    87  		lastPath = r.URL.Path
    88  	}
    89  
    90  	norm := config.NormalUser
    91  	admin := config.AdminUser
    92  	guest := config.GuestUser
    93  	cron := config.CronUser
    94  
    95  	addHandler("/", http.MethodGet, norm|admin|guest, redirectUnauth, handleReq)
    96  	addHandler("/get", http.MethodGet, norm|admin|guest, rejectUnauth, handleReq)
    97  	addHandler("/post", http.MethodPost, norm|admin, rejectUnauth, handleReq)
    98  	addHandler("/admin", http.MethodPost, admin, rejectUnauth, handleReq)
    99  	addHandler("/cron", http.MethodGet, norm|admin|cron, rejectUnauth, handleReq)
   100  	addHandler("/allow", http.MethodGet, norm|admin, allowUnauth, handleReq)
   101  
   102  	for _, tc := range []struct {
   103  		method, path string
   104  		email        string // google auth
   105  		user, pass   string // basic auth
   106  		cron         bool   // set X-Appengine-Cron header
   107  		code         int    // expected HTTP status code
   108  	}{
   109  		{"GET", "/", normEmail, "", "", false, 200},
   110  		{"GET", "/", "", normUser, normPass, false, 200},
   111  		{"GET", "/", "", adminUser, adminPass, false, 200},
   112  		{"GET", "/", "", guestUser, guestPass, false, 200},
   113  		{"GET", "/", normEmail, badUser, badPass, false, 302}, // bad basic user; don't check google
   114  		{"GET", "/", badEmail, "", "", false, 302},            // bad google user
   115  		{"GET", "/", "", badUser, badPass, false, 302},        // bad basic user
   116  		{"GET", "/", "", normUser, badPass, false, 302},       // bad basic password
   117  		{"GET", "/", "", normUser, "", false, 302},            // no basic password
   118  		{"GET", "/", "", "", "", false, 302},                  // no auth
   119  		{"POST", "/", "", "", "", false, 302},                 // no auth, wrong method
   120  		{"POST", "/", normEmail, "", "", false, 405},          // valid auth, wrong method
   121  
   122  		{"GET", "/get", normEmail, "", "", false, 200},
   123  		{"GET", "/get", "", adminUser, adminPass, false, 200},
   124  		{"GET", "/get", "", guestUser, guestPass, false, 200},
   125  		{"GET", "/get", badEmail, "", "", false, 401},
   126  		{"GET", "/get", "", "", "", false, 401},         // no auth
   127  		{"POST", "/get", "", "", "", false, 401},        // no auth, wrong method
   128  		{"POST", "/get", normEmail, "", "", false, 405}, // valid auth, wrong method
   129  
   130  		{"POST", "/post", normEmail, "", "", false, 200},
   131  		{"POST", "/post", "", adminUser, adminPass, false, 200},
   132  		{"POST", "/post", badEmail, "", "", false, 401},
   133  		{"POST", "/post", "", "", "", false, 401},               // no auth
   134  		{"GET", "/post", "", "", "", false, 401},                // no auth, wrong method
   135  		{"POST", "/post", "", guestUser, guestPass, false, 403}, // guest not allowed
   136  		{"GET", "/post", normEmail, "", "", false, 405},         // valid auth, wrong method
   137  
   138  		{"POST", "/admin", "", adminUser, adminPass, false, 200},
   139  		{"POST", "/admin", normEmail, "", "", false, 403},      // not admin
   140  		{"POST", "/admin", "", normUser, normPass, false, 403}, // not admin
   141  		{"POST", "/post", "", "", "", false, 401},              // no auth
   142  
   143  		{"GET", "/cron", normEmail, "", "", false, 200},
   144  		{"GET", "/cron", "", normUser, normPass, false, 200},
   145  		{"GET", "/cron", "", adminUser, adminPass, false, 200},
   146  		{"GET", "/cron", "", "", "", true, 200},
   147  		{"GET", "/cron", "", "", "", false, 401},       // no auth
   148  		{"GET", "/cron", badEmail, "", "", false, 401}, // bad google user
   149  		{"POST", "/cron", "", "", "", true, 405},       // wrong method
   150  
   151  		{"GET", "/allow", "", "", "", false, 200},               // no auth
   152  		{"GET", "/allow", normEmail, "", "", false, 200},        // valid user
   153  		{"GET", "/allow", "", normUser, normPass, false, 200},   // valid auth
   154  		{"GET", "/allow", "", adminUser, adminPass, false, 200}, // valid auth
   155  		{"GET", "/allow", "", guestUser, guestPass, false, 200}, // unlisted auth
   156  		{"POST", "/allow", "", "", "", false, 405},              // wrong method
   157  	} {
   158  		desc := tc.method + " " + tc.path
   159  		req, err := inst.NewRequest(tc.method, tc.path, nil)
   160  		if err != nil {
   161  			t.Fatalf("Creating %v request failed: %v", desc, err)
   162  		}
   163  
   164  		// Add credentials.
   165  		if tc.email != "" {
   166  			aetest.Login(&user.User{Email: tc.email}, req)
   167  			desc += " email=" + tc.email
   168  		} else {
   169  			aetest.Logout(req)
   170  		}
   171  		if tc.user != "" {
   172  			req.SetBasicAuth(tc.user, tc.pass)
   173  			desc += fmt.Sprintf(" basic=%s/%s", tc.user, tc.pass)
   174  		}
   175  		if tc.cron {
   176  			req.Header.Set("X-Appengine-Cron", "true")
   177  		}
   178  
   179  		lastMethod, lastPath = "", ""
   180  		rec := httptest.NewRecorder()
   181  		http.DefaultServeMux.ServeHTTP(rec, req)
   182  		if rec.Code != tc.code {
   183  			t.Errorf("%v returned %v; want %v", desc, rec.Code, tc.code)
   184  			continue
   185  		}
   186  		if rec.Code == 200 {
   187  			if lastMethod != tc.method || lastPath != tc.path {
   188  				t.Errorf("%v resulted in %v %v", desc, lastMethod, lastPath)
   189  			}
   190  		} else {
   191  			if lastMethod != "" || lastPath != "" {
   192  				t.Errorf("%v resulted in %v %v; should've been rejected",
   193  					desc, lastMethod, lastPath)
   194  			}
   195  			if rec.Code == 302 {
   196  				// These checks depend on the format of App Engine URLs:
   197  				//  /_ah/login?continue=http%3A//localhost%3A34773/
   198  				//  /_ah/logout?continue=http%3A//localhost%3A34773/_ah/login%3Fcontinue%3Dhttp%253A//localhost%253A34773/
   199  				if loc := rec.Result().Header.Get("Location"); !strings.Contains(loc, "/login") {
   200  					t.Errorf("%v redirected to non-login URL %v", desc, loc)
   201  				} else if tc.email != "" && !strings.Contains(loc, "/logout") {
   202  					t.Errorf("%v redirected to non-logout URL %v", desc, loc)
   203  				}
   204  			}
   205  		}
   206  	}
   207  }