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") } }) }