From fbfb220d5b7421109dbf81fc5e4e769f40ab16ac Mon Sep 17 00:00:00 2001 From: ntxtthomas Date: Mon, 30 Mar 2026 21:02:58 -0500 Subject: [PATCH 1/3] test fixes --- config/routes.rb | 4 +- .../attendance_entries_update_destroy_spec.rb | 2 +- spec/integration/auth_spec.rb | 131 +++++++++ .../api/v1/attendance_entries_spec.rb | 18 +- swagger/v1/swagger.yaml | 267 ++++++++++-------- 5 files changed, 287 insertions(+), 135 deletions(-) create mode 100644 spec/integration/auth_spec.rb diff --git a/config/routes.rb b/config/routes.rb index a717681..ef5d442 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,7 +2,9 @@ namespace :api do namespace :v1 do resource :me, only: :show, controller: :me - resources :attendance_entries, only: %i[index create update destroy] + resources :attendance_entries, only: %i[index create destroy] do + member { put :update, action: :update } + end end end devise_for :users, controllers: { sessions: 'users/sessions' } diff --git a/spec/integration/attendance_entries_update_destroy_spec.rb b/spec/integration/attendance_entries_update_destroy_spec.rb index 31056f4..fe75535 100644 --- a/spec/integration/attendance_entries_update_destroy_spec.rb +++ b/spec/integration/attendance_entries_update_destroy_spec.rb @@ -2,7 +2,7 @@ RSpec.describe 'AttendanceEntries API', type: :request do path '/api/v1/attendance_entries/{id}' do - patch 'Update an attendance entry' do + put 'Update an attendance entry' do tags 'AttendanceEntries' consumes 'application/json' produces 'application/json' diff --git a/spec/integration/auth_spec.rb b/spec/integration/auth_spec.rb new file mode 100644 index 0000000..2af06b1 --- /dev/null +++ b/spec/integration/auth_spec.rb @@ -0,0 +1,131 @@ +require 'swagger_helper' + +RSpec.describe 'Auth API', type: :request do + path '/users/sign_in' do + post 'Sign in and receive JWT' do + tags 'Auth' + consumes 'application/json' + produces 'application/json' + parameter name: :user, in: :body, schema: { + type: :object, + properties: { + user: { + type: :object, + properties: { + email: { type: :string, format: :email }, + password: { type: :string } + }, + required: %w[email password] + } + }, + required: ['user'] + } + + response '200', 'signed in' do + schema type: :object, + properties: { + token: { type: :string }, + user: { + type: :object, + properties: { + id: { type: :integer }, + email: { type: :string, format: :email } + }, + required: %w[id email] + } + }, + required: %w[token user] + + let(:existing_user) { create(:user, email: 'auth@example.com', password: 'password') } + let(:user) { { user: { email: existing_user.email, password: 'password' } } } + + run_test! + end + + response '401', 'unauthorized' do + schema type: :object, + properties: { + error: { type: :string }, + code: { type: :string, example: 'unauthorized' } + }, + required: %w[error code] + + let(:user) { { user: { email: 'auth@example.com', password: 'wrong' } } } + + run_test! + end + end + end + + path '/users/sign_out' do + delete 'Revoke current JWT' do + tags 'Auth' + produces 'application/json' + parameter name: :Authorization, in: :header, type: :string, required: false, description: 'Bearer token' + + response '204', 'signed out' do + let(:auth_user) { create(:user, email: 'logout@example.com', password: 'password') } + let(:Authorization) do + post '/users/sign_in', params: { user: { email: auth_user.email, password: 'password' } }, as: :json + "Bearer #{response.parsed_body.fetch('token')}" + end + + run_test! + end + + response '401', 'unauthorized' do + schema type: :object, + properties: { + error: { type: :string }, + code: { type: :string, example: 'unauthorized' } + }, + required: %w[error code] + + let(:Authorization) { nil } + + run_test! + end + end + end + + path '/api/v1/me' do + get 'Get current authenticated user' do + tags 'Auth' + produces 'application/json' + parameter name: :Authorization, in: :header, type: :string, required: true, description: 'Bearer token' + + response '200', 'current user' do + schema type: :object, + properties: { + user: { + type: :object, + properties: { + id: { type: :integer }, + email: { type: :string, format: :email } + }, + required: %w[id email] + } + }, + required: ['user'] + + let(:me_user) { create(:user, email: 'me@example.com') } + let(:Authorization) { "Bearer #{Warden::JWTAuth::UserEncoder.new.call(me_user, :user, nil).first}" } + + run_test! + end + + response '401', 'unauthorized' do + schema type: :object, + properties: { + error: { type: :string }, + code: { type: :string, example: 'unauthorized' } + }, + required: %w[error code] + + let(:Authorization) { nil } + + run_test! + end + end + end +end diff --git a/spec/requests/api/v1/attendance_entries_spec.rb b/spec/requests/api/v1/attendance_entries_spec.rb index 05f75d8..cb1b6cb 100644 --- a/spec/requests/api/v1/attendance_entries_spec.rb +++ b/spec/requests/api/v1/attendance_entries_spec.rb @@ -31,25 +31,25 @@ def auth_headers(user) end end - describe "PATCH /api/v1/attendance_entries/:id" do + describe "PUT /api/v1/attendance_entries/:id" do let!(:entry) { create(:attendance_entry, user: user, student_name: "Old") } let!(:other_entry) { create(:attendance_entry, user: other_user) } it "updates an entry owned by the user" do - patch "/api/v1/attendance_entries/#{entry.id}", - params: { attendance_entry: { student_name: "New" } }, - headers: auth_headers(user), - as: :json + put "/api/v1/attendance_entries/#{entry.id}", + params: { attendance_entry: { student_name: "New" } }, + headers: auth_headers(user), + as: :json expect(response).to have_http_status(:ok) expect(entry.reload.student_name).to eq("New") end it "returns 404 when trying to update someone else's entry" do - patch "/api/v1/attendance_entries/#{other_entry.id}", - params: { attendance_entry: { student_name: "Hax" } }, - headers: auth_headers(user), - as: :json + put "/api/v1/attendance_entries/#{other_entry.id}", + params: { attendance_entry: { student_name: "Hax" } }, + headers: auth_headers(user), + as: :json expect(response).to have_http_status(:not_found) end diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index 4815359..83861e6 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -4,115 +4,6 @@ info: title: API V1 version: v1 paths: - "/users/sign_in": - post: - summary: Sign in and receive JWT - tags: - - Auth - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - user: - type: object - properties: - email: - type: string - format: email - password: - type: string - required: - - email - - password - required: - - user - responses: - '200': - description: signed in - content: - application/json: - schema: - type: object - properties: - token: - type: string - user: - type: object - properties: - id: - type: integer - email: - type: string - format: email - required: - - token - - user - '401': - description: unauthorized - content: - application/json: - schema: - "$ref": "#/components/schemas/UnauthorizedError" - "/users/sign_out": - delete: - summary: Revoke current JWT - tags: - - Auth - parameters: - - name: Authorization - in: header - required: true - description: Bearer token - schema: - type: string - responses: - '204': - description: signed out - '401': - description: unauthorized - content: - application/json: - schema: - "$ref": "#/components/schemas/UnauthorizedError" - "/api/v1/me": - get: - summary: Get current authenticated user - tags: - - Auth - parameters: - - name: Authorization - in: header - required: true - description: Bearer token - schema: - type: string - responses: - '200': - description: current user - content: - application/json: - schema: - type: object - properties: - user: - type: object - properties: - id: - type: integer - email: - type: string - format: email - required: - - user - '401': - description: unauthorized - content: - application/json: - schema: - "$ref": "#/components/schemas/UnauthorizedError" "/api/v1/attendance_entries": get: summary: List attendance entries @@ -190,7 +81,7 @@ paths: - student_name - status "/api/v1/attendance_entries/{id}": - patch: + put: summary: Update an attendance entry tags: - AttendanceEntries @@ -250,22 +141,150 @@ paths: description: unauthorized '404': description: not found + "/users/sign_in": + post: + summary: Sign in and receive JWT + tags: + - Auth + parameters: [] + responses: + '200': + description: signed in + content: + application/json: + schema: + type: object + properties: + token: + type: string + user: + type: object + properties: + id: + type: integer + email: + type: string + format: email + required: + - id + - email + required: + - token + - user + '401': + description: unauthorized + content: + application/json: + schema: + type: object + properties: + error: + type: string + code: + type: string + example: unauthorized + required: + - error + - code + requestBody: + content: + application/json: + schema: + type: object + properties: + user: + type: object + properties: + email: + type: string + format: email + password: + type: string + required: + - email + - password + required: + - user + "/users/sign_out": + delete: + summary: Revoke current JWT + tags: + - Auth + parameters: + - name: Authorization + in: header + required: false + description: Bearer token + schema: + type: string + responses: + '204': + description: signed out + '401': + description: unauthorized + content: + application/json: + schema: + type: object + properties: + error: + type: string + code: + type: string + example: unauthorized + required: + - error + - code + "/api/v1/me": + get: + summary: Get current authenticated user + tags: + - Auth + parameters: + - name: Authorization + in: header + required: true + description: Bearer token + schema: + type: string + responses: + '200': + description: current user + content: + application/json: + schema: + type: object + properties: + user: + type: object + properties: + id: + type: integer + email: + type: string + format: email + required: + - id + - email + required: + - user + '401': + description: unauthorized + content: + application/json: + schema: + type: object + properties: + error: + type: string + code: + type: string + example: unauthorized + required: + - error + - code servers: - url: https://{defaultHost} variables: defaultHost: default: www.example.com -components: - schemas: - UnauthorizedError: - type: object - properties: - error: - type: string - example: Invalid Email or password. - code: - type: string - example: unauthorized - required: - - error - - code From d33eafcac9c481ef98421e196da2d0a212d07bff Mon Sep 17 00:00:00 2001 From: ntxtthomas Date: Mon, 30 Mar 2026 21:05:40 -0500 Subject: [PATCH 2/3] stop tracking .rspec_status --- .gitignore | 2 -- .rspec_status | 38 -------------------------------------- 2 files changed, 40 deletions(-) delete mode 100644 .rspec_status diff --git a/.gitignore b/.gitignore index d069c7b..db64792 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ *.rbc capybara-*.html .rspec -.rspec_status /db/*.sqlite3 /db/*.sqlite3-journal /db/*.sqlite3-[0-9]* @@ -45,4 +44,3 @@ node_modules/ /storage/* !/storage/.keep /public/uploads -.rspec_status diff --git a/.rspec_status b/.rspec_status deleted file mode 100644 index 7bdd7bd..0000000 --- a/.rspec_status +++ /dev/null @@ -1,38 +0,0 @@ -example_id | status | run_time | ------------------------------------------------------------------------ | ------- | --------------- | -./spec/integration/attendance_entries_get_spec.rb[1:1:1:1:1] | passed | 0.02298 seconds | -./spec/integration/attendance_entries_get_spec.rb[1:1:1:2:1] | passed | 0.00291 seconds | -./spec/integration/attendance_entries_post_spec.rb[1:1:1:1:1] | passed | 0.01303 seconds | -./spec/integration/attendance_entries_post_spec.rb[1:1:1:2:1] | passed | 0.26575 seconds | -./spec/integration/attendance_entries_post_spec.rb[1:1:1:3:1] | passed | 0.04712 seconds | -./spec/integration/attendance_entries_update_destroy_spec.rb[1:1:1:1:1] | passed | 0.01255 seconds | -./spec/integration/attendance_entries_update_destroy_spec.rb[1:1:1:2:1] | passed | 0.00317 seconds | -./spec/integration/attendance_entries_update_destroy_spec.rb[1:1:1:3:1] | passed | 0.00913 seconds | -./spec/integration/attendance_entries_update_destroy_spec.rb[1:2:1:1:1] | passed | 0.01171 seconds | -./spec/integration/attendance_entries_update_destroy_spec.rb[1:2:1:2:1] | passed | 0.0031 seconds | -./spec/integration/attendance_entries_update_destroy_spec.rb[1:2:1:3:1] | passed | 0.00935 seconds | -./spec/models/attendance_entry_spec.rb[1:1:1] | passed | 0.00203 seconds | -./spec/models/attendance_entry_spec.rb[1:1:2] | passed | 0.004 seconds | -./spec/models/attendance_entry_spec.rb[1:1:3:1] | passed | 0.00623 seconds | -./spec/models/attendance_entry_spec.rb[1:1:4] | passed | 0.00643 seconds | -./spec/models/attendance_entry_spec.rb[1:1:5] | passed | 0.00716 seconds | -./spec/models/attendance_entry_spec.rb[1:1:6] | passed | 0.0016 seconds | -./spec/models/attendance_entry_spec.rb[1:1:7] | passed | 0.00148 seconds | -./spec/models/jwt_denylist_spec.rb[1:1] | pending | 0 seconds | -./spec/models/user_spec.rb[1:1] | pending | 0.00002 seconds | -./spec/requests/api/v1/attendance_entries_spec.rb[1:1:1] | passed | 0.02612 seconds | -./spec/requests/api/v1/attendance_entries_spec.rb[1:1:2] | passed | 0.01484 seconds | -./spec/requests/api/v1/attendance_entries_spec.rb[1:2:1] | passed | 0.01972 seconds | -./spec/requests/api/v1/attendance_entries_spec.rb[1:2:2] | passed | 0.01551 seconds | -./spec/requests/api/v1/attendance_entries_spec.rb[1:3:1] | passed | 0.01912 seconds | -./spec/requests/api/v1/attendance_entries_spec.rb[1:3:2] | passed | 0.01586 seconds | -./spec/requests/api/v1/attendance_entries_spec.rb[2:1] | passed | 0.01864 seconds | -./spec/requests/api/v1/attendance_entries_spec.rb[2:2] | passed | 0.01045 seconds | -./spec/requests/api/v1/me_spec.rb[1:1:1] | passed | 0.00842 seconds | -./spec/requests/api/v1/me_spec.rb[1:1:2] | passed | 0.00357 seconds | -./spec/requests/api/v1/sessions_spec.rb[1:1:1] | passed | 0.01037 seconds | -./spec/requests/api/v1/sessions_spec.rb[1:1:2] | passed | 0.01094 seconds | -./spec/requests/api/v1/sessions_spec.rb[1:1:3] | passed | 0.01727 seconds | -./spec/requests/api/v1/sessions_spec.rb[1:2:1] | passed | 0.01432 seconds | -./spec/requests/api/v1/sessions_spec.rb[1:2:2] | passed | 0.02521 seconds | -./spec/requests/api/v1/sessions_spec.rb[1:2:3] | passed | 0.00403 seconds | From b8091403a308ac10ad1c2633d052cf8066fac655 Mon Sep 17 00:00:00 2001 From: ntxtthomas Date: Mon, 30 Mar 2026 21:21:21 -0500 Subject: [PATCH 3/3] adjusting for payload structure --- .gitignore | 1 + app/controllers/users/sessions_controller.rb | 31 +++++++++++++++++--- config/initializers/cors.rb | 12 ++++++++ spec/requests/api/v1/sessions_spec.rb | 12 ++++++++ 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index db64792..735074d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ *.rbc capybara-*.html .rspec +.rspec_status /db/*.sqlite3 /db/*.sqlite3-journal /db/*.sqlite3-[0-9]* diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index b85ac43..c4a2ca4 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -2,6 +2,8 @@ module Users class SessionsController < Devise::SessionsController respond_to :json + before_action :set_json_format + prepend_before_action only: :destroy do unless warden.authenticated?(:user) render json: { error: "Unauthorized", code: "unauthorized" }, status: :unauthorized @@ -9,15 +11,36 @@ class SessionsController < Devise::SessionsController end def create - resource = warden.authenticate!(auth_options) - sign_in(resource_name, resource) - token, = Warden::JWTAuth::UserEncoder.new.call(resource, :user, nil) - render json: { token: token, user: { id: resource.id, email: resource.email } }, status: :ok + credentials = sign_in_params + resource = User.find_for_database_authentication(email: credentials[:email]) + + if resource&.valid_password?(credentials[:password]) + sign_in(resource_name, resource) + token, = Warden::JWTAuth::UserEncoder.new.call(resource, :user, nil) + render json: { token: token, user: { id: resource.id, email: resource.email } }, status: :ok + else + render json: { error: "Invalid Email or password.", code: "unauthorized" }, status: :unauthorized + end end def destroy sign_out(current_user) head :no_content end + + private + + def set_json_format + request.format = :json + end + + def sign_in_params + source = params[:user].presence || params + + { + email: source[:email].to_s.strip.downcase, + password: source[:password].to_s + } + end end end diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index d2c1a19..2607247 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -11,6 +11,18 @@ origins = ENV["CORS_ALLOWED_ORIGINS"]&.split(",") || ["http://localhost:5174"] origins origins + resource "/users/sign_in", + headers: :any, + methods: %i[get post put patch delete options head], + credentials: false, + max_age: 600 + + resource "/users/sign_out", + headers: :any, + methods: %i[get post put patch delete options head], + credentials: false, + max_age: 600 + resource "/api/*", headers: :any, methods: %i[get post put patch delete options head], diff --git a/spec/requests/api/v1/sessions_spec.rb b/spec/requests/api/v1/sessions_spec.rb index a1a43e9..b88bba1 100644 --- a/spec/requests/api/v1/sessions_spec.rb +++ b/spec/requests/api/v1/sessions_spec.rb @@ -21,6 +21,18 @@ def sign_in_user(user) expect(token).to be_present end + it "accepts a flat payload from the frontend" do + post "/users/sign_in", + params: { email: user.email, password: user.password }, + as: :json + + expect(response).to have_http_status(:ok) + expect(response.parsed_body).to include( + "token" => a_kind_of(String), + "user" => include("email" => user.email) + ) + end + it "returns unauthorized on bad credentials" do post "/users/sign_in", params: { user: { email: user.email, password: "wrong" } },