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 }