Files
Behavision/server/internal/api/password_test.go
Suriyakumarvijayanayagam 6068b2c3c7 Nobody could change their own password
POST /api/auth/password. The cost of its absence was measured today
rather than argued: rotating three production accounts took a shell on
the host, three round trips, and briefly left a PLATFORM ADMIN - the
account that reads every company on the estate - with the password
PASTE_IT_HERE, because a placeholder in a pasted command was taken
literally and there was no way to correct it from the product.

A manager could always reset somebody ELSE's password. A platform admin
could be reset by nobody: they have no client, so the team routes are
not theirs, and `provision user` on the host was the only route. For
software that puts accounts on shop-floor PCs and staff phones, this is
not a feature - it is what makes every other credential decision
recoverable.

Three decisions:

- **authed, not tenantOnly.** A session is not a company's data, and the
  account with no company is precisely the one that had no route. Scoping
  this by client would have reproduced the hole it exists to close, which
  is also why SetUserPassword is not scoped by client the way
  ResetMemberPassword beside it is. The user id comes from the verified
  session, never the request, so there is nothing to point at anyone else.

- **The current password is required.** An access token lives twelve
  hours and travels on devices that get lost and shared; without this a
  stolen one owns the account permanently instead of until it expires.

- **Every OTHER session is revoked, and the caller's is kept.** Somebody
  changing their password because they believe it is known must not have
  to wonder whether the device that already had it is still signed in -
  and must not be signed out of the one in their hand while dealing with
  it. A failure there is logged, not returned: the password IS changed by
  then, and reporting an error would send them to retry with a current
  password that no longer exists.

The suite's login() helper fatals on anything but 200, which is right
everywhere else and useless here - half of what these tests assert is
that a password has STOPPED working. loginCode() returns the status.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-29 15:30:08 +05:30

116 lines
4.1 KiB
Go

package api
import (
"net/http"
"testing"
)
const pwPath = "/api/auth/password"
// loginCode signs in and returns only the status. The suite's login() fatals
// on anything but 200, which is right everywhere else and useless here: half
// of what these tests assert is that a password has STOPPED working.
func loginCode(t *testing.T, s *Server, email, password string) int {
t.Helper()
return do(t, s, "POST", "/api/auth/login", "",
map[string]string{"email": email, "password": password}).Code
}
// The account this endpoint exists for. A platform admin has no company, so
// the team routes are not theirs and tenantOnly refuses them - before this,
// changing their password needed a shell on the production host.
func TestAPlatformAdminCanChangeTheirOwnPassword(t *testing.T) {
s, fs := newServer(t)
seedPlatformAdmin(fs)
sess := login(t, s, "root@loyaly.ai", "admin123")
rec := do(t, s, "POST", pwPath, sess.Token, map[string]string{
"current_password": "admin123", "new_password": "a-much-longer-one",
})
if rec.Code != http.StatusOK {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
// The new one works and the old one does not - asserted by signing in,
// because that is the only thing a user actually cares about here.
if c := loginCode(t, s, "root@loyaly.ai", "a-much-longer-one"); c != http.StatusOK {
t.Errorf("new password signs in: got %d, want 200", c)
}
if c := loginCode(t, s, "root@loyaly.ai", "admin123"); c == http.StatusOK {
t.Error("the old password still signs in")
}
}
func TestAnOrdinaryUserCanChangeTheirOwnPassword(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "POST", pwPath, sess.Token, map[string]string{
"current_password": "correct horse battery", "new_password": "staple-battery-horse",
})
if rec.Code != http.StatusOK {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
}
// The whole security argument. An access token lives twelve hours and travels
// on shop-floor PCs and staff phones; without this, a stolen one owns the
// account permanently instead of until it expires.
func TestChangingAPasswordRequiresTheCurrentOne(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "POST", pwPath, sess.Token, map[string]string{
"current_password": "not the password", "new_password": "a-much-longer-one",
})
if rec.Code != http.StatusForbidden {
t.Fatalf("got %d, want 403 - a token alone must not be enough: %s",
rec.Code, rec.Body.String())
}
// And it must not have changed anything.
if c := loginCode(t, s, "manager@acme.com", "correct horse battery"); c != http.StatusOK {
t.Errorf("a refused change must leave the old password working: got %d", c)
}
}
// The floor lives in HashPassword, so this asserts the endpoint routes through
// it rather than re-implementing a check that could drift from the constant.
func TestAShortNewPasswordIsRefused(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "POST", pwPath, sess.Token, map[string]string{
"current_password": "correct horse battery", "new_password": "short",
})
if rec.Code != http.StatusBadRequest {
t.Errorf("got %d, want 400 for a password under the floor", rec.Code)
}
}
func TestReusingTheSamePasswordIsRefused(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "POST", pwPath, sess.Token, map[string]string{
"current_password": "correct horse battery",
"new_password": "correct horse battery",
})
if rec.Code != http.StatusBadRequest {
t.Errorf("got %d, want 400 - a no-op change reads as success and is not", rec.Code)
}
}
func TestChangingAPasswordNeedsASession(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
rec := do(t, s, "POST", pwPath, "", map[string]string{
"current_password": "correct horse battery", "new_password": "a-much-longer-one",
})
if rec.Code != http.StatusUnauthorized {
t.Errorf("got %d, want 401", rec.Code)
}
}