Files
sm-user-plugin/offboarding_test.go
Jakub Zych c3712dc0d4 fix: deactivate and ban revoke sessions and API tokens, and /refresh refuses deactivated, banned and cut-off subjects
classes.RevokeUserAccess stamps users.tokens_valid_after (whole seconds) and
revokes the users' personal API tokens; DeactivateUsers and BanUsers call
it for every selected user. Restore and unban revive nothing, and a
deactivated user who signs in again gets a fresh working token.

POST /refresh now loads the token's subject after bouncer.Refresh and
refuses a missing, deactivated or banned user and a token issued before
tokens_valid_after with the existing 401 body; a refused refresh stamps no
last_seen. Suspension is deliberately not checked.
2026-10-22 03:11:32 +02:00

196 lines
7.4 KiB
Go

package user
import (
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/controllers"
usermiddleware "git.golem15.com/golem15/sm-user-plugin/middleware"
"git.golem15.com/golem15/sm-user-plugin/models"
)
const refusedRefreshBody = `{"error":"Could not refresh token","msg":"User not found"}`
// offboardingCreds gives a seeded user an older session JWT (issued two
// seconds ago, so a revocation cutoff in this second cuts it) and a usr_
// personal API token.
func offboardingCreds(t *testing.T, e *adminEnv, user *models.User) (jwt, apiSecret string) {
t.Helper()
now := time.Now()
jwt = craftToken(t, strconv.FormatUint(uint64(user.ID), 10), "off-"+adminStamp(), now.Add(-2*time.Second), now.Add(time.Hour))
secret, token, err := classes.MintAPIToken("usr_", user.ID, "offboarding", []string{"read"}, nil, nil)
if err != nil {
t.Fatal(err)
}
if err := e.gdb.Create(token).Error; err != nil {
t.Fatal(err)
}
return jwt, secret
}
// apiTokenWorks authenticates secret through the user.api_token guard.
func apiTokenWorks(t *testing.T, e *adminEnv, secret string) bool {
t.Helper()
guard := &usermiddleware.APITokenGuard{App: e.app}
req := httptest.NewRequest(http.MethodGet, "/api/v1/example", nil)
req.Header.Set("Authorization", "Bearer "+secret)
principal, _, err := guard.AuthenticateCredential(req)
return err == nil && principal != nil
}
func (e *adminEnv) fetchCode(t *testing.T, token string) int {
t.Helper()
return e.call(t, http.MethodGet, "/_user/api/v1/fetch", token, nil).Code
}
func (e *adminEnv) refresh(t *testing.T, token string) *httptest.ResponseRecorder {
t.Helper()
return e.call(t, http.MethodPost, "/_user/api/v1/refresh", token, nil)
}
func assertRefreshRefused(t *testing.T, rec *httptest.ResponseRecorder) {
t.Helper()
if rec.Code != http.StatusUnauthorized || rec.Body.String() != refusedRefreshBody {
t.Fatalf("refresh = %d %s, want 401 %s", rec.Code, rec.Body.String(), refusedRefreshBody)
}
}
// TestOffboarding: deactivate and ban end every earlier session JWT and
// personal API token. A deactivated user reactivates by signing in and gets
// a working token; a banned one is refused. Restore and unban revive no
// token.
func TestOffboarding(t *testing.T) {
e := newAdminEnv(t)
admin := e.admin(t, controllers.PermissionAccessUsers)
t.Run("deactivate", func(t *testing.T) {
user := e.seedUser(t, "off-deactivate", nil)
jwt, secret := offboardingCreds(t, e, user)
if e.fetchCode(t, jwt) != http.StatusOK || !apiTokenWorks(t, e, secret) {
t.Fatal("credentials do not work before the deactivation")
}
e.expect(t, http.StatusOK, http.MethodPost, adminAPI(adminUsersPath+"/bulk/deactivate"), admin, map[string]any{"ids": []uint{user.ID}})
if code := e.fetchCode(t, jwt); code != http.StatusUnauthorized {
t.Fatalf("fetch after deactivate = %d", code)
}
if apiTokenWorks(t, e, secret) {
t.Fatal("API token works after deactivate")
}
assertRefreshRefused(t, e.refresh(t, jwt))
// Signing in right away (the same second allowed) restores the
// account, mails the reactivation and returns a working token.
code, fresh := e.userLogin(t, user.Email, "correct-horse")
if code != http.StatusOK || fresh == "" {
t.Fatalf("login after deactivate = %d", code)
}
if row := e.reload(t, user.ID); row == nil || row.DeletedAt.Valid {
t.Fatal("login did not restore the account")
}
if len(e.mails(user.Email)) != 1 {
t.Fatalf("reactivate mails = %d", len(e.mails(user.Email)))
}
if code := e.fetchCode(t, fresh); code != http.StatusOK {
t.Fatalf("fetch with the fresh token = %d", code)
}
if code := e.fetchCode(t, jwt); code != http.StatusUnauthorized {
t.Fatalf("old JWT after the restore = %d", code)
}
if apiTokenWorks(t, e, secret) {
t.Fatal("old API token works after the restore")
}
})
t.Run("ban", func(t *testing.T) {
user := e.seedUser(t, "off-ban", nil)
jwt, secret := offboardingCreds(t, e, user)
e.expect(t, http.StatusOK, http.MethodPost, adminAPI(adminUsersPath+"/bulk/ban"), admin, map[string]any{"ids": []uint{user.ID}})
if code := e.fetchCode(t, jwt); code != http.StatusUnauthorized {
t.Fatalf("fetch after ban = %d", code)
}
if apiTokenWorks(t, e, secret) {
t.Fatal("API token works after ban")
}
assertRefreshRefused(t, e.refresh(t, jwt))
rec := e.call(t, http.MethodPost, "/_user/api/v1/login", "", map[string]string{"email": user.Email, "password": "correct-horse"})
if rec.Code != http.StatusUnauthorized || rec.Body.String() != invalidLoginBody {
t.Fatalf("banned login = %d %s", rec.Code, rec.Body.String())
}
e.expect(t, http.StatusOK, http.MethodPost, adminAPI(adminUsersPath+"/bulk/unban"), admin, map[string]any{"ids": []uint{user.ID}})
if code := e.fetchCode(t, jwt); code != http.StatusUnauthorized {
t.Fatalf("old JWT after unban = %d", code)
}
if apiTokenWorks(t, e, secret) {
t.Fatal("old API token works after unban")
}
code, fresh := e.userLogin(t, user.Email, "correct-horse")
if code != http.StatusOK || e.fetchCode(t, fresh) != http.StatusOK {
t.Fatalf("login after unban = %d", code)
}
})
}
// TestRefreshSubjectChecks: /refresh loads the subject even without a
// revocation stamp. Deactivated, banned and cut-off subjects are refused
// with the token-failure body and stamp no last_seen; a suspended user still
// refreshes, and a normal refresh stamps last_seen.
func TestRefreshSubjectChecks(t *testing.T) {
e := newAdminEnv(t)
token := func(t *testing.T, user *models.User, iat time.Time) string {
t.Helper()
return craftToken(t, strconv.FormatUint(uint64(user.ID), 10), "refresh-"+adminStamp(), iat, iat.Add(time.Hour))
}
lastSeen := func(t *testing.T, user *models.User) *time.Time {
t.Helper()
return e.reload(t, user.ID).LastSeen
}
t.Run("deactivated", func(t *testing.T) {
user := e.seedUser(t, "refresh-deactivated", nil)
if err := e.gdb.Exec(`UPDATE users SET deleted_at = NOW() WHERE id = ?`, user.ID).Error; err != nil {
t.Fatal(err)
}
assertRefreshRefused(t, e.refresh(t, token(t, user, time.Now())))
if lastSeen(t, user) != nil {
t.Fatal("a refused refresh stamped last_seen")
}
})
t.Run("banned", func(t *testing.T) {
user := e.seedUser(t, "refresh-banned", nil)
e.seedThrottle(t, user, "", true, nil)
assertRefreshRefused(t, e.refresh(t, token(t, user, time.Now())))
if lastSeen(t, user) != nil {
t.Fatal("a refused refresh stamped last_seen")
}
})
t.Run("issued before tokens_valid_after", func(t *testing.T) {
user := e.seedUser(t, "refresh-cutoff", nil)
if err := e.gdb.Exec(`UPDATE users SET tokens_valid_after = ? WHERE id = ?`, time.Now().Truncate(time.Second), user.ID).Error; err != nil {
t.Fatal(err)
}
assertRefreshRefused(t, e.refresh(t, token(t, user, time.Now().Add(-10*time.Second))))
})
t.Run("suspended", func(t *testing.T) {
user := e.seedUser(t, "refresh-suspended", nil)
now := time.Now()
e.seedThrottle(t, user, "203.0.113.50", false, &now)
if rec := e.refresh(t, token(t, user, time.Now())); rec.Code != http.StatusOK {
t.Fatalf("suspended refresh = %d %s", rec.Code, rec.Body.String())
}
})
t.Run("normal", func(t *testing.T) {
user := e.seedUser(t, "refresh-normal", nil)
if rec := e.refresh(t, token(t, user, time.Now())); rec.Code != http.StatusOK {
t.Fatalf("refresh = %d %s", rec.Code, rec.Body.String())
}
if lastSeen(t, user) == nil {
t.Fatal("refresh did not stamp last_seen")
}
})
}