Files
story/internal/onboarding/operator_login_test.go
T

159 lines
6.0 KiB
Go
Raw Normal View History

package onboarding
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/google/uuid"
)
func newOperatorLoginFixture(t *testing.T) *loginFixture {
f := newLoginFixture(t)
f.s = f.s.operatorView()
f.newLoginSession()
f.mux.Handle("POST /operator-write", f.s.CustomerAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) })))
return f
}
func operatorPasswordSession(t *testing.T, f *loginFixture, password string) (*http.Cookie, string) {
t.Helper()
if f.trusted == nil || f.trusted.Name != f.s.trustedLoginCookieName() {
f.trustDeviceForTest()
}
in := PasswordLoginInput{Phone: f.phone, Password: password, CaptchaID: f.knownCaptcha(), CaptchaCode: "654321"}
w, data := f.request("POST", "/api/v1/web/login/password", in, f.cookie, f.csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
for _, c := range w.Result().Cookies() {
if c.Name == f.s.cookieName(true) {
return c, data["csrf_token"].(string)
}
}
t.Fatal("missing operator cookie")
return nil, ""
}
func TestOperatorPhoneLoginKeepsRolesAndIsolatesCustomerSession(t *testing.T) {
f := newOperatorLoginFixture(t)
_, err := f.s.ProvisionOperatorPhone(f.ctx, f.operatorID, f.p.Issuer, f.p.Subject, f.phone, "OperatorOnly123!", uuid.NewString())
must(t, err)
account, csrf := operatorPasswordSession(t, f, "OperatorOnly123!")
if account.Name != "__Host-platform-operator" || !account.Secure || !account.HttpOnly || account.SameSite != http.SameSiteLaxMode {
t.Fatal("operator cookie protections")
}
p, err := f.s.AccountPrincipal(f.ctx, account.Value, csrf, true)
must(t, err)
if p.Subject != f.p.Subject {
t.Fatal("wrong operator identity")
}
w, _ := f.request("POST", "/operator-write", struct{}{}, account, "wrong", "")
if w.Code != 403 {
t.Fatal("CSRF bypass")
}
w, _ = f.request("POST", "/operator-write", struct{}{}, account, csrf, "")
if w.Code != 204 {
t.Fatal(w.Body.String())
}
r := httptest.NewRequest("POST", "https://platform.example/operator-write", strings.NewReader("{}"))
r.Header.Set("Origin", "https://other.example")
r.Header.Set("X-CSRF-Token", csrf)
r.AddCookie(account)
w2 := httptest.NewRecorder()
f.mux.ServeHTTP(w2, r)
if w2.Code != 403 {
t.Fatal("cross-origin operator write")
}
_, state := f.request("GET", "/api/v1/web/account/password", nil, account, "", "")
if state["password_configured"] != true || state["sms_verified_recently"] != false {
t.Fatal("password login granted SMS proof")
}
// A phone can be registered in both portals without linking their identities.
customer := *f.s
customer.operatorLogin = false
_, err = customer.ProvisionCustomerPhone(f.ctx, f.web.UserID, f.user.Issuer, f.user.Subject, f.phone, "CustomerOnly123!", uuid.NewString())
must(t, err)
if _, err = customer.AccountPrincipal(f.ctx, account.Value, csrf, true); err == nil {
t.Fatal("operator token crossed into customer table")
}
cf := *f
cf.fixture = &fixture{}
*cf.fixture = *f.fixture
cf.s = &customer
cf.newLoginSession()
cc, ccsrf := operatorPasswordSession(t, &cf, "CustomerOnly123!")
if _, err = f.s.AccountPrincipal(f.ctx, cc.Value, ccsrf, true); err == nil {
t.Fatal("customer token granted operator access")
}
f.newLoginSession()
second, _ := operatorPasswordSession(t, f, "OperatorOnly123!")
w, _ = f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "ChangedOperator123!", CurrentPassword: "OperatorOnly123!"}, account, csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
if _, err = f.s.AccountPrincipal(f.ctx, second.Value, "", false); err == nil {
t.Fatal("password change kept another operator session")
}
if _, err = customer.AccountPrincipal(f.ctx, cc.Value, "", false); err != nil {
t.Fatal("operator password change revoked customer", err)
}
w, _ = f.request("POST", "/api/v1/web/auth/logout", struct{}{}, account, csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
if _, err = f.s.AccountPrincipal(f.ctx, account.Value, "", false); err == nil {
t.Fatal("operator logout not revoked")
}
if _, err = customer.AccountPrincipal(f.ctx, cc.Value, "", false); err != nil {
t.Fatal("operator logout revoked customer", err)
}
}
func TestOperatorSMSOnlyAllowsProvisionedActiveOperators(t *testing.T) {
f := newOperatorLoginFixture(t)
if _, err := f.s.ProvisionOperatorPhone(f.ctx, f.web.UserID, f.user.Issuer, f.user.Subject, f.phone, "OperatorOnly123!", uuid.NewString()); err == nil {
t.Fatal("customer promoted by provisioning")
}
id := f.send()
w, _ := f.request("POST", "/api/v1/web/login/sms/verify", SMSVerifyInput{ChallengeID: id, Code: f.sender.code}, f.cookie, f.csrf, "")
if w.Code != 403 {
t.Fatal("unregistered operator accepted", w.Body.String())
}
var count int
must(t, f.s.pool.QueryRow(f.ctx, `SELECT count(*) FROM operator_phone_credential WHERE phone_digest=$1`, domainMAC(f.s.crypt.hashKey, "PHONE1\n"+f.phone)).Scan(&count))
if count != 0 {
t.Fatal("SMS registered operator")
}
_, err := f.s.ProvisionOperatorPhone(f.ctx, f.operatorID, f.p.Issuer, f.p.Subject, f.phone, "OperatorOnly123!", uuid.NewString())
must(t, err)
w, data := f.request("POST", "/api/v1/web/login/sms/verify", SMSVerifyInput{ChallengeID: id, Code: f.sender.code}, f.cookie, f.csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
var account *http.Cookie
for _, c := range w.Result().Cookies() {
if c.Name == f.s.cookieName(true) {
account = c
}
}
if account == nil {
t.Fatal("missing operator SMS session")
}
w, _ = f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "ResetOperator123!"}, account, data["csrf_token"].(string), "")
if w.Code != 200 {
t.Fatal("SMS reset failed", w.Body.String())
}
_, err = f.s.pool.Exec(f.ctx, `UPDATE user_account SET status='disabled' WHERE id=$1::uuid`, f.operatorID)
must(t, err)
t.Cleanup(func() {
_, _ = f.s.pool.Exec(f.ctx, `UPDATE user_account SET status='active' WHERE id=$1::uuid`, f.operatorID)
})
if _, err = f.s.AccountPrincipal(f.ctx, account.Value, "", false); err == nil {
t.Fatal("disabled operator session accepted")
}
}