package controllers import ( "encoding/json" "io" "net/http/httptest" "strings" "testing" "time" "nearle/middleware" "nearle/models" "nearle/services" "nearle/utils" fiberv1 "github.com/gofiber/fiber" "github.com/gofiber/fiber/v2" ) /* Setting a first password, with no session and no way to get one. A branch login created by `createtenantlocation` arrives with an empty password. The console signs in, is told to set one, and does — and until now it did that through `PUT /users/update`, which is behind the session guard. Once WEB_AUTH_REQUIRED began defaulting on, that answered 401 "a session token is required; sign in again" to somebody who could not sign in, because signing in needs the password they were trying to set. Every such account was unusable, and the 401 read as an authentication bug rather than a deadlock. `publicWebPaths` had named `/users/setpassword` since the guard was written. The path was reserved; the handler never existed, so it answered 404. The tests that matter are about the two halves: it must be reachable WITHOUT a session, and it must refuse everything except the one case it exists for. */ type fakePasswords struct { // set records what reached the write, so a refusal can be shown to have // refused rather than merely reported. set []string lastUserid int refuseIt error } func (f *fakePasswords) SetInitialPassword(userid int, password string) error { f.lastUserid = userid if f.refuseIt != nil { return f.refuseIt } f.set = append(f.set, password) return nil } // The rest of UserService, unused here. func (f *fakePasswords) GetAllUsers(int, int, int, int, string) ([]models.UserInfo, error) { return nil, nil } func (f *fakePasswords) GetUserByID(int) (models.UserInfo, error) { return models.UserInfo{}, nil } func (f *fakePasswords) Login(models.User) (models.UserInfo, error) { return models.UserInfo{}, nil } func (f *fakePasswords) TenantLogin(models.User) (models.TenantUserInfo, error) { return models.TenantUserInfo{}, nil } func (f *fakePasswords) UpdateStaff(models.User) error { return nil } func (f *fakePasswords) AppLogin(models.User) (models.TenantUserInfo, fiberv1.Map, error) { return models.TenantUserInfo{}, fiberv1.Map{}, nil } func (f *fakePasswords) CreateUser(models.User) (models.UserInfo, services.InviteOutcome, error) { return models.UserInfo{}, services.InviteOutcome{}, nil } func passwordApp(t *testing.T, service *fakePasswords) *fiber.App { t.Helper() t.Setenv("POS_TOKEN_SECRET", testSecret) app := fiber.New() // The real guard, mounted exactly as routes.go mounts it. The point of this // file is which side of it this endpoint lands on. app.Use("/live/api/v1/web", middleware.WebAuth(nil)) app.Post("/live/api/v1/web/users/setpassword", NewUserController(service).SetPassword) app.Put("/live/api/v1/web/users/update", NewUserController(service).UpdateStaff) return app } func send(t *testing.T, app *fiber.App, method, path, body string) (int, string) { t.Helper() req := httptest.NewRequest(method, path, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req, -1) if err != nil { t.Fatalf("%s %s: %v", method, path, err) } raw, _ := io.ReadAll(resp.Body) return resp.StatusCode, string(raw) } func TestAFirstPasswordCanBeSetWithoutASession(t *testing.T) { // The whole point. There is no session to present and no way to obtain one. service := &fakePasswords{} app := passwordApp(t, service) status, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword", `{"token":"`+invite(t, 904)+`","password":"opensesame"}`) if status == fiber.StatusUnauthorized { t.Fatalf("the guard blocked the one call that cannot present a token: %s", body) } if status != fiber.StatusOK { t.Fatalf("HTTP %d: %s", status, body) } if len(service.set) != 1 || service.set[0] != "opensesame" { t.Fatalf("the password did not reach the service: %v", service.set) } } func TestTheGeneralUpdateStaysBehindTheGuard(t *testing.T) { // The reason this is a new endpoint rather than `/users/update` being // opened up: that one writes whatever struct it is handed, so unauthenticated // it would let anybody change any field of any user. service := &fakePasswords{} app := passwordApp(t, service) status, body := send(t, app, "PUT", "/live/api/v1/web/users/update", `{"userid":904,"roleid":1,"tenantid":9}`) if status != fiber.StatusUnauthorized { t.Fatalf("an untokened user update was not refused: %d %s", status, body) } } func TestAnAccountThatAlreadyHasOneIsRefusedAsAConflict(t *testing.T) { // 409, never 401. Nothing here is an authentication failure — the caller is // not supposed to have a session — and a 401 would send the console into its // sign-out-and-reload path on the one screen with nothing to sign out of. service := &fakePasswords{refuseIt: errAlreadySet{}} app := passwordApp(t, service) status, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword", `{"token":"`+invite(t, 904)+`","password":"opensesame"}`) if status != fiber.StatusConflict { t.Fatalf("expected 409, got %d: %s", status, body) } if len(service.set) != 0 { t.Fatalf("a refused call still wrote: %v", service.set) } } func TestTheRefusalDoesNotSayWhichAccountsExist(t *testing.T) { // "No such user" and "already has a password" must read identically, or // this becomes a way to ask whether a userid exists and whether it has been // set up — unauthenticated, one request at a time. service := &fakePasswords{refuseIt: errAlreadySet{}} app := passwordApp(t, service) _, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword", `{"token":"`+invite(t, 904)+`","password":"opensesame"}`) for _, leak := range []string{"not found", "no such", "does not exist"} { if strings.Contains(strings.ToLower(body), leak) { t.Fatalf("the refusal distinguishes a missing account: %s", body) } } } func TestAMalformedBodyIsRefusedWithoutPanicking(t *testing.T) { app := passwordApp(t, &fakePasswords{}) status, _ := send(t, app, "POST", "/live/api/v1/web/users/setpassword", `{"userid":`) if status != fiber.StatusBadRequest { t.Fatalf("expected 400, got %d", status) } } func TestTheAnswerIsTheEnvelopeTheConsoleUnwraps(t *testing.T) { // A handler answering at the top level passes a service test and hands the // console `undefined`. _, body := send(t, passwordApp(t, &fakePasswords{}), "POST", "/live/api/v1/web/users/setpassword", `{"token":"`+invite(t, 904)+`","password":"opensesame"}`) var envelope struct { Status bool `json:"status"` Code int `json:"code"` Message string `json:"message"` } if err := json.Unmarshal([]byte(body), &envelope); err != nil { t.Fatalf("not an envelope: %s", body) } if !envelope.Status || envelope.Code != fiber.StatusOK { t.Fatalf("success did not read as success: %s", body) } } type errAlreadySet struct{} func (errAlreadySet) Error() string { return "that account cannot have its password set here — it may already have one" } func (f *fakePasswords) TenantWebLogin(models.User) (models.TenantUserInfo, map[string]interface{}) { return models.TenantUserInfo{}, map[string]interface{}{} } func (f *fakePasswords) DeleteUser(int) error { return nil } // invite mints a real invitation for the test's account. // // A helper rather than a literal, because the token is signed: a hand-written // string would test the refusal path and nothing else, and the point of these // is what happens when a genuine invitation arrives. func invite(t *testing.T, userid int) string { t.Helper() token, _, err := utils.MintInviteToken(utils.InviteClaims{Userid: userid, Tenantid: 1147}, time.Now()) if err != nil { t.Fatalf("minting an invitation: %v", err) } return token } /* The invitation replaced a userid, and that was a security fix rather than a tidy-up. `applogin` answers a POST carrying an email and no password with 409 and the userid, for any account that has not set one. So the recipe was: know a merchant's primary email — usually printed on their shopfront — POST it, receive their userid, set their password, own the business's admin account. No guessing at any step, and the empty-password check was no defence because an un-set-up account is exactly what such an attacker wants. */ func TestAUseridIsNoLongerEnoughToSetAPassword(t *testing.T) { // The hole, asserted closed. A body carrying a userid and no invitation // must not set anything, whatever the userid is. service := &fakePasswords{} app := passwordApp(t, service) status, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword", `{"userid":904,"password":"opensesame"}`) if status == fiber.StatusOK { t.Fatalf("a bare userid still set a password: %s", body) } if len(service.set) != 0 { t.Fatalf("a bare userid reached the service: %v", service.set) } } func TestAnInvitationSetsThePasswordForTheAccountItNames(t *testing.T) { service := &fakePasswords{} app := passwordApp(t, service) status, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword", `{"token":"`+invite(t, 904)+`","password":"opensesame"}`) if status != fiber.StatusOK { t.Fatalf("HTTP %d: %s", status, body) } if len(service.set) != 1 || service.set[0] != "opensesame" { t.Fatalf("the password did not reach the service: %v", service.set) } } func TestTheUseridComesFromTheSignatureNotTheRequest(t *testing.T) { // An invitation for 904 with a `userid` field claiming 999 must set 904's // password. If the body could override it, the token would be decoration. service := &fakePasswords{} app := passwordApp(t, service) status, _ := send(t, app, "POST", "/live/api/v1/web/users/setpassword", `{"token":"`+invite(t, 904)+`","userid":999,"password":"opensesame"}`) if status != fiber.StatusOK { t.Fatalf("a valid invitation was refused: %d", status) } if service.lastUserid != 904 { t.Fatalf("the request's userid won: set the password for %d", service.lastUserid) } } func TestAForgedInvitationIsRefused(t *testing.T) { service := &fakePasswords{} app := passwordApp(t, service) for _, token := range []string{"", "i1.forged.signature", "not-a-token", "w1.a.b"} { status, _ := send(t, app, "POST", "/live/api/v1/web/users/setpassword", `{"token":"`+token+`","password":"opensesame"}`) if status == fiber.StatusOK { t.Fatalf("%q was accepted as an invitation", token) } } if len(service.set) != 0 { t.Fatalf("a forged invitation wrote: %v", service.set) } }