login fix
This commit is contained in:
206
controllers/setPassword_test.go
Normal file
206
controllers/setPassword_test.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"nearle/middleware"
|
||||
"nearle/models"
|
||||
|
||||
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
|
||||
refuseIt error
|
||||
}
|
||||
|
||||
func (f *fakePasswords) SetInitialPassword(userid int, password string) error {
|
||||
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, error) {
|
||||
return models.UserInfo{}, 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",
|
||||
`{"userid":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",
|
||||
`{"userid":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",
|
||||
`{"userid":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", `{"userid":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 }
|
||||
Reference in New Issue
Block a user