From 4991c5fb4595184cb65eb6278401f954fb4b0188 Mon Sep 17 00:00:00 2001 From: Scooter Date: Fri, 4 Sep 2026 10:57:11 +0300 Subject: [PATCH 1/8] feat: poison hearbeats --- .../api/admin/application_controller.rb | 7 + .../api/admin/v1/bans_controller.rb | 70 ++++++ app/models/concerns/heartbeat_poisoning.rb | 41 ++++ app/models/heartbeat.rb | 1 + app/models/user.rb | 73 ++++++ config/routes.rb | 4 + .../20260903120000_add_poison_to_users.rb | 9 + db/schema.rb | 6 +- spec/requests/api/admin/v1/bans_spec.rb | 168 ++++++++++++++ swagger/admin/swagger.yaml | 178 +++++++++++++++ .../api/admin/v1/bans_controller_test.rb | 215 ++++++++++++++++++ .../concerns/heartbeat_poisoning_test.rb | 191 ++++++++++++++++ 12 files changed, 962 insertions(+), 1 deletion(-) create mode 100644 app/controllers/api/admin/v1/bans_controller.rb create mode 100644 app/models/concerns/heartbeat_poisoning.rb create mode 100644 db/migrate/20260903120000_add_poison_to_users.rb create mode 100644 spec/requests/api/admin/v1/bans_spec.rb create mode 100644 test/controllers/api/admin/v1/bans_controller_test.rb create mode 100644 test/models/concerns/heartbeat_poisoning_test.rb diff --git a/app/controllers/api/admin/application_controller.rb b/app/controllers/api/admin/application_controller.rb index 5882aa4d5..4a2b3dc39 100644 --- a/app/controllers/api/admin/application_controller.rb +++ b/app/controllers/api/admin/application_controller.rb @@ -9,9 +9,16 @@ class ApplicationController < ActionController::API before_action :authenticate_admin! before_action :set_paper_trail_whodunnit + around_action :allow_poisoned_heartbeats private + def allow_poisoned_heartbeats(&block) + return yield unless ActiveModel::Type::Boolean.new.cast(params[:include_poison]) + + Heartbeat.including_poison(&block) + end + def authenticate_admin! authenticate_or_request_with_http_token do |token, _| auth_admin_api_key(token) || auth_oauth_admin(token) diff --git a/app/controllers/api/admin/v1/bans_controller.rb b/app/controllers/api/admin/v1/bans_controller.rb new file mode 100644 index 000000000..6aa53ffaf --- /dev/null +++ b/app/controllers/api/admin/v1/bans_controller.rb @@ -0,0 +1,70 @@ +module Api + module Admin + module V1 + class BansController < Api::Admin::V1::ApplicationController + before_action :require_superadmin + before_action :set_user + + def create + cutoff = ban_date + return render_error("date is required") if cutoff.blank? + + @user.apply_poison!(cutoff, reason: params[:reason]) + + render json: { + success: true, + user_id: @user.id, + poisoned_until: @user.poisoned_until.iso8601, + poisoned_at: @user.poisoned_at.iso8601, + poison_reason: @user.poison_reason, + hidden_heartbeats: hidden_heartbeat_count + }, status: :created + rescue ArgumentError => e + if e.message.include?("future") + render_error("date cannot be in the future") + else + render_error("date is invalid") + end + end + + def show + render json: { + user_id: @user.id, + poisoned: @user.poisoned?, + poisoned_until: @user.poisoned_until&.iso8601, + poisoned_at: @user.poisoned_at&.iso8601, + poison_reason: @user.poison_reason, + hidden_heartbeats: hidden_heartbeat_count + } + end + + def destroy + unless @user.poisoned? + return render json: { success: true, user_id: @user.id, poisoned_until: nil, already_unbanned: true } + end + + @user.remove_poison! + + render json: { success: true, user_id: @user.id, poisoned_until: nil } + end + + private + + def set_user + @user = User.lookup_by_identifier(params[:hackatime_id].to_s) + render_not_found_json("User not found") unless @user + end + + def ban_date + return params[:date] if params[:date].present? + return params[:end_date] if params[:end_date].present? + + raw = request.raw_post.to_s.strip + raw.presence unless raw.start_with?("{", "[") + end + + def hidden_heartbeat_count = Heartbeat.only_poisoned.where(user_id: @user.id).count + end + end + end +end diff --git a/app/models/concerns/heartbeat_poisoning.rb b/app/models/concerns/heartbeat_poisoning.rb new file mode 100644 index 000000000..6ea27611e --- /dev/null +++ b/app/models/concerns/heartbeat_poisoning.rb @@ -0,0 +1,41 @@ +module HeartbeatPoisoning + extend ActiveSupport::Concern + + THREAD_KEY = :hackatime_include_poisoned_heartbeats + + included do + def self.poisoned_arel + users = User.arel_table + heartbeats = arel_table + cutoff_epoch = Arel::Nodes::NamedFunction.new( + "EXTRACT", [ Arel::Nodes::InfixOperation.new("FROM", Arel.sql("EPOCH"), users[:poisoned_until]) ] + ) + + users.project(1) + .where(users[:id].eq(heartbeats[:user_id])) + .where(users[:poisoned_until].not_eq(nil)) + .where(heartbeats[:time].lt(cutoff_epoch)) + .exists + end + + default_scope { HeartbeatPoisoning.included_poison? ? all : excluding_poisoned } + + scope :excluding_poisoned, -> { where.not(poisoned_arel) } + + scope :only_poisoned, -> { unscoped.where(deleted_at: nil).where(poisoned_arel) } + end + + class_methods do + def including_poison(&block) = HeartbeatPoisoning.including_poison(&block) + end + + def self.included_poison? = Thread.current[THREAD_KEY].present? + + def self.including_poison + previous = Thread.current[THREAD_KEY] + Thread.current[THREAD_KEY] = true + yield + ensure + Thread.current[THREAD_KEY] = previous + end +end diff --git a/app/models/heartbeat.rb b/app/models/heartbeat.rb index 9247ccdad..2f6af1d21 100644 --- a/app/models/heartbeat.rb +++ b/app/models/heartbeat.rb @@ -5,6 +5,7 @@ class Heartbeat < ApplicationRecord include Heartbeatable include TimeRangeFilterable + include HeartbeatPoisoning time_range_filterable_field :time diff --git a/app/models/user.rb b/app/models/user.rb index 83a5e38ec..29c07ee6e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -241,6 +241,79 @@ def clear_leaderboard_page_cache LeaderboardPageCache.clear! end + scope :poisoned, -> { where.not(poisoned_until: nil) } + + def poisoned? = poisoned_until.present? + + def apply_poison!(cutoff, reason: nil) + raise ArgumentError, "cutoff is required" if cutoff.blank? + raise ArgumentError, "cutoff cannot be in the future" if poison_cutoff_in_future?(cutoff) + + cutoff = coerce_poison_cutoff(cutoff) + raise ArgumentError, "cutoff is invalid" if cutoff.blank? + + update!(poisoned_until: cutoff, poisoned_at: Time.current, poison_reason: reason.presence) + invalidate_poisoned_derived_data! + true + end + + def remove_poison! + return false unless poisoned? + + update!(poisoned_until: nil, poisoned_at: nil, poison_reason: nil) + invalidate_poisoned_derived_data! + true + end + private def invalidate_poisoned_derived_data! + schedule_dashboard_rollup_refresh + clear_leaderboard_page_cache + end + + private def poison_cutoff_in_future?(cutoff) + Time.use_zone(timezone.presence || "UTC") do + if (date = poison_cutoff_date_only(cutoff)) + date > Date.current + else + instant = coerce_poison_cutoff(cutoff) + instant.present? && instant > Time.current + end + end + end + + private def poison_cutoff_date_only(cutoff) + case cutoff + when Date then cutoff + when String then Date.parse(cutoff.strip) if cutoff.strip.match?(/\A\d{4}-\d{2}-\d{2}\z/) + end + rescue Date::Error + nil + end + + private def coerce_poison_cutoff(cutoff) + case cutoff + when Date then end_of_day_in_user_zone(cutoff) + when Time, ActiveSupport::TimeWithZone, DateTime then cutoff + when String then parse_poison_cutoff_string(cutoff) + end + end + + private def end_of_day_in_user_zone(date) + Time.use_zone(timezone.presence || "UTC") { date.in_time_zone.beginning_of_day + 1.day } + end + + private def parse_poison_cutoff_string(value) + value = value.strip + return if value.blank? + + if value.match?(/\A\d{4}-\d{2}-\d{2}\z/) + end_of_day_in_user_zone(Date.parse(value)) + else + Time.zone.parse(value) + end + rescue Date::Error + nil + end + def schedule_leaderboard_shadowban_expiration LeaderboardShadowbanExpirationJob.set(wait_until: leaderboard_shadowban_expires_at).perform_later(id) end diff --git a/config/routes.rb b/config/routes.rb index 43371fdcc..06b67712c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -297,6 +297,10 @@ def matches?(request) post "user/search_fuzzy", to: "admin#search_users_fuzzy" post "user/convict", to: "admin#user_convict" + get "ban/:hackatime_id", to: "bans#show", as: :ban + post "ban/:hackatime_id", to: "bans#create", as: :create_ban + delete "ban/:hackatime_id", to: "bans#destroy", as: :unban + # Admin API Keys management resources :admin_api_keys, only: [ :index, :show, :create, :destroy ] diff --git a/db/migrate/20260903120000_add_poison_to_users.rb b/db/migrate/20260903120000_add_poison_to_users.rb new file mode 100644 index 000000000..1849b9b58 --- /dev/null +++ b/db/migrate/20260903120000_add_poison_to_users.rb @@ -0,0 +1,9 @@ +class AddPoisonToUsers < ActiveRecord::Migration[8.0] + def change + add_column :users, :poisoned_until, :datetime + add_column :users, :poisoned_at, :datetime + add_column :users, :poison_reason, :text + + add_index :users, :poisoned_until, where: "poisoned_until IS NOT NULL" + end +end diff --git a/db/schema.rb b/db/schema.rb index fc82a8934..2fce39da0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_20_115013) do +ActiveRecord::Schema[8.1].define(version: 2026_09_03_120000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_stat_statements" @@ -691,6 +691,9 @@ t.text "leaderboard_shadowban_reason" t.boolean "leaderboard_shadowbanned", default: false, null: false t.bigint "leaderboard_shadowbanned_by_id" + t.text "poison_reason" + t.datetime "poisoned_at" + t.datetime "poisoned_until" t.text "profile_bio" t.string "profile_bluesky_url" t.string "profile_discord_url" @@ -719,6 +722,7 @@ t.index ["hca_id"], name: "index_users_on_hca_id" t.index ["leaderboard_shadowbanned"], name: "index_users_on_leaderboard_shadowbanned", where: "(leaderboard_shadowbanned = true)" t.index ["leaderboard_shadowbanned_by_id"], name: "index_users_on_leaderboard_shadowbanned_by_id" + t.index ["poisoned_until"], name: "index_users_on_poisoned_until", where: "(poisoned_until IS NOT NULL)" t.index ["slack_uid"], name: "index_users_on_slack_uid", unique: true t.index ["slack_username"], name: "index_users_on_slack_username_trgm", opclass: :gin_trgm_ops, using: :gin t.index ["timezone", "trust_level"], name: "index_users_on_timezone_trust_level" diff --git a/spec/requests/api/admin/v1/bans_spec.rb b/spec/requests/api/admin/v1/bans_spec.rb new file mode 100644 index 000000000..9e48dd60b --- /dev/null +++ b/spec/requests/api/admin/v1/bans_spec.rb @@ -0,0 +1,168 @@ +require 'swagger_helper' + +RSpec.describe 'Api::Admin::V1::Bans', type: :request, openapi_spec: 'admin/swagger.yaml' do + error_schema = { + type: :object, + properties: { error: { type: :string, example: 'User not found' } } + } + + ban_state_schema = { + type: :object, + properties: { + user_id: { type: :integer, example: 42 }, + poisoned: { type: :boolean, example: true }, + poisoned_until: { type: :string, format: 'date-time', nullable: true, example: '2026-06-16T00:00:00Z' }, + poisoned_at: { type: :string, format: 'date-time', nullable: true, example: '2026-06-15T18:04:00Z' }, + poison_reason: { type: :string, nullable: true, example: 'Telescreen: fabricated heartbeats' }, + hidden_heartbeats: { type: :integer, example: 1284 } + } + } + + path '/api/admin/v1/ban/{hackatime_id}' do + parameter name: :hackatime_id, in: :path, type: :string, + description: 'Hackatime user identifier: numeric ID, Slack UID, HCA ID or username' + + get('Get Ban State') do + tags 'Admin Resources' + description <<~DESC + Report the current heartbeat poisoning state, including the cutoff, when the + ban was applied and the recorded reason. Requires a superadmin API key. + DESC + security [ AdminToken: [] ] + produces 'application/json' + + response(200, 'successful') do + let(:Authorization) { "Bearer dev-admin-api-key-12345" } + let(:target) { create(:user, username: 'rswag_ban_show', timezone: 'UTC') } + let(:hackatime_id) { target.id.to_s } + + before { target.apply_poison!((Date.current - 30).to_s, reason: 'Fraud!') } + + schema(**ban_state_schema) + run_test! + end + + response(404, 'user not found') do + let(:Authorization) { "Bearer dev-admin-api-key-12345" } + let(:hackatime_id) { 'no-such-user' } + schema(**error_schema) + run_test! + end + + response(401, 'unauthorized') do + let(:Authorization) { "Bearer invalid-token" } + let(:hackatime_id) { '1' } + run_test! + end + end + + post('Poison Heartbeats (Ban)') do + tags 'Admin Resources' + description <<~DESC + Poison Hearbeats! + DESC + security [ AdminToken: [] ] + consumes 'application/json' + produces 'application/json' + + parameter name: :payload, in: :body, schema: { + type: :object, + properties: { + date: { type: :string, format: 'date', example: '2026-06-15', description: 'Inclusive last day to poison' }, + end_date: { type: :string, format: 'date', example: '2026-06-15', description: 'Alias for `date`' }, + reason: { type: :string, nullable: true, example: 'Fraud!' } + }, + required: [ 'date' ] + } + + response(201, 'created') do + let(:Authorization) { "Bearer dev-admin-api-key-12345" } + let(:target) { create(:user, username: 'rswag_ban_create', timezone: 'UTC') } + let(:hackatime_id) { target.id.to_s } + let(:payload) { { date: (Date.current - 30).to_s, reason: 'Fraud!' } } + + schema type: :object, + properties: { + success: { type: :boolean, example: true }, + user_id: { type: :integer, example: 42 }, + poisoned_until: { type: :string, format: 'date-time', example: '2026-06-16T00:00:00Z' }, + poisoned_at: { type: :string, format: 'date-time', example: '2026-06-15T18:04:00Z' }, + poison_reason: { type: :string, nullable: true, example: 'Fraud!' }, + hidden_heartbeats: { type: :integer, example: 1284 } + } + + run_test! + end + + response(422, 'invalid date - Returned when the date is missing, unparseable, or in the future.') do + let(:Authorization) { "Bearer dev-admin-api-key-12345" } + let(:target) { create(:user, username: 'rswag_ban_future', timezone: 'UTC') } + let(:hackatime_id) { target.id.to_s } + let(:payload) { { date: (Date.current + 1).to_s } } + schema(**error_schema) + run_test! + end + + response(404, 'user not found') do + let(:Authorization) { "Bearer dev-admin-api-key-12345" } + let(:hackatime_id) { 'no-such-user' } + let(:payload) { { date: (Date.current - 30).to_s } } + schema(**error_schema) + run_test! + end + + response(401, 'unauthorized - Returned when the key is missing, invalid, or not superadmin level.') do + let(:Authorization) { "Bearer viewer-admin-api-key-rswag-ban" } + let(:hackatime_id) { '1' } + let(:payload) { { date: (Date.current - 30).to_s } } + + before do + u = create(:user, :viewer, username: 'rswag_ban_viewer', timezone: 'UTC') + create(:admin_api_key, user: u, name: 'Viewer Ban Key', token: 'viewer-admin-api-key-rswag-ban') + end + + run_test! + end + end + + delete('Remove Poison (Unban)') do + tags 'Admin Resources' + description <<~DESC + Lift the poison. Requires a superadmin API key. + DESC + security [ AdminToken: [] ] + produces 'application/json' + + response(200, 'successful') do + let(:Authorization) { "Bearer dev-admin-api-key-12345" } + let(:target) { create(:user, username: 'rswag_ban_delete', timezone: 'UTC') } + let(:hackatime_id) { target.id.to_s } + + before { target.apply_poison!((Date.current - 30).to_s, reason: 'Fraud!') } + + schema type: :object, + properties: { + success: { type: :boolean, example: true }, + user_id: { type: :integer, example: 42 }, + poisoned_until: { type: :string, nullable: true, example: nil }, + already_unbanned: { type: :boolean, example: false, description: 'Present when the user was not banned' } + } + + run_test! + end + + response(404, 'user not found') do + let(:Authorization) { "Bearer dev-admin-api-key-12345" } + let(:hackatime_id) { 'no-such-user' } + schema(**error_schema) + run_test! + end + + response(401, 'unauthorized') do + let(:Authorization) { "Bearer invalid-token" } + let(:hackatime_id) { '1' } + run_test! + end + end + end +end diff --git a/swagger/admin/swagger.yaml b/swagger/admin/swagger.yaml index 968a0b8d3..23759d05f 100644 --- a/swagger/admin/swagger.yaml +++ b/swagger/admin/swagger.yaml @@ -2884,6 +2884,184 @@ paths: description: Primary email or "no email" '401': description: unauthorized + "/api/admin/v1/ban/{hackatime_id}": + parameters: + - name: hackatime_id + in: path + description: 'Hackatime user identifier: numeric ID, Slack UID, HCA ID or username' + required: true + schema: + type: string + get: + summary: Get Ban State + tags: + - Admin Resources + security: + - AdminToken: [] + responses: + '200': + description: successful + content: + application/json: + schema: + type: object + properties: + user_id: + type: integer + example: 42 + poisoned: + type: boolean + example: true + poisoned_until: + type: string + format: date-time + nullable: true + example: '2026-06-16T00:00:00Z' + poisoned_at: + type: string + format: date-time + nullable: true + example: '2026-06-15T18:04:00Z' + poison_reason: + type: string + nullable: true + example: 'Fraud!' + hidden_heartbeats: + type: integer + example: 1284 + '404': + description: user not found + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: User not found + '401': + description: unauthorized + post: + summary: Poison Heartbeats + tags: + - Admin Resources + security: + - AdminToken: [] + parameters: [] + responses: + '201': + description: created + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + user_id: + type: integer + example: 42 + poisoned_until: + type: string + format: date-time + example: '2026-06-16T00:00:00Z' + poisoned_at: + type: string + format: date-time + example: '2026-06-15T18:04:00Z' + poison_reason: + type: string + nullable: true + example: 'Fraud!' + hidden_heartbeats: + type: integer + example: 1284 + '422': + description: invalid date - Returned when the date is missing, unparseable, + or in the future. + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: User not found + '404': + description: user not found + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: User not found + '401': + description: unauthorized — Returned when the key is missing, invalid, or + not superadmin level. + requestBody: + content: + application/json: + schema: + type: object + properties: + date: + type: string + format: date + example: '2026-06-15' + description: Inclusive last day to poison + end_date: + type: string + format: date + example: '2026-06-15' + description: Alias for `date` + reason: + type: string + nullable: true + example: 'Telescreen: fabricated heartbeats' + required: + - date + delete: + summary: Un-Poison Hearbeats + tags: + - Admin Resources + security: + - AdminToken: [] + responses: + '200': + description: successful + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + user_id: + type: integer + example: 42 + poisoned_until: + type: string + nullable: true + example: + already_unbanned: + type: boolean + example: false + '404': + description: user not found + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: User not found + '401': + description: unauthorized "/api/admin/v1/heartbeats/ip_machine_pairs": get: summary: List users sharing the same machine + IP combination diff --git a/test/controllers/api/admin/v1/bans_controller_test.rb b/test/controllers/api/admin/v1/bans_controller_test.rb new file mode 100644 index 000000000..f97bd7f81 --- /dev/null +++ b/test/controllers/api/admin/v1/bans_controller_test.rb @@ -0,0 +1,215 @@ +require "test_helper" + +class Api::Admin::V1::BansControllerTest < ActionDispatch::IntegrationTest + setup do + @superadmin = create(:user, admin_level: :superadmin) + @key = create(:admin_api_key, user: @superadmin, name: "Telescreen") + + @user = create(:user, timezone: "UTC") + @cutoff = Time.utc(2026, 3, 1) + @before_cutoff = build_heartbeat(@cutoff - 2.days, "old-project") + @after_cutoff = build_heartbeat(@cutoff + 2.days, "new-project") + end + + def build_heartbeat(time, project) + create(:heartbeat, user: @user, entity: "src/main.rb", type: "file", + category: "coding", time: time.to_f, project: project, source_type: :test_entry) + end + + def auth_headers(key = @key) + { "Authorization" => ActionController::HttpAuthentication::Token.encode_credentials(key.token) } + end + + test "poisons heartbeats on or before the posted end date" do + post "/api/admin/v1/ban/#{@user.id}", params: { date: "2026-03-01" }, headers: auth_headers, as: :json + + assert_response :created + assert_equal true, response.parsed_body["success"] + assert_equal 1, response.parsed_body["hidden_heartbeats"] + + assert @user.reload.poisoned? + assert_not_includes Heartbeat.all, @before_cutoff + assert_includes Heartbeat.all, @after_cutoff + end + + test "the end date is inclusive of the named day" do + on_the_day = build_heartbeat(Time.utc(2026, 3, 1, 23, 30), "ban-day-night") + + post "/api/admin/v1/ban/#{@user.id}", params: { date: "2026-03-01" }, headers: auth_headers, as: :json + + assert_response :created + assert_not_includes Heartbeat.all, on_the_day + assert_equal @cutoff + 1.day, @user.reload.poisoned_until.utc + end + + test "accepts a bare date string as the request body" do + post "/api/admin/v1/ban/#{@user.id}", params: "2026-03-01", + headers: auth_headers.merge("Content-Type" => "text/plain") + + assert_response :created + assert_equal @cutoff + 1.day, @user.reload.poisoned_until.utc + end + + test "accepts the hackatime id in its other forms" do + @user.update!(slack_uid: "U12345", username: "banme") + + post "/api/admin/v1/ban/U12345", params: { date: "2026-03-01" }, headers: auth_headers, as: :json + assert_response :created + assert @user.reload.poisoned? + + delete "/api/admin/v1/ban/banme", headers: auth_headers, as: :json + assert_response :success + assert_not @user.reload.poisoned? + end + + test "a second ban overwrites the previous end date" do + post "/api/admin/v1/ban/#{@user.id}", params: { date: "2026-03-01" }, headers: auth_headers, as: :json + post "/api/admin/v1/ban/#{@user.id}", params: { date: "2026-03-05" }, headers: auth_headers, as: :json + + assert_response :created + assert_equal Time.utc(2026, 3, 6), @user.reload.poisoned_until.utc + assert_not_includes Heartbeat.all, @after_cutoff + end + + test "unbanning restores the hidden heartbeats" do + post "/api/admin/v1/ban/#{@user.id}", params: { date: "2026-03-01" }, headers: auth_headers, as: :json + assert_not_includes Heartbeat.all, @before_cutoff + + delete "/api/admin/v1/ban/#{@user.id}", headers: auth_headers, as: :json + + assert_response :success + assert_nil response.parsed_body["poisoned_until"] + assert_includes Heartbeat.all, @before_cutoff + end + + test "unbanning a user who is not banned succeeds without changing anything" do + delete "/api/admin/v1/ban/#{@user.id}", headers: auth_headers, as: :json + + assert_response :success + assert_equal true, response.parsed_body["already_unbanned"] + end + + test "rejects a missing date" do + post "/api/admin/v1/ban/#{@user.id}", params: {}, headers: auth_headers, as: :json + + assert_response :unprocessable_entity + assert_not @user.reload.poisoned? + end + + test "rejects a future end date" do + post "/api/admin/v1/ban/#{@user.id}", params: { date: (Date.current + 1).to_s }, headers: auth_headers, as: :json + + assert_response :unprocessable_entity + assert_equal "date cannot be in the future", response.parsed_body["error"] + assert_not @user.reload.poisoned? + end + + test "accepts today as an end date" do + post "/api/admin/v1/ban/#{@user.id}", params: { date: Date.current.to_s }, headers: auth_headers, as: :json + + assert_response :created + assert @user.reload.poisoned? + end + + test "rejects an unparseable date rather than poisoning everything" do + post "/api/admin/v1/ban/#{@user.id}", params: { date: "not-a-date" }, headers: auth_headers, as: :json + + assert_response :unprocessable_entity + assert_not @user.reload.poisoned? + end + + test "returns not found for an unknown user" do + post "/api/admin/v1/ban/nonexistent-user", params: { date: "2026-03-01" }, headers: auth_headers, as: :json + + assert_response :not_found + end + + test "returns the audit trail in the ban response" do + post "/api/admin/v1/ban/#{@user.id}", + params: { date: "2026-03-01", reason: "Telescreen: fabricated heartbeats" }, + headers: auth_headers, as: :json + + assert_response :created + assert_equal "Telescreen: fabricated heartbeats", response.parsed_body["poison_reason"] + assert_not_nil response.parsed_body["poisoned_at"] + end + + test "show reports the current ban state" do + get "/api/admin/v1/ban/#{@user.id}", headers: auth_headers, as: :json + + assert_response :success + assert_equal false, response.parsed_body["poisoned"] + assert_nil response.parsed_body["poisoned_until"] + + @user.apply_poison!(@cutoff, reason: "fabricated heartbeats") + + get "/api/admin/v1/ban/#{@user.id}", headers: auth_headers, as: :json + + assert_response :success + assert_equal true, response.parsed_body["poisoned"] + assert_equal "fabricated heartbeats", response.parsed_body["poison_reason"] + assert_equal 1, response.parsed_body["hidden_heartbeats"] + assert_not_nil response.parsed_body["poisoned_at"] + end + + test "show requires a superadmin key" do + key = create(:admin_api_key, user: create(:user, admin_level: :admin), name: "Admin") + + get "/api/admin/v1/ban/#{@user.id}", headers: auth_headers(key), as: :json + + assert_response :unauthorized + end + + + test "requires an api key" do + post "/api/admin/v1/ban/#{@user.id}", params: { date: "2026-03-01" }, as: :json + + assert_response :unauthorized + assert_not @user.reload.poisoned? + end + + test "an ultraadmin key is allowed" do + key = create(:admin_api_key, user: create(:user, admin_level: :ultraadmin), name: "Ultra") + + post "/api/admin/v1/ban/#{@user.id}", params: { date: "2026-03-01" }, headers: auth_headers(key), as: :json + + assert_response :created + end + + test "a plain admin key is rejected" do + key = create(:admin_api_key, user: create(:user, admin_level: :admin), name: "Admin") + + post "/api/admin/v1/ban/#{@user.id}", params: { date: "2026-03-01" }, headers: auth_headers(key), as: :json + + assert_response :unauthorized + assert_not @user.reload.poisoned? + end + + test "a viewer key is rejected" do + key = create(:admin_api_key, user: create(:user, admin_level: :viewer), name: "Viewer") + + post "/api/admin/v1/ban/#{@user.id}", params: { date: "2026-03-01" }, headers: auth_headers(key), as: :json + + assert_response :unauthorized + assert_not @user.reload.poisoned? + end + + test "a non-admin key cannot unban either" do + @user.apply_poison!(@cutoff) + key = create(:admin_api_key, user: create(:user, admin_level: :admin), name: "Admin") + + delete "/api/admin/v1/ban/#{@user.id}", headers: auth_headers(key), as: :json + + assert_response :unauthorized + assert @user.reload.poisoned? + end + + test "a revoked superadmin key is rejected" do + @key.revoke! + + post "/api/admin/v1/ban/#{@user.id}", params: { date: "2026-03-01" }, headers: auth_headers, as: :json + + assert_response :unauthorized + assert_not @user.reload.poisoned? + end +end diff --git a/test/models/concerns/heartbeat_poisoning_test.rb b/test/models/concerns/heartbeat_poisoning_test.rb new file mode 100644 index 000000000..1362064a7 --- /dev/null +++ b/test/models/concerns/heartbeat_poisoning_test.rb @@ -0,0 +1,191 @@ +require "test_helper" + +class HeartbeatPoisoningTest < ActiveSupport::TestCase + include ActiveJob::TestHelper + + setup do + Rails.cache.clear + clear_enqueued_jobs + @original_queue_adapter = ActiveJob::Base.queue_adapter + ActiveJob::Base.queue_adapter = :test + + @user = create(:user, timezone: "UTC") + @cutoff = Time.utc(2026, 3, 1) + @before_cutoff = build_heartbeat(@cutoff - 2.days, "old-project") + @after_cutoff = build_heartbeat(@cutoff + 2.days, "new-project") + end + + teardown do + Rails.cache.clear + clear_enqueued_jobs + ActiveJob::Base.queue_adapter = @original_queue_adapter + end + + def build_heartbeat(time, project) + create(:heartbeat, user: @user, entity: "src/main.rb", type: "file", + category: "coding", time: time.to_f, project: project, source_type: :test_entry) + end + + test "poisoning hides heartbeats before the cutoff but keeps the rows" do + assert_includes Heartbeat.all, @before_cutoff + + @user.apply_poison!(@cutoff) + + assert_not_includes Heartbeat.all, @before_cutoff + assert_includes Heartbeat.all, @after_cutoff + + assert Heartbeat.unscoped.exists?(@before_cutoff.id) + end + + test "poisoning applies to the banned user's own association reads" do + @user.apply_poison!(@cutoff) + + assert_not_includes @user.heartbeats.reload, @before_cutoff + assert_includes @user.heartbeats.reload, @after_cutoff + end + + test "poisoned time is excluded from durations and project grouping" do + @user.apply_poison!(@cutoff) + + projects = @user.heartbeats.group(:project).duration_seconds + + assert_not_includes projects.keys, "old-project" + end + + test "including_poison reveals hidden heartbeats only inside the block" do + @user.apply_poison!(@cutoff) + + Heartbeat.including_poison do + assert_includes Heartbeat.all, @before_cutoff + end + + assert_not_includes Heartbeat.all, @before_cutoff + end + + test "including_poison restores the previous state even when the block raises" do + @user.apply_poison!(@cutoff) + + assert_raises(RuntimeError) do + Heartbeat.including_poison { raise "boom" } + end + + assert_not_includes Heartbeat.all, @before_cutoff + end + + test "removing the poison restores the hidden heartbeats" do + @user.apply_poison!(@cutoff) + assert_not_includes Heartbeat.all, @before_cutoff + + @user.remove_poison! + + assert_includes Heartbeat.all, @before_cutoff + assert_not @user.reload.poisoned? + end + + test "only_poisoned returns exactly the hidden heartbeats" do + @user.apply_poison!(@cutoff) + + poisoned = Heartbeat.only_poisoned.where(user_id: @user.id) + + assert_includes poisoned, @before_cutoff + assert_not_includes poisoned, @after_cutoff + end + + test "poisoning one user does not hide another user's heartbeats" do + other = create(:user, timezone: "UTC") + other_old = create(:heartbeat, user: other, entity: "a.rb", type: "file", + category: "coding", time: (@cutoff - 5.days).to_f, project: "other", source_type: :test_entry) + + @user.apply_poison!(@cutoff) + + assert_includes Heartbeat.all, other_old + end + + test "soft deleted heartbeats stay hidden regardless of poison state" do + @after_cutoff.soft_delete + @user.apply_poison!(@cutoff) + + Heartbeat.including_poison do + assert_not_includes Heartbeat.all, @after_cutoff + end + end + + test "a date-only cutoff covers the whole named day in the user's own timezone" do + @user.update!(timezone: "America/New_York") + + @user.apply_poison!("2026-03-01") + + assert_equal Time.utc(2026, 3, 2, 5), @user.poisoned_until.utc + end + + test "heartbeats logged on the ban date itself are poisoned" do + ban_date = Date.new(2026, 3, 1) + morning = build_heartbeat(Time.utc(2026, 3, 1, 0, 1), "ban-day-morning") + night = build_heartbeat(Time.utc(2026, 3, 1, 23, 59), "ban-day-night") + next_day = build_heartbeat(Time.utc(2026, 3, 2, 0, 1), "day-after") + + @user.apply_poison!(ban_date.to_s) + + assert_not_includes Heartbeat.all, morning + assert_not_includes Heartbeat.all, night + assert_includes Heartbeat.all, next_day + end + + test "a timestamp cutoff is treated as an absolute instant" do + @user.update!(timezone: "America/New_York") + + @user.apply_poison!("2026-03-01T12:00:00Z") + + assert_equal Time.utc(2026, 3, 1, 12), @user.poisoned_until.utc + end + + test "poisoning schedules a dashboard rollup refresh so derived totals recompute" do + clear_enqueued_jobs + + @user.apply_poison!(@cutoff) + + assert_enqueued_with job: DashboardRollupRefreshJob, args: [ @user.id ] + end + + test "removing the poison schedules a dashboard rollup refresh" do + @user.apply_poison!(@cutoff) + clear_enqueued_jobs + + @user.remove_poison! + + assert_enqueued_with job: DashboardRollupRefreshJob, args: [ @user.id ] + end + + test "today is an allowed ban date even though it ends at tomorrow's midnight" do + @user.apply_poison!(Date.current.to_s) + + assert @user.reload.poisoned? + assert_equal Date.current + 1, @user.poisoned_until.in_time_zone(@user.timezone).to_date + end + + test "a future ban date is rejected" do + assert_raises(ArgumentError) { @user.apply_poison!((Date.current + 1).to_s) } + assert_raises(ArgumentError) { @user.apply_poison!(Date.current + 30) } + + assert_not @user.reload.poisoned? + end + + test "a future timestamp is rejected" do + assert_raises(ArgumentError) { @user.apply_poison!((Time.current + 2.hours).iso8601) } + + assert_not @user.reload.poisoned? + end + + test "a future date is rejected relative to the user's own timezone" do + @user.update!(timezone: "Pacific/Kiritimati") + + assert_raises(ArgumentError) { @user.apply_poison!((Date.current + 2).to_s) } + end + + test "applying poison without a cutoff raises rather than hiding everything" do + assert_raises(ArgumentError) { @user.apply_poison!(nil) } + assert_raises(ArgumentError) { @user.apply_poison!("") } + + assert_not @user.reload.poisoned? + end +end From 59a724b35d1a3c87a40c7a7bb7d4abea55167ae7 Mon Sep 17 00:00:00 2001 From: Scooter Date: Fri, 4 Sep 2026 11:04:42 +0300 Subject: [PATCH 2/8] fix spec --- spec/requests/api/admin/v1/bans_spec.rb | 2 +- swagger/admin/swagger.yaml | 15 +++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/spec/requests/api/admin/v1/bans_spec.rb b/spec/requests/api/admin/v1/bans_spec.rb index 9e48dd60b..78ab590ba 100644 --- a/spec/requests/api/admin/v1/bans_spec.rb +++ b/spec/requests/api/admin/v1/bans_spec.rb @@ -13,7 +13,7 @@ poisoned: { type: :boolean, example: true }, poisoned_until: { type: :string, format: 'date-time', nullable: true, example: '2026-06-16T00:00:00Z' }, poisoned_at: { type: :string, format: 'date-time', nullable: true, example: '2026-06-15T18:04:00Z' }, - poison_reason: { type: :string, nullable: true, example: 'Telescreen: fabricated heartbeats' }, + poison_reason: { type: :string, nullable: true, example: 'Fraud!' }, hidden_heartbeats: { type: :integer, example: 1284 } } } diff --git a/swagger/admin/swagger.yaml b/swagger/admin/swagger.yaml index 23759d05f..d16bb461a 100644 --- a/swagger/admin/swagger.yaml +++ b/swagger/admin/swagger.yaml @@ -2896,6 +2896,8 @@ paths: summary: Get Ban State tags: - Admin Resources + description: | + Report the current heartbeat poisoning state. Requires a superadmin API key. security: - AdminToken: [] responses: @@ -2925,7 +2927,7 @@ paths: poison_reason: type: string nullable: true - example: 'Fraud!' + example: Fraud! hidden_heartbeats: type: integer example: 1284 @@ -2945,6 +2947,9 @@ paths: summary: Poison Heartbeats tags: - Admin Resources + description: 'Poison Hearbeats! + + ' security: - AdminToken: [] parameters: [] @@ -2973,7 +2978,7 @@ paths: poison_reason: type: string nullable: true - example: 'Fraud!' + example: Fraud! hidden_heartbeats: type: integer example: 1284 @@ -2999,7 +3004,7 @@ paths: type: string example: User not found '401': - description: unauthorized — Returned when the key is missing, invalid, or + description: unauthorized - Returned when the key is missing, invalid, or not superadmin level. requestBody: content: @@ -3020,13 +3025,14 @@ paths: reason: type: string nullable: true - example: 'Telescreen: fabricated heartbeats' + example: Fraud! required: - date delete: summary: Un-Poison Hearbeats tags: - Admin Resources + description: 'Lift the poison. Requires a superadmin API key. security: - AdminToken: [] responses: @@ -3050,6 +3056,7 @@ paths: already_unbanned: type: boolean example: false + description: Present when the user was not banned '404': description: user not found content: From 3f9ad57a6ed50e7d020caa819ef9df6435468a21 Mon Sep 17 00:00:00 2001 From: Scooter Date: Fri, 4 Sep 2026 12:37:06 +0300 Subject: [PATCH 3/8] copilot suggestions --- app/jobs/cache/active_projects_job.rb | 6 ++ app/models/concerns/heartbeat_poisoning.rb | 2 +- app/models/user.rb | 5 +- app/services/heartbeat_ingest.rb | 8 ++- config/routes.rb | 4 +- .../20260903120000_add_poison_to_users.rb | 2 +- spec/requests/api/admin/v1/bans_spec.rb | 25 ++++++-- swagger/admin/swagger.yaml | 23 ++++--- .../concerns/heartbeat_poisoning_test.rb | 62 ++++++++++++++++++- 9 files changed, 113 insertions(+), 24 deletions(-) diff --git a/app/jobs/cache/active_projects_job.rb b/app/jobs/cache/active_projects_job.rb index 863384c01..86b20ad01 100644 --- a/app/jobs/cache/active_projects_job.rb +++ b/app/jobs/cache/active_projects_job.rb @@ -13,6 +13,12 @@ def calculate WHERE source_type = ? AND deleted_at IS NULL AND time > ? + AND NOT EXISTS ( + SELECT 1 FROM users poisoned_users + WHERE poisoned_users.id = heartbeats.user_id + AND poisoned_users.poisoned_until IS NOT NULL + AND heartbeats.time < EXTRACT(EPOCH FROM poisoned_users.poisoned_until) + ) ) SELECT DISTINCT ON (recent.user_id) project_repo_mappings.*, recent.user_id FROM project_repo_mappings diff --git a/app/models/concerns/heartbeat_poisoning.rb b/app/models/concerns/heartbeat_poisoning.rb index 6ea27611e..917f249cd 100644 --- a/app/models/concerns/heartbeat_poisoning.rb +++ b/app/models/concerns/heartbeat_poisoning.rb @@ -18,7 +18,7 @@ def self.poisoned_arel .exists end - default_scope { HeartbeatPoisoning.included_poison? ? all : excluding_poisoned } + default_scope { HeartbeatPoisoning.included_poison? ? all : where.not(poisoned_arel) } scope :excluding_poisoned, -> { where.not(poisoned_arel) } diff --git a/app/models/user.rb b/app/models/user.rb index 29c07ee6e..d3a684520 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -282,6 +282,7 @@ def remove_poison! private def poison_cutoff_date_only(cutoff) case cutoff + when DateTime then nil when Date then cutoff when String then Date.parse(cutoff.strip) if cutoff.strip.match?(/\A\d{4}-\d{2}-\d{2}\z/) end @@ -291,8 +292,8 @@ def remove_poison! private def coerce_poison_cutoff(cutoff) case cutoff - when Date then end_of_day_in_user_zone(cutoff) when Time, ActiveSupport::TimeWithZone, DateTime then cutoff + when Date then end_of_day_in_user_zone(cutoff) when String then parse_poison_cutoff_string(cutoff) end end @@ -308,7 +309,7 @@ def remove_poison! if value.match?(/\A\d{4}-\d{2}-\d{2}\z/) end_of_day_in_user_zone(Date.parse(value)) else - Time.zone.parse(value) + Time.use_zone(timezone.presence || "UTC") { Time.zone.parse(value) } end rescue Date::Error nil diff --git a/app/services/heartbeat_ingest.rb b/app/services/heartbeat_ingest.rb index 3d50324a4..06a894bf2 100644 --- a/app/services/heartbeat_ingest.rb +++ b/app/services/heartbeat_ingest.rb @@ -123,10 +123,12 @@ def normalize_direct_heartbeat(heartbeat, placeholder_state:) ).slice(*Heartbeat.column_names.map(&:to_sym)) end + def heartbeats_for_dedup = Heartbeat.including_poison { Heartbeat.where(user_id: @user.id) } + def persist_direct_heartbeats(entries) entries_by_hash = entries.group_by { |entry| entry[:fields_hash] } hashes = entries_by_hash.keys - persisted_by_hash = @user.heartbeats.where(fields_hash: hashes).index_by(&:fields_hash) + persisted_by_hash = heartbeats_for_dedup.where(fields_hash: hashes).index_by(&:fields_hash) missing_entries = entries_by_hash.filter_map do |fields_hash, matching_entries| matching_entries.first unless persisted_by_hash.key?(fields_hash) end @@ -147,7 +149,7 @@ def persist_direct_heartbeats(entries) unresolved_hashes = missing_entries.map { |entry| entry[:fields_hash] } - inserted_by_hash.keys if unresolved_hashes.any? persisted_by_hash.merge!( - @user.heartbeats.where(fields_hash: unresolved_hashes).index_by(&:fields_hash) + heartbeats_for_dedup.where(fields_hash: unresolved_hashes).index_by(&:fields_hash) ) end end @@ -273,7 +275,7 @@ def flush_import_batch(seen_hashes) records = seen_hashes.values compatible_hashes = records.flat_map { |record| [ record[:fields_hash], record[:legacy_fields_hash] ] }.compact.uniq existing_hashes = compatible_hashes.each_slice(10_000).flat_map do |hashes| - @user.heartbeats.where(fields_hash: hashes).pluck(:fields_hash) + heartbeats_for_dedup.where(fields_hash: hashes).pluck(:fields_hash) end.to_set records = records.reject do |record| existing_hashes.include?(record[:fields_hash]) || existing_hashes.include?(record[:legacy_fields_hash]) diff --git a/config/routes.rb b/config/routes.rb index 06b67712c..9111ab9e1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -297,9 +297,7 @@ def matches?(request) post "user/search_fuzzy", to: "admin#search_users_fuzzy" post "user/convict", to: "admin#user_convict" - get "ban/:hackatime_id", to: "bans#show", as: :ban - post "ban/:hackatime_id", to: "bans#create", as: :create_ban - delete "ban/:hackatime_id", to: "bans#destroy", as: :unban + resource :ban, only: [ :show, :create, :destroy ], path: "ban/:hackatime_id" # Admin API Keys management resources :admin_api_keys, only: [ :index, :show, :create, :destroy ] diff --git a/db/migrate/20260903120000_add_poison_to_users.rb b/db/migrate/20260903120000_add_poison_to_users.rb index 1849b9b58..88a27e178 100644 --- a/db/migrate/20260903120000_add_poison_to_users.rb +++ b/db/migrate/20260903120000_add_poison_to_users.rb @@ -1,4 +1,4 @@ -class AddPoisonToUsers < ActiveRecord::Migration[8.0] +class AddPoisonToUsers < ActiveRecord::Migration[8.1] def change add_column :users, :poisoned_until, :datetime add_column :users, :poisoned_at, :datetime diff --git a/spec/requests/api/admin/v1/bans_spec.rb b/spec/requests/api/admin/v1/bans_spec.rb index 78ab590ba..58443ca26 100644 --- a/spec/requests/api/admin/v1/bans_spec.rb +++ b/spec/requests/api/admin/v1/bans_spec.rb @@ -6,6 +6,11 @@ properties: { error: { type: :string, example: 'User not found' } } } + date_error_schema = { + type: :object, + properties: { error: { type: :string, example: 'date cannot be in the future' } } + } + ban_state_schema = { type: :object, properties: { @@ -59,7 +64,7 @@ post('Poison Heartbeats (Ban)') do tags 'Admin Resources' description <<~DESC - Poison Hearbeats! + Poison Heartbeats! DESC security [ AdminToken: [] ] consumes 'application/json' @@ -67,12 +72,22 @@ parameter name: :payload, in: :body, schema: { type: :object, + description: 'Supply the cutoff as `date` or its alias `end_date`. Exactly one is required.', properties: { - date: { type: :string, format: 'date', example: '2026-06-15', description: 'Inclusive last day to poison' }, - end_date: { type: :string, format: 'date', example: '2026-06-15', description: 'Alias for `date`' }, + date: { + type: :string, + example: '2026-06-15', + description: 'Inclusive last day to poison. A date (`YYYY-MM-DD`) covers ' \ + "the whole day in the user's timezone; an ISO 8601 timestamp is " \ + 'treated as an absolute instant. Must not be in the future.' + }, + end_date: { type: :string, example: '2026-06-15', description: 'Alias for `date`' }, reason: { type: :string, nullable: true, example: 'Fraud!' } }, - required: [ 'date' ] + anyOf: [ + { required: [ 'date' ] }, + { required: [ 'end_date' ] } + ] } response(201, 'created') do @@ -99,7 +114,7 @@ let(:target) { create(:user, username: 'rswag_ban_future', timezone: 'UTC') } let(:hackatime_id) { target.id.to_s } let(:payload) { { date: (Date.current + 1).to_s } } - schema(**error_schema) + schema(**date_error_schema) run_test! end diff --git a/swagger/admin/swagger.yaml b/swagger/admin/swagger.yaml index d16bb461a..471d904f7 100644 --- a/swagger/admin/swagger.yaml +++ b/swagger/admin/swagger.yaml @@ -2947,7 +2947,7 @@ paths: summary: Poison Heartbeats tags: - Admin Resources - description: 'Poison Hearbeats! + description: 'Poison Heartbeats! ' security: @@ -2992,7 +2992,7 @@ paths: properties: error: type: string - example: User not found + example: date cannot be in the future '404': description: user not found content: @@ -3011,28 +3011,35 @@ paths: application/json: schema: type: object + description: Supply the cutoff as `date` or its alias `end_date`. Exactly + one is required. properties: date: type: string - format: date example: '2026-06-15' - description: Inclusive last day to poison + description: Inclusive last day to poison. A date (`YYYY-MM-DD`) + covers the whole day in the user's timezone; an ISO 8601 timestamp + is treated as an absolute instant. Must not be in the future. end_date: type: string - format: date example: '2026-06-15' description: Alias for `date` reason: type: string nullable: true example: Fraud! - required: - - date + anyOf: + - required: + - date + - required: + - end_date delete: - summary: Un-Poison Hearbeats + summary: Un-Poison Heartbeats tags: - Admin Resources description: 'Lift the poison. Requires a superadmin API key. + + ' security: - AdminToken: [] responses: diff --git a/test/models/concerns/heartbeat_poisoning_test.rb b/test/models/concerns/heartbeat_poisoning_test.rb index 1362064a7..e677ebf45 100644 --- a/test/models/concerns/heartbeat_poisoning_test.rb +++ b/test/models/concerns/heartbeat_poisoning_test.rb @@ -72,6 +72,36 @@ def build_heartbeat(time, project) assert_not_includes Heartbeat.all, @before_cutoff end + test "a DateTime cutoff keeps its time of day" do + @user.apply_poison!(DateTime.new(2026, 3, 1, 12, 0, 0)) + + assert_equal Time.utc(2026, 3, 1, 12), @user.poisoned_until.utc + end + + test "a Date cutoff still covers the whole day" do + @user.apply_poison!(Date.new(2026, 3, 1)) + + assert_equal Time.utc(2026, 3, 2), @user.poisoned_until.utc + end + + test "resubmitting a poisoned heartbeat is a duplicate, not a failure" do + attrs = { + "entity" => "src/dedup.rb", "type" => "file", "category" => "coding", + "editor" => "vscode", "project" => "dedup", "language" => "Ruby", + "branch" => "main", "time" => (@cutoff - 2.days).to_f + } + create(:heartbeat, user: @user, source_type: :direct_entry, + entity: "src/dedup.rb", type: "file", category: "coding", editor: "vscode", + project: "dedup", language: "Ruby", branch: "main", time: (@cutoff - 2.days).to_f) + + @user.apply_poison!(@cutoff) + + result = HeartbeatIngest.call(user: @user, mode: :direct, request_context: {}, heartbeats: [ attrs ]) + + assert_equal 0, result.failed_count + assert_equal 1, result.duplicate_count + end + test "removing the poison restores the hidden heartbeats" do @user.apply_poison!(@cutoff) assert_not_includes Heartbeat.all, @before_cutoff @@ -82,6 +112,23 @@ def build_heartbeat(time, project) assert_not @user.reload.poisoned? end + test "the default scope applies the poison filter exactly once" do + @user.apply_poison!(@cutoff) + + assert_equal 1, Heartbeat.all.to_sql.scan(/EXISTS/).length + assert_equal 0, Heartbeat.including_poison { Heartbeat.all.to_sql.scan(/EXISTS/).length } + end + + test "scopes remain composable without tripping the default scope" do + @user.apply_poison!(@cutoff) + + assert_nothing_raised do + Heartbeat.where(project: "new-project").excluding_poisoned.count + Heartbeat.only_poisoned.count + Heartbeat.coding_only.excluding_poisoned.duration_seconds + end + end + test "only_poisoned returns exactly the hidden heartbeats" do @user.apply_poison!(@cutoff) @@ -131,13 +178,26 @@ def build_heartbeat(time, project) assert_includes Heartbeat.all, next_day end - test "a timestamp cutoff is treated as an absolute instant" do + test "a timestamp cutoff with an explicit offset is treated as an absolute instant" do @user.update!(timezone: "America/New_York") @user.apply_poison!("2026-03-01T12:00:00Z") assert_equal Time.utc(2026, 3, 1, 12), @user.poisoned_until.utc end + test "a naive timestamp resolves in the user's zone regardless of the ambient zone" do + @user.update!(timezone: "America/New_York") + + Time.use_zone("Asia/Tokyo") { @user.apply_poison!("2026-03-01T12:00:00") } + from_tokyo = @user.poisoned_until.utc + + @user.remove_poison! + Time.use_zone("UTC") { @user.apply_poison!("2026-03-01T12:00:00") } + from_utc = @user.poisoned_until.utc + + assert_equal Time.utc(2026, 3, 1, 17), from_tokyo + assert_equal from_tokyo, from_utc + end test "poisoning schedules a dashboard rollup refresh so derived totals recompute" do clear_enqueued_jobs From fc54b97ee9fe6281eb23ad805314942ec5f42019 Mon Sep 17 00:00:00 2001 From: Scooter Date: Fri, 4 Sep 2026 12:42:04 +0300 Subject: [PATCH 4/8] fix lb stuff --- app/models/user.rb | 8 ++++++++ test/models/concerns/heartbeat_poisoning_test.rb | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/app/models/user.rb b/app/models/user.rb index d3a684520..a1d83eec4 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -266,9 +266,17 @@ def remove_poison! end private def invalidate_poisoned_derived_data! schedule_dashboard_rollup_refresh + discard_stale_leaderboard_entries! clear_leaderboard_page_cache end + + private def discard_stale_leaderboard_entries! + boundary = [ poisoned_until, Time.current ].compact.max + boards = Leaderboard.where(start_date: ..boundary.to_date) + LeaderboardEntry.where(user_id: id, leaderboard_id: boards.select(:id)).delete_all + end + private def poison_cutoff_in_future?(cutoff) Time.use_zone(timezone.presence || "UTC") do if (date = poison_cutoff_date_only(cutoff)) diff --git a/test/models/concerns/heartbeat_poisoning_test.rb b/test/models/concerns/heartbeat_poisoning_test.rb index e677ebf45..4c1c24b09 100644 --- a/test/models/concerns/heartbeat_poisoning_test.rb +++ b/test/models/concerns/heartbeat_poisoning_test.rb @@ -129,6 +129,17 @@ def build_heartbeat(time, project) end end + test "poisoning removes the user's stale leaderboard entries" do + board = Leaderboard.create!(start_date: Date.current, period_type: :daily) + mine = LeaderboardEntry.create!(leaderboard: board, user: @user, total_seconds: 1140) + other = LeaderboardEntry.create!(leaderboard: board, user: create(:user, timezone: "UTC"), total_seconds: 1140) + + @user.apply_poison!(@cutoff) + + assert_nil LeaderboardEntry.find_by(id: mine.id) + assert_not_nil LeaderboardEntry.find_by(id: other.id) + end + test "only_poisoned returns exactly the hidden heartbeats" do @user.apply_poison!(@cutoff) From d5eef9cf43d4e9c0ce598fd6eb195b66cfdd20d8 Mon Sep 17 00:00:00 2001 From: Scooter Date: Fri, 4 Sep 2026 12:56:31 +0300 Subject: [PATCH 5/8] fix spec again --- spec/requests/api/admin/v1/bans_spec.rb | 9 +++------ swagger/admin/swagger.yaml | 6 ++++-- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/spec/requests/api/admin/v1/bans_spec.rb b/spec/requests/api/admin/v1/bans_spec.rb index 58443ca26..589c9b53d 100644 --- a/spec/requests/api/admin/v1/bans_spec.rb +++ b/spec/requests/api/admin/v1/bans_spec.rb @@ -29,10 +29,7 @@ get('Get Ban State') do tags 'Admin Resources' - description <<~DESC - Report the current heartbeat poisoning state, including the cutoff, when the - ban was applied and the recorded reason. Requires a superadmin API key. - DESC + description "Report the current heartbeat poisoning state. Requires a superadmin API key.\n" security [ AdminToken: [] ] produces 'application/json' @@ -61,7 +58,7 @@ end end - post('Poison Heartbeats (Ban)') do + post('Poison Heartbeats') do tags 'Admin Resources' description <<~DESC Poison Heartbeats! @@ -140,7 +137,7 @@ end end - delete('Remove Poison (Unban)') do + delete('Un-Poison Heartbeats') do tags 'Admin Resources' description <<~DESC Lift the poison. Requires a superadmin API key. diff --git a/swagger/admin/swagger.yaml b/swagger/admin/swagger.yaml index 471d904f7..cb7bee89d 100644 --- a/swagger/admin/swagger.yaml +++ b/swagger/admin/swagger.yaml @@ -2896,8 +2896,10 @@ paths: summary: Get Ban State tags: - Admin Resources - description: | - Report the current heartbeat poisoning state. Requires a superadmin API key. + description: 'Report the current heartbeat poisoning state. Requires a superadmin + API key. + + ' security: - AdminToken: [] responses: From f44dfc6195175de86aa722d0d949314b32a11095 Mon Sep 17 00:00:00 2001 From: Scooter Date: Fri, 4 Sep 2026 13:02:15 +0300 Subject: [PATCH 6/8] do not delete lb history on un-poison --- app/models/leaderboard.rb | 2 ++ app/models/user.rb | 10 ++++-- .../concerns/heartbeat_poisoning_test.rb | 35 +++++++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/app/models/leaderboard.rb b/app/models/leaderboard.rb index 50748d7c3..bf0529bab 100644 --- a/app/models/leaderboard.rb +++ b/app/models/leaderboard.rb @@ -7,6 +7,8 @@ class Leaderboard < ApplicationRecord enum :period_type, { daily: 0, last_7_days: 2 } + REBUILDABLE_PERIODS = %i[daily last_7_days].freeze + def finished_generating? = finished_generating_at.present? def period_end_date = start_date diff --git a/app/models/user.rb b/app/models/user.rb index a1d83eec4..fbe5b2e4b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -272,9 +272,13 @@ def remove_poison! private def discard_stale_leaderboard_entries! - boundary = [ poisoned_until, Time.current ].compact.max - boards = Leaderboard.where(start_date: ..boundary.to_date) - LeaderboardEntry.where(user_id: id, leaderboard_id: boards.select(:id)).delete_all + Leaderboard::REBUILDABLE_PERIODS.each do |period| + date = LeaderboardDateRange.normalize_date(Date.current, period) + board = Leaderboard.find_by(start_date: date, period_type: period, timezone_utc_offset: nil) + LeaderboardEntry.where(user_id: id, leaderboard_id: board.id).delete_all if board + + LeaderboardUpdateJob.perform_later(period, date, force_update: true) + end end private def poison_cutoff_in_future?(cutoff) diff --git a/test/models/concerns/heartbeat_poisoning_test.rb b/test/models/concerns/heartbeat_poisoning_test.rb index 4c1c24b09..2ef6535d6 100644 --- a/test/models/concerns/heartbeat_poisoning_test.rb +++ b/test/models/concerns/heartbeat_poisoning_test.rb @@ -140,6 +140,41 @@ def build_heartbeat(time, project) assert_not_nil LeaderboardEntry.find_by(id: other.id) end + test "poisoning leaves historical leaderboard entries alone" do + old_board = Leaderboard.create!(start_date: Date.current - 90, period_type: :daily) + historical = LeaderboardEntry.create!(leaderboard: old_board, user: @user, total_seconds: 900) + + @user.apply_poison!(@cutoff) + + assert_not_nil LeaderboardEntry.find_by(id: historical.id) + end + + test "unbanning does not delete historical leaderboard entries" do + old_board = Leaderboard.create!(start_date: Date.current - 90, period_type: :daily) + historical = LeaderboardEntry.create!(leaderboard: old_board, user: @user, total_seconds: 900) + @user.apply_poison!(@cutoff) + + @user.remove_poison! + + assert_not_nil LeaderboardEntry.find_by(id: historical.id) + end + + test "poison changes enqueue leaderboard rebuilds for the current boards" do + clear_enqueued_jobs + + @user.apply_poison!(@cutoff) + + Leaderboard::REBUILDABLE_PERIODS.each do |period| + assert_enqueued_with job: LeaderboardUpdateJob, + args: [ period, LeaderboardDateRange.normalize_date(Date.current, period), { force_update: true } ] + end + + clear_enqueued_jobs + @user.remove_poison! + + assert_enqueued_jobs Leaderboard::REBUILDABLE_PERIODS.size, only: LeaderboardUpdateJob + end + test "only_poisoned returns exactly the hidden heartbeats" do @user.apply_poison!(@cutoff) From 15a5839d58767ca1ff047ad4e752acc70b334714 Mon Sep 17 00:00:00 2001 From: Scooter Date: Fri, 4 Sep 2026 13:05:56 +0300 Subject: [PATCH 7/8] deleted board can be selected Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- app/models/user.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/user.rb b/app/models/user.rb index fbe5b2e4b..519bd820d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -274,7 +274,7 @@ def remove_poison! private def discard_stale_leaderboard_entries! Leaderboard::REBUILDABLE_PERIODS.each do |period| date = LeaderboardDateRange.normalize_date(Date.current, period) - board = Leaderboard.find_by(start_date: date, period_type: period, timezone_utc_offset: nil) + board = Leaderboard.find_by(start_date: date, period_type: period, timezone_utc_offset: nil, deleted_at: nil) LeaderboardEntry.where(user_id: id, leaderboard_id: board.id).delete_all if board LeaderboardUpdateJob.perform_later(period, date, force_update: true) From 603fdd03afb23e18987c681210c525c7ed31623c Mon Sep 17 00:00:00 2001 From: Scooter Date: Fri, 4 Sep 2026 13:11:45 +0300 Subject: [PATCH 8/8] be within spec for dates --- .../api/admin/v1/bans_controller.rb | 14 +++++++++---- .../api/admin/v1/bans_controller_test.rb | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/app/controllers/api/admin/v1/bans_controller.rb b/app/controllers/api/admin/v1/bans_controller.rb index 6aa53ffaf..a9a81193a 100644 --- a/app/controllers/api/admin/v1/bans_controller.rb +++ b/app/controllers/api/admin/v1/bans_controller.rb @@ -2,6 +2,8 @@ module Api module Admin module V1 class BansController < Api::Admin::V1::ApplicationController + MAX_RAW_DATE_LENGTH = 40 + before_action :require_superadmin before_action :set_user @@ -9,7 +11,7 @@ def create cutoff = ban_date return render_error("date is required") if cutoff.blank? - @user.apply_poison!(cutoff, reason: params[:reason]) + @user.apply_poison!(cutoff, reason: ban_params[:reason]) render json: { success: true, @@ -55,12 +57,16 @@ def set_user render_not_found_json("User not found") unless @user end + def ban_params = params.permit(:date, :end_date, :reason) + def ban_date - return params[:date] if params[:date].present? - return params[:end_date] if params[:end_date].present? + permitted = ban_params + return permitted[:date] if permitted[:date].present? + return permitted[:end_date] if permitted[:end_date].present? raw = request.raw_post.to_s.strip - raw.presence unless raw.start_with?("{", "[") + return if raw.blank? || raw.length > MAX_RAW_DATE_LENGTH + raw unless raw.start_with?("{", "[") end def hidden_heartbeat_count = Heartbeat.only_poisoned.where(user_id: @user.id).count diff --git a/test/controllers/api/admin/v1/bans_controller_test.rb b/test/controllers/api/admin/v1/bans_controller_test.rb index f97bd7f81..0046ba012 100644 --- a/test/controllers/api/admin/v1/bans_controller_test.rb +++ b/test/controllers/api/admin/v1/bans_controller_test.rb @@ -161,6 +161,26 @@ def auth_headers(key = @key) end + test "ignores unpermitted parameters in the request body" do + post "/api/admin/v1/ban/#{@user.id}", + params: { date: "2026-03-01", reason: "Fraud!", poisoned_until: "2030-01-01", admin_level: "ultraadmin" }, + headers: auth_headers, as: :json + + assert_response :created + @user.reload + assert_equal @cutoff + 1.day, @user.poisoned_until.utc + assert_equal "Fraud!", @user.poison_reason + assert_not @user.admin_level_ultraadmin? + end + + test "rejects an oversized raw body instead of parsing it" do + post "/api/admin/v1/ban/#{@user.id}", params: "x" * 500, + headers: auth_headers.merge("Content-Type" => "text/plain") + + assert_response :unprocessable_entity + assert_not @user.reload.poisoned? + end + test "requires an api key" do post "/api/admin/v1/ban/#{@user.id}", params: { date: "2026-03-01" }, as: :json