From 52aa1615b6c1a33918798dae770da8a1681faf52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= Date: Mon, 4 May 2026 03:37:12 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=E3=83=8B=E3=82=B8=E3=83=A9=E3=83=BC?= =?UTF-8?q?=E8=A9=B3=E7=B4=B0=E3=83=9A=E3=83=BC=E3=82=B8=E4=BD=9C=E6=88=90?= =?UTF-8?q?=20(#63)=20(#341)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #63 #63 #63 #63 Co-authored-by: miteruzo Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/341 --- backend/app/controllers/tags_controller.rb | 50 +++- backend/app/models/tag.rb | 2 + backend/app/representations/tag_repr.rb | 2 +- backend/config/routes.rb | 1 + .../spec/requests/tags_deerjikists_spec.rb | 244 ++++++++++++++++-- frontend/src/App.tsx | 2 + frontend/src/components/TagLink.tsx | 46 +++- frontend/src/consts.ts | 7 +- frontend/src/lib/queryKeys.ts | 11 +- frontend/src/lib/tags.ts | 8 +- .../deerjikists/DeerjikistDetailPage.tsx | 155 +++++++++++ frontend/src/types.ts | 8 +- 12 files changed, 494 insertions(+), 42 deletions(-) create mode 100644 frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 9b8bbe9..aed029f 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -1,3 +1,7 @@ +require 'net/http' +require 'uri' + + class TagsController < ApplicationController def index post_id = params[:post] @@ -182,7 +186,8 @@ class TagsController < ApplicationController .find_by(id: params[:id]) return head :not_found unless tag - render json: DeerjikistRepr.many(tag.deerjikists) + render json: { tag: TagRepr.base(tag), + deerjikists: DeerjikistRepr.many(tag.deerjikists) } end def deerjikists_by_name @@ -194,7 +199,31 @@ class TagsController < ApplicationController .find_by(tag_names: { name: }) return head :not_found unless tag - render json: DeerjikistRepr.many(tag.deerjikists) + render json: { tag: TagRepr.base(tag), + deerjikists: DeerjikistRepr.many(tag.deerjikists) } + end + + def update_deerjikists + return head :unauthorized unless current_user + return head :forbidden unless current_user.gte_member? + + tag = Tag.joins(:tag_name) + .includes(:tag_name, tag_name: :wiki_page) + .find_by(id: params[:id]) + return head :not_found unless tag + + ApplicationRecord.transaction do + tag.deerjikists = [] + params[:_json].each do + platform = _1[:platform] + code = normalise_deerjikist_code(platform, _1[:code]) + deerjikist = Deerjikist.find_or_initialize_by(platform:, code:) + deerjikist.tag = tag + deerjikist.save! + end + end + + render json: DeerjikistRepr.many(tag.reload.deerjikists) end def materials_by_name @@ -391,4 +420,21 @@ class TagsController < ApplicationController TagImplication.create!(tag:, parent_tag:) end end + + def normalise_deerjikist_code platform, code + return code if platform != 'youtube' || code[0] != '@' + + url = "https://www.youtube.com/#{ code }" + + html = Net::HTTP.get(URI(url)) + + canonical = html[ + /), + ) + end + + it 'normalises youtube handle to channel id' do + expect { + do_request + }.to change { Deerjikist.where(tag: tag).count }.from(0).to(1) + + expect(response).to have_http_status(:ok) + + expect(Net::HTTP).to have_received(:get) + + expect(Deerjikist.exists?(platform: 'youtube', code: channel_id, tag: tag)) + .to eq(true) + + expect(json).to be_a(Array) + expect(json.size).to eq(1) + expect(json[0]['platform']).to eq('youtube') + expect(json[0]['code']).to eq(channel_id) + end end end end diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7f44d7d..f52209d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import RouteBlockerOverlay from '@/components/RouteBlockerOverlay' import TopNav from '@/components/TopNav' import { Toaster } from '@/components/ui/toaster' import { apiPost, isApiError } from '@/lib/api' +import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage' import MaterialBasePage from '@/pages/materials/MaterialBasePage' import MaterialDetailPage from '@/pages/materials/MaterialDetailPage' import MaterialListPage from '@/pages/materials/MaterialListPage' @@ -58,6 +59,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { }/> }/> }/> + }/> }/> }/> }/> diff --git a/frontend/src/components/TagLink.tsx b/frontend/src/components/TagLink.tsx index 884c851..a68f8a9 100644 --- a/frontend/src/components/TagLink.tsx +++ b/frontend/src/components/TagLink.tsx @@ -45,9 +45,9 @@ export default (({ tag, <> {(linkFlg && withWiki) && ( - {(tag.materialId != null || tag.hasWiki) + {(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists) ? ( - tag.materialId == null + tag.materialId == null && !(tag.hasDeerjikists) ? ( ) : ( - - ? - )) + tag.materialId != null + ? ( + + ? + ) + : ( + + ? + ))) : ( ['character', 'material'].includes (tag.category) ? ( @@ -71,13 +79,23 @@ export default (({ tag, ! ) : ( - - ! - ))} + tag.category === 'deerjikist' + ? ( + + ! + ) + : ( + + ! + )))} )} {nestLevel > 0 && ( = + { nico: 'ニコニコ', youtube: 'YouTube' } as const + export const TAG_COLOUR = { deerjikist: 'rose', meme: 'purple', diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 97bae56..6ac3f21 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -9,11 +9,12 @@ export const postsKeys = { ['posts', 'changes', p] as const } export const tagsKeys = { - root: ['tags'] as const, - index: (p: FetchTagsParams) => ['tags', 'index', p] as const, - show: (name: string) => ['tags', name] as const, - changes: (p: { id?: string; page: number; limit: number }) => - ['tags', 'changes', p] as const } + root: ['tags'] as const, + index: (p: FetchTagsParams) => ['tags', 'index', p] as const, + show: (name: string) => ['tags', name] as const, + changes: (p: { id?: string; page: number; limit: number }) => + ['tags', 'changes', p] as const, + deerjikists: (id: string) => ['tags', 'deerjikists', id] as const } export const wikiKeys = { root: ['wiki'] as const, diff --git a/frontend/src/lib/tags.ts b/frontend/src/lib/tags.ts index e2c95c3..74eba45 100644 --- a/frontend/src/lib/tags.ts +++ b/frontend/src/lib/tags.ts @@ -1,6 +1,6 @@ import { apiGet } from '@/lib/api' -import type { FetchTagsParams, Tag, TagVersion } from '@/types' +import type { Deerjikist, FetchTagsParams, Tag, TagVersion } from '@/types' export const fetchTags = async ( @@ -56,3 +56,9 @@ export const fetchTagChanges = async ( versions: TagVersion[] count: number }> => await apiGet ('/tags/versions', { params: { ...(id && { id }), page, limit } }) + + +export const fetchDeerjikistsByTag = async ( + id: string, +): Promise<{ tag: Tag; deerjikists: Deerjikist[]}> => + await apiGet (`/tags/${ id }/deerjikists`) diff --git a/frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx b/frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx new file mode 100644 index 0000000..ee3651f --- /dev/null +++ b/frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx @@ -0,0 +1,155 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useEffect, useState } from 'react' +import { useParams } from 'react-router-dom' + +import TagLink from '@/components/TagLink' +import Label from '@/components/common/Label' +import PageTitle from '@/components/common/PageTitle' +import MainArea from '@/components/layout/MainArea' +import { toast } from '@/components/ui/use-toast' +import { PLATFORM_NAMES, PLATFORMS } from '@/consts' +import { apiPut } from '@/lib/api' +import { tagsKeys } from '@/lib/queryKeys' +import { fetchDeerjikistsByTag } from '@/lib/tags' +import { cn } from '@/lib/utils' + +import type { FC, FormEvent } from 'react' + +import type { Deerjikist, Platform } from '@/types' + + +export default (() => { + const { id } = useParams () + const tagId = String (id ?? '') + const tagKey = tagsKeys.deerjikists (tagId) + + const { data: qData, isLoading: loading } = + useQuery ({ queryKey: tagKey, queryFn: () => fetchDeerjikistsByTag (tagId) }) + const tag = qData?.tag + const deerjikists = qData?.deerjikists ?? [] + + const [data, setData] = + useState<(Omit & { platform: Platform | null })[]> ([]) + const [disabled, setDisabled] = useState (true) + + const qc = useQueryClient () + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault () + + try + { + setDisabled (true) + + setData (await apiPut (`/tags/${ id }/deerjikists`, data)) + qc.invalidateQueries ({ queryKey: tagsKeys.root }) + + toast ({ description: '更新しました.' }) + } + catch + { + toast ({ title: '更新失敗', description: '入力内容を確認してください.' }) + } + finally + { + setDisabled (false) + } + } + + useEffect (() => { + if (!(tag)) + { + setDisabled (true) + return + } + + setData (deerjikists) + setDisabled (false) + }, [tag, deerjikists]) + + return ( + + {(loading || !(tag)) ? 'Loading...' : ( +
+ + + + +
+ {data.map ((datum, i) => ( +
+ + + + + {/* プラットフォーム */} +
+ + +
+ + {/* コード */} +
+ + setData (prev => { + const rtn = [...prev] + rtn[i] = { ...rtn[i], code: e.target.value } + return rtn + })}/> +
+
+ ))} + +
+ +
+ +
+ +
+
+
+ )} +
) +}) satisfies FC diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 51363c3..5fb8078 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,5 +1,6 @@ import { CATEGORIES, FETCH_POSTS_ORDER_FIELDS, + PLATFORMS, USER_ROLES, ViewFlagBehavior } from '@/consts' @@ -7,6 +8,8 @@ import type { ReactNode } from 'react' export type Category = typeof CATEGORIES[number] +export type Deerjikist = { platform: Platform; code: string } + export type FetchPostsOrder = `${ FetchPostsOrderField }:${ 'asc' | 'desc' }` export type FetchPostsOrderField = typeof FETCH_POSTS_ORDER_FIELDS[number] @@ -114,6 +117,8 @@ export type NiconicoViewerHandle = { showComments: () => void hideComments: () => void } +export type Platform = typeof PLATFORMS[number] + export type Post = { id: number url: string @@ -178,7 +183,8 @@ export type Tag = { createdAt: string updatedAt: string hasWiki: boolean - materialId: number + materialId: number | null + hasDeerjikists: boolean children?: Tag[] matchedAlias?: string | null } From b47cdc7ad786427908756f88bf2ae660964f4bd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= Date: Mon, 4 May 2026 16:22:13 +0900 Subject: [PATCH 2/2] =?UTF-8?q?BAN=20=E3=81=AE=E5=AE=9F=E8=A3=85=20(#327)?= =?UTF-8?q?=20(#342)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #327 #327 #327 #327 Merge remote-tracking branch 'origin/main' into feature/327 #327 Co-authored-by: miteruzo Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/342 --- .../app/controllers/application_controller.rb | 21 +- backend/app/controllers/users_controller.rb | 6 +- backend/app/models/ip_address.rb | 5 +- backend/app/models/user.rb | 6 +- ..._to_banned_at_in_users_and_ip_addresses.rb | 16 ++ backend/db/schema.rb | 8 +- backend/spec/factories/ip_addresses.rb | 10 + backend/spec/factories/users.rb | 15 +- backend/spec/requests/users_spec.rb | 269 ++++++++++++++---- backend/spec/support/test_records.rb | 6 +- 10 files changed, 286 insertions(+), 76 deletions(-) create mode 100644 backend/db/migrate/20260501153900_rename_banned_to_banned_at_in_users_and_ip_addresses.rb create mode 100644 backend/spec/factories/ip_addresses.rb diff --git a/backend/app/controllers/application_controller.rb b/backend/app/controllers/application_controller.rb index 0d412e0..4f3d6ed 100644 --- a/backend/app/controllers/application_controller.rb +++ b/backend/app/controllers/application_controller.rb @@ -1,14 +1,16 @@ class ApplicationController < ActionController::API + before_action :reject_banned_ip_address! before_action :authenticate_user + before_action :reject_banned_user! - def current_user - @current_user - end + def current_user = @current_user private def authenticate_user code = request.headers['X-Transfer-Code'] || request.headers['HTTP_X_TRANSFER_CODE'] + return if code.blank? + @current_user = User.find_by(inheritance_code: code) end @@ -22,4 +24,17 @@ class ApplicationController < ActionController::API s.in?(['', '1', 'true', 'on', 'yes']) end end + + def reject_banned_ip_address! + ip_address = IpAddress.find_by(ip_address: IPAddr.new(request.remote_ip).hton) + return unless ip_address&.banned? + + head :forbidden + end + + def reject_banned_user! + return unless current_user&.banned? + + head :forbidden + end end diff --git a/backend/app/controllers/users_controller.rb b/backend/app/controllers/users_controller.rb index 64aa43c..a144593 100644 --- a/backend/app/controllers/users_controller.rb +++ b/backend/app/controllers/users_controller.rb @@ -1,9 +1,6 @@ class UsersController < ApplicationController def create - return head :unprocessable_entity if request.remote_ip.blank? - user = nil - User.transaction do user = User.create!(inheritance_code: SecureRandom.uuid, role: :guest) attach_ip_address!(user) @@ -17,8 +14,7 @@ class UsersController < ApplicationController def verify user = User.find_by(inheritance_code: params[:code]) return render json: { valid: false } unless user - - return head :unprocessable_entity if request.remote_ip.blank? + return head :forbidden if user.banned? attach_ip_address!(user) diff --git a/backend/app/models/ip_address.rb b/backend/app/models/ip_address.rb index bf73658..e51e3ac 100644 --- a/backend/app/models/ip_address.rb +++ b/backend/app/models/ip_address.rb @@ -1,7 +1,10 @@ class IpAddress < ApplicationRecord validates :ip_address, presence: true, length: { maximum: 16 } - validates :banned, inclusion: { in: [true, false] } has_many :user_ips, dependent: :destroy has_many :users, through: :user_ips + + def banned? = banned_at.present? + def ban! = banned? || update!(banned_at: Time.current) + def unban! = update!(banned_at: nil) end diff --git a/backend/app/models/user.rb b/backend/app/models/user.rb index 7e07642..f974adf 100644 --- a/backend/app/models/user.rb +++ b/backend/app/models/user.rb @@ -4,7 +4,6 @@ class User < ApplicationRecord validates :name, length: { maximum: 255 } validates :inheritance_code, presence: true, length: { maximum: 64 } validates :role, presence: true, inclusion: { in: roles.keys } - validates :banned, inclusion: { in: [true, false] } has_many :created_posts, class_name: 'Post', foreign_key: :uploaded_user_id, dependent: :nullify @@ -19,5 +18,10 @@ class User < ApplicationRecord class_name: 'WikiPage', foreign_key: :updated_user_id, dependent: :nullify def viewed?(post) = user_post_views.exists?(post_id: post.id) + def gte_member? = member? || admin? + + def banned? = banned_at.present? + def ban! = banned? || update!(banned_at: Time.current) + def unban! = update!(banned_at: nil) end diff --git a/backend/db/migrate/20260501153900_rename_banned_to_banned_at_in_users_and_ip_addresses.rb b/backend/db/migrate/20260501153900_rename_banned_to_banned_at_in_users_and_ip_addresses.rb new file mode 100644 index 0000000..71d2c63 --- /dev/null +++ b/backend/db/migrate/20260501153900_rename_banned_to_banned_at_in_users_and_ip_addresses.rb @@ -0,0 +1,16 @@ +class RenameBannedToBannedAtInUsersAndIpAddresses < ActiveRecord::Migration[8.0] + def up + [:users, :ip_addresses].each do + add_column _1, :banned_at, :datetime, after: :banned + add_index _1, :banned_at + remove_column _1, :banned + end + end + + def down + [:ip_addresses, :users].each do + add_column _1, :banned, :boolean, null: false, default: false, after: :banned_at + remove_column _1, :banned_at + end + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 48ee45f..042d227 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_04_27_214800) do +ActiveRecord::Schema[8.0].define(version: 2026_05_01_153900) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -50,9 +50,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_27_214800) do create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.binary "ip_address", limit: 16, null: false - t.boolean "banned", default: false, null: false + t.datetime "banned_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.index ["banned_at"], name: "index_ip_addresses_on_banned_at" t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true end @@ -332,9 +333,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_27_214800) do t.string "name" t.string "inheritance_code", limit: 64, null: false t.string "role", null: false - t.boolean "banned", default: false, null: false + t.datetime "banned_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.index ["banned_at"], name: "index_users_on_banned_at" end create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| diff --git a/backend/spec/factories/ip_addresses.rb b/backend/spec/factories/ip_addresses.rb new file mode 100644 index 0000000..6ffc363 --- /dev/null +++ b/backend/spec/factories/ip_addresses.rb @@ -0,0 +1,10 @@ +FactoryBot.define do + factory :ip_address do + ip_address { IPAddr.new('203.0.113.10').hton } + banned_at { nil } + + trait :banned do + banned_at { Time.current } + end + end +end diff --git a/backend/spec/factories/users.rb b/backend/spec/factories/users.rb index f7db70a..9225a23 100644 --- a/backend/spec/factories/users.rb +++ b/backend/spec/factories/users.rb @@ -1,15 +1,24 @@ FactoryBot.define do factory :user do - name { "test-user" } + name { nil } inheritance_code { SecureRandom.uuid } - role { "guest" } + role { 'guest' } + banned_at { nil } + + trait :guest do + role { 'guest' } + end trait :member do - role { "member" } + role { 'member' } end trait :admin do role { 'admin' } end + + trait :banned do + banned_at { Time.current } + end end end diff --git a/backend/spec/requests/users_spec.rb b/backend/spec/requests/users_spec.rb index 1f28e95..67c556f 100644 --- a/backend/spec/requests/users_spec.rb +++ b/backend/spec/requests/users_spec.rb @@ -1,109 +1,266 @@ -require "rails_helper" +require 'rails_helper' + +RSpec.describe 'Users', type: :request do + let(:remote_ip) { '203.0.113.10' } + + before do + allow_any_instance_of(ActionDispatch::Request) + .to receive(:remote_ip) + .and_return(remote_ip) + end + + def auth_headers(user) + { 'X-Transfer-Code' => user.inheritance_code } + end + + describe 'POST /users' do + it 'creates guest user, IpAddress and UserIp, and returns code' do + expect { + post '/users' + }.to change(User, :count).by(1) + .and change(IpAddress, :count).by(1) + .and change(UserIp, :count).by(1) -RSpec.describe "Users", type: :request do - describe "POST /users" do - it "creates guest user and returns code" do - post "/users" expect(response).to have_http_status(:created) - expect(json["code"]).to be_present - expect(json["user"]["role"]).to eq("guest") + expect(json['code']).to be_present + expect(json['user']['role']).to eq('guest') + + user = User.last + ip_address = IpAddress.find_by(ip_address: IPAddr.new(remote_ip).hton) + + expect(user.role).to eq('guest') + expect(ip_address).to be_present + expect(UserIp.exists?(user:, ip_address:)).to eq(true) + end + + it 'returns 403 and does not create user when current IP address is banned' do + IpAddress.create!( + ip_address: IPAddr.new(remote_ip).hton, + banned_at: Time.current + ) + + expect { + post '/users' + }.not_to change(User, :count) + + expect(response).to have_http_status(:forbidden) + expect(UserIp.count).to eq(0) end end - describe "POST /users/code/renew" do - it "returns 401 when not logged in" do - sign_out - post "/users/code/renew" + describe 'POST /users/code/renew' do + it 'returns 401 when not logged in' do + post '/users/code/renew' + expect(response).to have_http_status(:unauthorized) end + + it 'returns 403 when current user is banned' do + user = create(:user, :banned) + + post '/users/code/renew', headers: auth_headers(user) + + expect(response).to have_http_status(:forbidden) + end + + it 'returns 403 when current IP address is banned' do + user = create(:user) + + IpAddress.create!( + ip_address: IPAddr.new(remote_ip).hton, + banned_at: Time.current + ) + + post '/users/code/renew', headers: auth_headers(user) + + expect(response).to have_http_status(:forbidden) + end end - describe "PUT /users/:id" do - let(:user) { create(:user, name: "old-name", role: "guest") } + describe 'PUT /users/:id' do + let(:user) { create(:user, name: 'old-name', role: 'guest') } + + it 'returns 401 when current_user id mismatch' do + other_user = create(:user) + + put "/users/#{user.id}", + params: { name: 'new-name' }, + headers: auth_headers(other_user) - it "returns 401 when current_user id mismatch" do - sign_in_as(create(:user)) - put "/users/#{user.id}", params: { name: "new-name" } expect(response).to have_http_status(:unauthorized) end - it "returns 400 when name is blank" do - sign_in_as(user) - put "/users/#{user.id}", params: { name: " " } + it 'returns 400 when name is blank' do + put "/users/#{user.id}", + params: { name: ' ' }, + headers: auth_headers(user) + expect(response).to have_http_status(:bad_request) end - it "updates name and returns 201 with user slice" do - sign_in_as(user) - put "/users/#{user.id}", params: { name: "new-name" } + it 'updates name and returns user slice' do + put "/users/#{user.id}", + params: { name: 'new-name' }, + headers: auth_headers(user) expect(response).to have_http_status(:ok) - expect(json["id"]).to eq(user.id) - expect(json["name"]).to eq("new-name") + expect(json['id']).to eq(user.id) + expect(json['name']).to eq('new-name') user.reload - expect(user.name).to eq("new-name") + expect(user.name).to eq('new-name') + end + + it 'returns 403 when current user is banned' do + user.update!(banned_at: Time.current) + + put "/users/#{user.id}", + params: { name: 'new-name' }, + headers: auth_headers(user) + + expect(response).to have_http_status(:forbidden) + + user.reload + expect(user.name).to eq('old-name') + end + + it 'returns 403 when current IP address is banned' do + IpAddress.create!( + ip_address: IPAddr.new(remote_ip).hton, + banned_at: Time.current + ) + + put "/users/#{user.id}", + params: { name: 'new-name' }, + headers: auth_headers(user) + + expect(response).to have_http_status(:forbidden) + + user.reload + expect(user.name).to eq('old-name') end end - describe "POST /users/verify" do - it "returns valid:false when code not found" do - post "/users/verify", params: { code: "nope" } + describe 'POST /users/verify' do + it 'returns valid:false when code not found' do + post '/users/verify', params: { code: 'nope' } + expect(response).to have_http_status(:ok) - expect(json["valid"]).to eq(false) + expect(json['valid']).to eq(false) end - it "creates IpAddress and UserIp, and returns valid:true with user slice" do - user = create(:user, inheritance_code: SecureRandom.uuid, role: "guest") + it 'returns 403 when current IP address is banned' do + user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest') + + IpAddress.create!( + ip_address: IPAddr.new(remote_ip).hton, + banned_at: Time.current + ) + + expect { + post '/users/verify', params: { code: user.inheritance_code } + }.not_to change(UserIp, :count) + + expect(response).to have_http_status(:forbidden) + end + + it 'returns 403 when verified user is banned' do + user = create( + :user, + :banned, + inheritance_code: SecureRandom.uuid, + role: 'guest' + ) + + expect { + post '/users/verify', params: { code: user.inheritance_code } + }.not_to change(UserIp, :count) + + expect(response).to have_http_status(:forbidden) + end - # request.remote_ip を固定 - allow_any_instance_of(ActionDispatch::Request).to receive(:remote_ip).and_return("203.0.113.10") + it 'creates IpAddress and UserIp, and returns valid:true with user slice' do + user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest') expect { - post "/users/verify", params: { code: user.inheritance_code } + post '/users/verify', params: { code: user.inheritance_code } }.to change(UserIp, :count).by(1) + .and change(IpAddress, :count).by(1) expect(response).to have_http_status(:ok) - expect(json["valid"]).to eq(true) - expect(json["user"]["id"]).to eq(user.id) - expect(json["user"]["inheritance_code"]).to eq(user.inheritance_code) - expect(json["user"]["role"]).to eq("guest") + expect(json['valid']).to eq(true) + expect(json['user']['id']).to eq(user.id) + expect(json['user']['inheritance_code']).to eq(user.inheritance_code) + expect(json['user']['role']).to eq('guest') - # ついでに IpAddress もできてることを確認(ipの保存形式がバイナリでも count で見れる) - expect(IpAddress.count).to be >= 1 + ip_address = IpAddress.find_by(ip_address: IPAddr.new(remote_ip).hton) + expect(ip_address).to be_present + expect(UserIp.exists?(user:, ip_address:)).to eq(true) end - it "is idempotent for same user+ip (does not create duplicate UserIp)" do - user = create(:user, inheritance_code: SecureRandom.uuid, role: "guest") - allow_any_instance_of(ActionDispatch::Request).to receive(:remote_ip).and_return("203.0.113.10") + it 'is idempotent for same user and same IP address' do + user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest') - post "/users/verify", params: { code: user.inheritance_code } + post '/users/verify', params: { code: user.inheritance_code } expect(response).to have_http_status(:ok) expect { - post "/users/verify", params: { code: user.inheritance_code } + post '/users/verify', params: { code: user.inheritance_code } }.not_to change(UserIp, :count) expect(response).to have_http_status(:ok) - expect(json["valid"]).to eq(true) + expect(json['valid']).to eq(true) + end + + it 'creates another UserIp for same user and different IP address' do + user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest') + + post '/users/verify', params: { code: user.inheritance_code } + expect(response).to have_http_status(:ok) + + allow_any_instance_of(ActionDispatch::Request) + .to receive(:remote_ip) + .and_return('203.0.113.11') + + expect { + post '/users/verify', params: { code: user.inheritance_code } + }.to change(UserIp, :count).by(1) + + expect(response).to have_http_status(:ok) + expect(json['valid']).to eq(true) end end - describe "GET /users/me" do - it "returns 404 when code not found" do - get "/users/me", params: { code: "nope" } + describe 'GET /users/me' do + it 'returns 404 when code not found' do + get '/users/me', params: { code: 'nope' } + expect(response).to have_http_status(:not_found) end - it "returns user slice when found" do - user = create(:user, inheritance_code: SecureRandom.uuid, name: "me", role: "guest") - get "/users/me", params: { code: user.inheritance_code } + it 'returns user slice when found' do + user = create(:user, inheritance_code: SecureRandom.uuid, name: 'me', role: 'guest') + + get '/users/me', params: { code: user.inheritance_code } expect(response).to have_http_status(:ok) - expect(json["id"]).to eq(user.id) - expect(json["name"]).to eq("me") - expect(json["inheritance_code"]).to eq(user.inheritance_code) - expect(json["role"]).to eq("guest") + expect(json['id']).to eq(user.id) + expect(json['name']).to eq('me') + expect(json['inheritance_code']).to eq(user.inheritance_code) + expect(json['role']).to eq('guest') + end + + it 'returns 403 when current IP address is banned' do + user = create(:user, inheritance_code: SecureRandom.uuid) + + IpAddress.create!( + ip_address: IPAddr.new(remote_ip).hton, + banned_at: Time.current + ) + + get '/users/me', params: { code: user.inheritance_code } + + expect(response).to have_http_status(:forbidden) end end end diff --git a/backend/spec/support/test_records.rb b/backend/spec/support/test_records.rb index 350b8da..b6a7cd5 100644 --- a/backend/spec/support/test_records.rb +++ b/backend/spec/support/test_records.rb @@ -2,14 +2,12 @@ module TestRecords def create_member_user! User.create!(name: 'spec user', inheritance_code: SecureRandom.hex(16), - role: 'member', - banned: false) + role: 'member') end def create_admin_user! User.create!(name: 'spec admin', inheritance_code: SecureRandom.hex(16), - role: 'admin', - banned: false) + role: 'admin') end end