diff --git a/app/controllers/api/summary_controller.rb b/app/controllers/api/summary_controller.rb index afc817dc1..1d0f8cde3 100644 --- a/app/controllers/api/summary_controller.rb +++ b/app/controllers/api/summary_controller.rb @@ -1,5 +1,7 @@ module Api class SummaryController < ApplicationController + include DateParsing + skip_before_action :verify_authenticity_token before_action :set_user @@ -10,6 +12,7 @@ def index params[:from] || params[:start], params[:to] || params[:end] ) + return if performed? return render_bad_request("Invalid date range") unless date_range service = WakatimeService.new( @@ -54,11 +57,13 @@ def determine_date_range(interval, range, from_date, to_date) Time.use_zone("UTC") do now = Time.current - if from_date.present? && to_date.present? - from = parse_explicit_date(from_date, boundary: :start) - to = parse_explicit_date(to_date, boundary: :end) - return nil if from.nil? || to.nil? - return from..to + if from_date.present? || to_date.present? + return parse_date_range( + start_value: from_date, + end_value: to_date, + start_default: nil, + end_default: nil + ) { |value, boundary| parse_explicit_date(value, boundary:) } end case (interval || range) diff --git a/app/controllers/api/v1/authenticated/hours_controller.rb b/app/controllers/api/v1/authenticated/hours_controller.rb index a96e3fe80..61173c869 100644 --- a/app/controllers/api/v1/authenticated/hours_controller.rb +++ b/app/controllers/api/v1/authenticated/hours_controller.rb @@ -2,11 +2,22 @@ module Api module V1 module Authenticated class HoursController < ApplicationController + include RenderHelpers + include DateParsing + require_oauth_scope :read def index - start_date = params[:start_date]&.to_date || 7.days.ago.to_date - end_date = params[:end_date]&.to_date || Date.current + date_range = parse_date_range( + start_value: params[:start_date], + end_value: params[:end_date], + start_default: 7.days.ago.to_date, + end_default: Date.current + ) { |value| value.to_s.to_date } + return unless date_range + + start_date = date_range.begin + end_date = date_range.end total_seconds = current_user.heartbeats .where(time: start_date.beginning_of_day.to_i..end_date.end_of_day.to_i) diff --git a/app/controllers/api/v1/my/heartbeats_controller.rb b/app/controllers/api/v1/my/heartbeats_controller.rb index 179ac223e..abb234d09 100644 --- a/app/controllers/api/v1/my/heartbeats_controller.rb +++ b/app/controllers/api/v1/my/heartbeats_controller.rb @@ -1,5 +1,7 @@ class Api::V1::My::HeartbeatsController < ApplicationController include ActionView::Helpers::DateHelper + include DateParsing + before_action :ensure_authenticated! def most_recent @@ -24,8 +26,16 @@ def most_recent end def index - start_time = params[:start_time].present? ? Time.parse(params[:start_time]) : Time.current.beginning_of_day - end_time = params[:end_time].present? ? Time.parse(params[:end_time]) : Time.current.end_of_day + time_range = parse_date_range( + start_value: params[:start_time], + end_value: params[:end_time], + start_default: Time.current.beginning_of_day, + end_default: Time.current.end_of_day + ) { |value| Time.parse(value) } + return unless time_range + + start_time = time_range.begin + end_time = time_range.end heartbeats = current_user.heartbeats .where("time >= ? AND time <= ?", start_time.to_f, end_time.to_f) diff --git a/app/controllers/api/v1/stats_controller.rb b/app/controllers/api/v1/stats_controller.rb index 8161faa22..94b3d3bb5 100644 --- a/app/controllers/api/v1/stats_controller.rb +++ b/app/controllers/api/v1/stats_controller.rb @@ -1,4 +1,6 @@ class Api::V1::StatsController < ApplicationController + include DateParsing + USER_LOOKUP_ACTIONS = [ :user_stats, :user_spans, :user_projects, :user_project, :user_projects_details ].freeze before_action :authenticate_admin_api_key!, only: [ :show ], unless: -> { Rails.env.development? } @@ -7,12 +9,18 @@ class Api::V1::StatsController < ApplicationController def show # take either user_id with a start date & end date - start_date = parse_date_param(:start_date, default: 10.years.ago, boundary: :start) - return if performed? - end_date = parse_date_param(:end_date, default: Date.today.end_of_day, boundary: :end) - return if performed? + date_range = parse_date_range( + start_value: params[:start_date], + end_value: params[:end_date], + start_default: 10.years.ago, + end_default: Date.today.end_of_day + ) do |value, boundary| + date = Date.iso8601(value) + boundary == :start ? date.beginning_of_day : date.end_of_day + end + return unless date_range - query = Heartbeat.where(time: start_date..end_date) + query = Heartbeat.where(time: date_range) if params[:username].present? user = User.lookup_by_identifier(params[:username]) @@ -31,10 +39,10 @@ def show def user_stats # Used by the github stats page feature - start_date = parse_datetime_param(:start_date, default: 10.years.ago) - return if performed? - end_date = parse_datetime_param(:end_date, default: Date.today.end_of_day) - return if performed? + date_range = default_datetime_range + return unless date_range + start_date = date_range.begin + end_date = date_range.end # /api/v1/users/current/stats?filter_by_project=harbor,high-seas filter_by_projects = params[:filter_by_project].presence&.split(",") @@ -113,10 +121,10 @@ def user_stats end def user_spans - start_date = parse_datetime_param(:start_date, default: 10.years.ago) - return if performed? - end_date = parse_datetime_param(:end_date, default: Date.today.end_of_day) - return if performed? + date_range = default_datetime_range + return unless date_range + start_date = date_range.begin + end_date = date_range.end heartbeats = @user.heartbeats.where(time: start_date.to_f..end_date.to_f) heartbeats = heartbeats.where(project: params[:project]) if params[:project].present? @@ -148,11 +156,19 @@ def banned_users_counts end def user_projects + return unless parse_date_range( + start_value: params[:since].presence || params[:start].presence || params[:start_date], + end_value: params[:until].presence || params[:until_date].presence || params[:end].presence || params[:end_date], + start_default: 30.days.ago.beginning_of_day, + end_default: Time.current + ) { |value| value.to_datetime } + render json: { projects: project_stats_query(include_archived: true).project_names } end def user_project return render_bad_request("whats the name?") unless params[:project_name].present? + return unless project_stats_date_range project_data = project_stats_query.project_details(names: [ params[:project_name] ]).first return render_not_found_json("found nuthin") unless project_data @@ -160,6 +176,8 @@ def user_project end def user_projects_details + return unless project_stats_date_range + render json: { projects: project_stats_query.project_details(names: params[:projects]&.split(",")&.map(&:strip)) } end @@ -214,22 +232,21 @@ def unique_heartbeat_seconds(heartbeats) total_seconds.to_i end - def parse_date_param(param_name, default:, boundary:) - raw_value = params[param_name] - return default if raw_value.blank? - parsed_date = Date.iso8601(raw_value) - boundary == :start ? parsed_date.beginning_of_day : parsed_date.end_of_day - rescue ArgumentError, Date::Error, TypeError - render_error("Invalid #{param_name}") + def default_datetime_range + parse_date_range( + start_value: params[:start_date], + end_value: params[:end_date], + start_default: 10.years.ago, + end_default: Date.today.end_of_day + ) { |value| Time.zone.parse(value.to_s) } end - def parse_datetime_param(param_name, default:) - raw_value = params[param_name] - return default if raw_value.blank? - parsed_time = Time.zone.parse(raw_value.to_s) - raise ArgumentError if parsed_time.nil? - parsed_time - rescue ArgumentError, TypeError - render_error("Invalid #{param_name}") + def project_stats_date_range + parse_date_range( + start_value: params[:start].presence || params[:start_date], + end_value: params[:end].presence || params[:end_date], + start_default: 1.year.ago.to_datetime, + end_default: Time.current.to_datetime + ) { |value| value.to_datetime } end end diff --git a/app/controllers/concerns/date_parsing.rb b/app/controllers/concerns/date_parsing.rb index 396a1b382..9f16e1970 100644 --- a/app/controllers/concerns/date_parsing.rb +++ b/app/controllers/concerns/date_parsing.rb @@ -2,10 +2,23 @@ # Shared timestamp/date parsing helpers used by API endpoints that accept # `start_date`/`end_date` (or arbitrary date params). -# Hosts must also include RenderHelpers (or define render_error). +# Hosts must also include RenderHelpers or provide equivalent render methods. module DateParsing extend ActiveSupport::Concern + # Parses and validates a pair of date/time values while allowing each endpoint + # to retain its existing parser and defaults. + def parse_date_range(start_value:, end_value:, start_default:, end_default:) + start_at = start_value.present? ? yield(start_value, :start) : start_default + end_at = end_value.present? ? yield(end_value, :end) : end_default + + return render_bad_request("Invalid date range") if start_at.nil? || end_at.nil? || end_at < start_at + + start_at..end_at + rescue Date::Error, ArgumentError, TypeError + render_bad_request("Invalid date range") + end + # Parses start_date/end_date params with defaults; returns a Range[start_ts..end_ts] # or nil after rendering an error. def parse_default_time_range diff --git a/spec/requests/api/summary_spec.rb b/spec/requests/api/summary_spec.rb index 125f950d6..1948e7656 100644 --- a/spec/requests/api/summary_spec.rb +++ b/spec/requests/api/summary_spec.rb @@ -81,7 +81,7 @@ run_test! end - response(400, 'invalid date range') do + response(400, 'invalid, incomplete or reversed date range') do let(:date_test_user) { create(:user, slack_uid: "UDATE#{SecureRandom.hex(4)}", timezone: 'UTC', allow_public_stats_lookup: true) } let(:Authorization) { "Bearer dev-api-key-12345" } let(:api_key) { "dev-api-key-12345" } diff --git a/spec/requests/api/v1/authenticated_spec.rb b/spec/requests/api/v1/authenticated_spec.rb index d27ff95ae..cfdd1281b 100644 --- a/spec/requests/api/v1/authenticated_spec.rb +++ b/spec/requests/api/v1/authenticated_spec.rb @@ -52,8 +52,8 @@ security [ { OAuth2: [ 'read' ] } ] produces 'application/json' - parameter name: :start_date, in: :query, schema: { type: :string, format: :date }, description: 'Start date (YYYY-MM-DD)' - parameter name: :end_date, in: :query, schema: { type: :string, format: :date }, description: 'End date (YYYY-MM-DD)' + parameter name: :start_date, in: :query, schema: { type: :string, format: :date }, description: 'Start date (YYYY-MM-DD), defaults to 7 days ago' + parameter name: :end_date, in: :query, schema: { type: :string, format: :date }, description: 'End date (YYYY-MM-DD), defaults to today' response(200, 'successful') do before { Doorkeeper::AccessToken.by_token('dev-api-key-12345').update!(scopes: 'read') } @@ -70,6 +70,16 @@ run_test! end + response(400, 'invalid or reversed date range') do + before { Doorkeeper::AccessToken.by_token('dev-api-key-12345').update!(scopes: 'read') } + + let(:Authorization) { "Bearer dev-api-key-12345" } + let(:start_date) { 'not-a-date' } + let(:end_date) { Date.today.to_s } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + response(403, 'insufficient scope') do let(:Authorization) { "Bearer dev-api-key-12345" } let(:start_date) { 7.days.ago.to_date.to_s } diff --git a/spec/requests/api/v1/my_spec.rb b/spec/requests/api/v1/my_spec.rb index 443cb75a9..de8d081b3 100644 --- a/spec/requests/api/v1/my_spec.rb +++ b/spec/requests/api/v1/my_spec.rb @@ -89,6 +89,14 @@ def login_browser_user run_test! end + response(400, 'invalid or reversed date range') do + let(:Authorization) { "Bearer dev-api-key-12345" } + let(:start_time) { 'not-a-date' } + let(:end_time) { Time.now.iso8601 } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + response(401, 'unauthorized — Returned when the Authorization header is missing, the OAuth token lacks the read scope, or the token is invalid.') do let(:Authorization) { 'Bearer invalid' } let(:start_time) { 1.day.ago.iso8601 } diff --git a/spec/requests/api/v1/stats_spec.rb b/spec/requests/api/v1/stats_spec.rb index 99d1ce25a..ed3eb16a7 100644 --- a/spec/requests/api/v1/stats_spec.rb +++ b/spec/requests/api/v1/stats_spec.rb @@ -6,7 +6,6 @@ tags 'Stats' description 'Returns the total coding time for all users, optionally filtered by user or date range. Requires an active Admin API Key supplied via the Bearer header.' security [ { Bearer: [] } ] - produces 'text/plain' parameter name: :start_date, in: :query, schema: { type: :string, format: :date }, description: 'Start date (YYYY-MM-DD), defaults to 10 years ago' parameter name: :end_date, in: :query, schema: { type: :string, format: :date }, description: 'End date (YYYY-MM-DD), defaults to today' @@ -21,8 +20,10 @@ let(:username) { nil } let(:user_email) { nil } schema type: :integer, example: 123456 + metadata[:response][:content] = { 'text/plain' => { schema: metadata[:response][:schema] } } run_test! do |response| expect(response).to have_http_status(:ok) + expect(response.media_type).to eq('text/plain') expect(response.body.to_i).to be >= 0 end end @@ -35,7 +36,10 @@ let(:username) { nil } let(:user_email) { nil } schema '$ref' => '#/components/schemas/Error' - run_test! + metadata[:response][:content] = { 'application/json' => { schema: metadata[:response][:schema] } } + run_test! do |response| + expect(response.media_type).to eq('application/json') + end end response(404, 'user not found') do @@ -46,10 +50,13 @@ let(:username) { 'non_existent_user' } let(:user_email) { nil } schema '$ref' => '#/components/schemas/Error' - run_test! + metadata[:response][:content] = { 'application/json' => { schema: metadata[:response][:schema] } } + run_test! do |response| + expect(response.media_type).to eq('application/json') + end end - response(422, 'invalid date') do + response(400, 'invalid or reversed date range') do let(:Authorization) { "Bearer dev-admin-api-key-12345" } let(:api_key) { nil } let(:start_date) { 'invalid-date' } @@ -57,7 +64,10 @@ let(:username) { nil } let(:user_email) { nil } schema '$ref' => '#/components/schemas/Error' - run_test! + metadata[:response][:content] = { 'application/json' => { schema: metadata[:response][:schema] } } + run_test! do |response| + expect(response.media_type).to eq('application/json') + end end end end @@ -144,7 +154,7 @@ run_test! end - response(422, 'invalid date') do + response(400, 'invalid or reversed date range') do let(:username) { 'testuser' } let(:start_date) { 'invalid-date' } let(:end_date) { '2023-01-02' } @@ -190,9 +200,35 @@ produces 'application/json' parameter name: :username, in: :path, type: :string, description: 'Username, Slack ID, or User ID' + parameter name: :since, in: :query, schema: { type: :string, format: :date_time }, description: 'Start date/time (ISO 8601), defaults to 30 days ago and takes precedence over start and start_date' + parameter name: :start, in: :query, schema: { type: :string, format: :date_time }, description: 'Start date/time alias, takes precedence over start_date' + parameter name: :start_date, in: :query, schema: { type: :string, format: :date_time }, description: 'Start date/time alias' + parameter name: :until, in: :query, schema: { type: :string, format: :date_time }, description: 'End date/time (ISO 8601), defaults to the current time and takes precedence over until_date, end and end_date' + parameter name: :until_date, in: :query, schema: { type: :string, format: :date_time }, description: 'End date/time alias, takes precedence over end and end_date' + parameter name: :end, in: :query, schema: { type: :string, format: :date_time }, description: 'End date/time alias, takes precedence over end_date' + parameter name: :end_date, in: :query, schema: { type: :string, format: :date_time }, description: 'End date/time alias' + + let(:since) { nil } + let(:start) { nil } + let(:start_date) { nil } + let(:until) { nil } + let(:until_date) { nil } + let(:end) { nil } + let(:end_date) { nil } response(200, 'successful') do - let(:username) { 'testuser' } + let!(:projects_user) { create(:user, username: "proj_alias_#{SecureRandom.hex(3)}", allow_public_stats_lookup: true) } + let!(:before_range) { create(:heartbeat, user: projects_user, time: Time.utc(2025, 1, 2).to_f, project: 'before_range') } + let!(:in_range) { create(:heartbeat, user: projects_user, time: Time.utc(2025, 1, 10).to_f, project: 'in_range') } + let!(:after_range) { create(:heartbeat, user: projects_user, time: Time.utc(2025, 1, 20).to_f, project: 'after_range') } + let(:username) { projects_user.username } + let(:since) { '2025-01-05' } + let(:start) { '2025-01-01' } + let(:start_date) { '2024-01-01' } + let(:until) { '2025-01-15' } + let(:until_date) { '2025-01-25' } + let(:end) { '2025-02-01' } + let(:end_date) { '2025-03-01' } schema type: :object, properties: { @@ -201,6 +237,23 @@ items: { type: :string, example: 'hackatime' } } } + run_test! do |response| + expect(response.parsed_body.fetch('projects')).to eq([ 'in_range' ]) + end + end + + response(400, 'invalid or reversed date range') do + let(:username) { 'testuser' } + let(:since) { 'not-a-date' } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + + response(400, 'invalid or reversed date range') do + let(:username) { 'testuser' } + let(:since) { '2025-02-01' } + let(:until) { '2025-01-01' } + schema '$ref' => '#/components/schemas/Error' run_test! end @@ -229,10 +282,10 @@ parameter name: :username, in: :path, type: :string, description: 'Username, Slack ID, or User ID' parameter name: :project_name, in: :path, type: :string, description: 'Project name' - parameter name: :start, in: :query, schema: { type: :string, format: :date_time } - parameter name: :end, in: :query, schema: { type: :string, format: :date_time } - parameter name: :start_date, in: :query, schema: { type: :string, format: :date_time } - parameter name: :end_date, in: :query, schema: { type: :string, format: :date_time } + parameter name: :start, in: :query, schema: { type: :string, format: :date_time }, description: 'Start date/time (ISO 8601), defaults to 1 year ago' + parameter name: :end, in: :query, schema: { type: :string, format: :date_time }, description: 'End date/time (ISO 8601), defaults to the current time' + parameter name: :start_date, in: :query, schema: { type: :string, format: :date_time }, description: 'Alias for start' + parameter name: :end_date, in: :query, schema: { type: :string, format: :date_time }, description: 'Alias for end' response(200, 'successful') do let(:username) { 'testuser' } @@ -257,7 +310,7 @@ run_test! end - response(400, 'bad request — Returned when project_name is blank/whitespace-only.') do + response(400, 'invalid date range or missing project name') do let(:username) { 'testuser' } let(:project_name) { '%20' } let(:start) { nil } @@ -268,6 +321,17 @@ run_test! end + response(400, 'invalid date range or missing project name') do + let(:username) { 'testuser' } + let(:project_name) { 'harbor' } + let(:start) { 'not-a-date' } + let(:end) { nil } + let(:start_date) { nil } + let(:end_date) { nil } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + response(403, 'forbidden — The target user has disabled public stats lookup and the requester is not that user.') do before do create(:user, :with_email, username: 'private_project_user', slack_uid: 'PRIVATE_PROJ_2', allow_public_stats_lookup: false, timezone: 'America/New_York', email: 'private_project@example.com') @@ -306,10 +370,10 @@ parameter name: :since, in: :query, schema: { type: :string, format: :date_time }, description: 'Start time (ISO 8601) for project discovery' parameter name: :until, in: :query, schema: { type: :string, format: :date_time }, description: 'End time (ISO 8601) for project discovery' parameter name: :until_date, in: :query, schema: { type: :string, format: :date_time }, description: 'End time (ISO 8601) for project discovery' - parameter name: :start, in: :query, schema: { type: :string, format: :date_time } - parameter name: :end, in: :query, schema: { type: :string, format: :date_time } - parameter name: :start_date, in: :query, schema: { type: :string, format: :date_time } - parameter name: :end_date, in: :query, schema: { type: :string, format: :date_time } + parameter name: :start, in: :query, schema: { type: :string, format: :date_time }, description: 'Start date/time (ISO 8601), defaults to 1 year ago' + parameter name: :end, in: :query, schema: { type: :string, format: :date_time }, description: 'End date/time (ISO 8601), defaults to the current time' + parameter name: :start_date, in: :query, schema: { type: :string, format: :date_time }, description: 'Alias for start' + parameter name: :end_date, in: :query, schema: { type: :string, format: :date_time }, description: 'Alias for end' response(200, 'successful') do let(:username) { 'testuser' } @@ -345,6 +409,20 @@ run_test! end + response(400, 'invalid or reversed date range') do + let(:username) { 'testuser' } + let(:projects) { nil } + let(:since) { nil } + let(:until) { nil } + let(:until_date) { nil } + let(:start) { 'not-a-date' } + let(:end) { nil } + let(:start_date) { nil } + let(:end_date) { nil } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + response(403, 'forbidden — The target user has disabled public stats lookup and the requester is not that user.') do before do create(:user, :with_email, username: 'private_details_user', slack_uid: 'PRIVATE_DET_1', allow_public_stats_lookup: false, timezone: 'America/New_York', email: 'private_details@example.com') @@ -484,7 +562,7 @@ run_test! end - response(422, 'invalid date') do + response(400, 'invalid or reversed date range') do let(:Authorization) { "Bearer dev-api-key-12345" } let(:api_key) { "dev-api-key-12345" } let(:username) { 'testuser' } diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index a484f3d50..ee74c4977 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -987,7 +987,7 @@ paths: type: object nullable: true '400': - description: invalid date range + description: invalid, incomplete or reversed date range '404': description: user not found '403': @@ -1057,13 +1057,13 @@ paths: schema: type: string format: date - description: Start date (YYYY-MM-DD) + description: Start date (YYYY-MM-DD), defaults to 7 days ago - name: end_date in: query schema: type: string format: date - description: End date (YYYY-MM-DD) + description: End date (YYYY-MM-DD), defaults to today responses: '200': description: successful @@ -1083,6 +1083,12 @@ paths: total_seconds: type: number example: 153000.0 + '400': + description: invalid or reversed date range + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" '403': description: insufficient scope '401': @@ -1626,6 +1632,12 @@ paths: - end_time - total_seconds - heartbeats + '400': + description: invalid or reversed date range + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" '401': description: unauthorized — Returned when the Authorization header is missing, the OAuth token lacks the read scope, or the token is invalid. @@ -1674,19 +1686,19 @@ paths: description: unauthorized — Returned when the Admin API Key is missing, revoked, or incorrect. (Auth is bypassed in the development environment.) content: - text/plain: + application/json: schema: "$ref": "#/components/schemas/Error" '404': description: user not found content: - text/plain: + application/json: schema: "$ref": "#/components/schemas/Error" - '422': - description: invalid date + '400': + description: invalid or reversed date range content: - text/plain: + application/json: schema: "$ref": "#/components/schemas/Error" "/api/v1/banned_users/counts": @@ -1794,8 +1806,8 @@ paths: application/json: schema: "$ref": "#/components/schemas/Error" - '422': - description: invalid date + '400': + description: invalid or reversed date range content: application/json: schema: @@ -1858,6 +1870,50 @@ paths: required: true schema: type: string + - name: since + in: query + schema: + type: string + format: date_time + description: Start date/time (ISO 8601), defaults to 30 days ago and takes + precedence over start and start_date + - name: start + in: query + schema: + type: string + format: date_time + description: Start date/time alias, takes precedence over start_date + - name: start_date + in: query + schema: + type: string + format: date_time + description: Start date/time alias + - name: until + in: query + schema: + type: string + format: date_time + description: End date/time (ISO 8601), defaults to the current time and takes + precedence over until_date, end and end_date + - name: until_date + in: query + schema: + type: string + format: date_time + description: End date/time alias, takes precedence over end and end_date + - name: end + in: query + schema: + type: string + format: date_time + description: End date/time alias, takes precedence over end_date + - name: end_date + in: query + schema: + type: string + format: date_time + description: End date/time alias responses: '200': description: successful @@ -1871,6 +1927,12 @@ paths: items: type: string example: hackatime + '400': + description: invalid or reversed date range + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" '403': description: forbidden — The target user has disabled public stats lookup and the requester is not that user. @@ -1909,21 +1971,25 @@ paths: schema: type: string format: date_time + description: Start date/time (ISO 8601), defaults to 1 year ago - name: end in: query schema: type: string format: date_time + description: End date/time (ISO 8601), defaults to the current time - name: start_date in: query schema: type: string format: date_time + description: Alias for start - name: end_date in: query schema: type: string format: date_time + description: Alias for end responses: '200': description: successful @@ -1973,7 +2039,7 @@ paths: type: boolean example: false '400': - description: bad request — Returned when project_name is blank/whitespace-only. + description: invalid date range or missing project name content: application/json: schema: @@ -2035,21 +2101,25 @@ paths: schema: type: string format: date_time + description: Start date/time (ISO 8601), defaults to 1 year ago - name: end in: query schema: type: string format: date_time + description: End date/time (ISO 8601), defaults to the current time - name: start_date in: query schema: type: string format: date_time + description: Alias for start - name: end_date in: query schema: type: string format: date_time + description: Alias for end responses: '200': description: successful @@ -2103,6 +2173,12 @@ paths: archived: type: boolean example: false + '400': + description: invalid or reversed date range + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" '403': description: forbidden — The target user has disabled public stats lookup and the requester is not that user. @@ -2245,8 +2321,8 @@ paths: application/json: schema: "$ref": "#/components/schemas/Error" - '422': - description: invalid date + '400': + description: invalid or reversed date range content: application/json: schema: diff --git a/test/controllers/api/summary_controller_test.rb b/test/controllers/api/summary_controller_test.rb new file mode 100644 index 000000000..6c675ad79 --- /dev/null +++ b/test/controllers/api/summary_controller_test.rb @@ -0,0 +1,25 @@ +require "test_helper" + +class Api::SummaryControllerTest < ActionDispatch::IntegrationTest + setup do + @user = create(:user, username: "summary_#{SecureRandom.hex(3)}", allow_public_stats_lookup: true) + end + + test "index rejects a malformed one-sided explicit range instead of using the default" do + get "/api/summary", params: { user_id: @user.username, from: "not-a-date" } + + assert_response :bad_request + assert_equal({ "error" => "Invalid date range" }, response.parsed_body) + end + + test "index rejects a reversed explicit range" do + get "/api/summary", params: { + user_id: @user.username, + from: "2025-02-01", + to: "2025-01-01" + } + + assert_response :bad_request + assert_equal({ "error" => "Invalid date range" }, response.parsed_body) + end +end diff --git a/test/controllers/api/v1/authenticated/hours_controller_test.rb b/test/controllers/api/v1/authenticated/hours_controller_test.rb new file mode 100644 index 000000000..db970b052 --- /dev/null +++ b/test/controllers/api/v1/authenticated/hours_controller_test.rb @@ -0,0 +1,51 @@ +require "test_helper" + +class Api::V1::Authenticated::HoursControllerTest < ActionDispatch::IntegrationTest + test "index preserves the documented date defaults" do + user = create(:user) + access_token = create_oauth_access_token(user) + + travel_to Time.zone.local(2026, 9, 1, 12) do + get "/api/v1/authenticated/hours", headers: { "Authorization" => "Bearer #{access_token.token}" } + + assert_response :success + assert_equal "2026-08-25", response.parsed_body.fetch("start_date") + assert_equal "2026-09-01", response.parsed_body.fetch("end_date") + end + end + + test "index rejects a malformed date range" do + user = create(:user) + access_token = create_oauth_access_token(user) + + get "/api/v1/authenticated/hours", + params: { start_date: "not-a-date" }, + headers: { "Authorization" => "Bearer #{access_token.token}" } + + assert_response :bad_request + assert_equal({ "error" => "Invalid date range" }, response.parsed_body) + end + + test "index rejects a reversed date range" do + user = create(:user) + access_token = create_oauth_access_token(user) + + get "/api/v1/authenticated/hours", + params: { start_date: "2025-02-01", end_date: "2025-01-01" }, + headers: { "Authorization" => "Bearer #{access_token.token}" } + + assert_response :bad_request + assert_equal({ "error" => "Invalid date range" }, response.parsed_body) + end + + private + + def create_oauth_access_token(user) + application = create(:oauth_application, owner: user, scopes: "profile read") + create(:oauth_access_token, + application: application, + resource_owner_id: user.id, + scopes: "profile read", + expires_in: 16.years) + end +end diff --git a/test/controllers/api/v1/my/heartbeats_controller_test.rb b/test/controllers/api/v1/my/heartbeats_controller_test.rb index 409d18296..608974cb5 100644 --- a/test/controllers/api/v1/my/heartbeats_controller_test.rb +++ b/test/controllers/api/v1/my/heartbeats_controller_test.rb @@ -73,6 +73,30 @@ class Api::V1::My::HeartbeatsControllerTest < ActionDispatch::IntegrationTest assert_response :unauthorized end + test "index rejects a malformed time range" do + user = create(:user) + api_key = create(:api_key, user: user, name: "test") + + get "/api/v1/my/heartbeats", + params: { start_time: "not-a-date" }, + headers: { "Authorization" => "Bearer #{api_key.token}" } + + assert_response :bad_request + assert_equal({ "error" => "Invalid date range" }, response.parsed_body) + end + + test "index rejects a reversed time range" do + user = create(:user) + api_key = create(:api_key, user: user, name: "test") + + get "/api/v1/my/heartbeats", + params: { start_time: "2025-02-01T00:00:00Z", end_time: "2025-01-01T00:00:00Z" }, + headers: { "Authorization" => "Bearer #{api_key.token}" } + + assert_response :bad_request + assert_equal({ "error" => "Invalid date range" }, response.parsed_body) + end + private def create_oauth_access_token(user, scopes:) diff --git a/test/controllers/api/v1/stats_controller_test.rb b/test/controllers/api/v1/stats_controller_test.rb index f125800f0..786cacbbe 100644 --- a/test/controllers/api/v1/stats_controller_test.rb +++ b/test/controllers/api/v1/stats_controller_test.rb @@ -210,6 +210,74 @@ class Api::V1::StatsControllerTest < ActionDispatch::IntegrationTest assert_response :unauthorized end + test "user_projects preserves discovery date alias precedence" do + user = create(:user, username: "projects_#{SecureRandom.hex(3)}", allow_public_stats_lookup: true) + create_heartbeat(user:, time: Time.utc(2025, 1, 2).to_f, project: "before_range", category: "coding") + create_heartbeat(user:, time: Time.utc(2025, 1, 10).to_f, project: "in_range", category: "coding") + create_heartbeat(user:, time: Time.utc(2025, 1, 20).to_f, project: "after_range", category: "coding") + + requests = [ + { + since: "2025-01-05", start: "2025-01-01", start_date: "2024-01-01", + until: "2025-01-15", until_date: "2025-01-25", end: "2025-02-01", end_date: "2025-03-01" + }, + { + start: "2025-01-05", start_date: "2025-01-01", + until_date: "2025-01-15", end: "2025-02-01", end_date: "2025-03-01" + }, + { start_date: "2025-01-05", end: "2025-01-15", end_date: "2025-03-01" } + ] + + requests.each do |params| + get "/api/v1/users/#{user.username}/projects", params: params + + assert_response :success + assert_equal [ "in_range" ], response.parsed_body.fetch("projects") + end + end + + test "date-filtered endpoints reject malformed ranges consistently" do + user = create(:user, username: "dates_#{SecureRandom.hex(3)}", allow_public_stats_lookup: true) + admin_api_key = create_admin_api_key + + requests = [ + [ "/api/v1/stats", { start_date: "not-a-date" }, { "Authorization" => "Bearer #{admin_api_key.token}" } ], + [ "/api/v1/users/#{user.username}/stats", { start_date: "not-a-date" }, {} ], + [ "/api/v1/users/#{user.username}/heartbeats/spans", { start_date: "not-a-date" }, {} ], + [ "/api/v1/users/#{user.username}/projects", { since: "not-a-date" }, {} ], + [ "/api/v1/users/#{user.username}/project/test", { start: "not-a-date" }, {} ], + [ "/api/v1/users/#{user.username}/projects/details", { start_date: "not-a-date" }, {} ] + ] + + requests.each do |path, params, headers| + get path, params: params, headers: headers + + assert_response :bad_request, path + assert_equal({ "error" => "Invalid date range" }, response.parsed_body, path) + end + end + + test "date-filtered endpoints reject reversed ranges consistently" do + user = create(:user, username: "dates_#{SecureRandom.hex(3)}", allow_public_stats_lookup: true) + admin_api_key = create_admin_api_key + + requests = [ + [ "/api/v1/stats", { start_date: "2025-02-01", end_date: "2025-01-01" }, { "Authorization" => "Bearer #{admin_api_key.token}" } ], + [ "/api/v1/users/#{user.username}/stats", { start_date: "2025-02-01", end_date: "2025-01-01" }, {} ], + [ "/api/v1/users/#{user.username}/heartbeats/spans", { start_date: "2025-02-01", end_date: "2025-01-01" }, {} ], + [ "/api/v1/users/#{user.username}/projects", { since: "2025-02-01", until: "2025-01-01" }, {} ], + [ "/api/v1/users/#{user.username}/project/test", { start: "2025-02-01", end: "2025-01-01" }, {} ], + [ "/api/v1/users/#{user.username}/projects/details", { start: "2025-02-01", end: "2025-01-01" }, {} ] + ] + + requests.each do |path, params, headers| + get path, params: params, headers: headers + + assert_response :bad_request, path + assert_equal({ "error" => "Invalid date range" }, response.parsed_body, path) + end + end + private def create_admin_api_key