diff --git a/.github/workflows/update_only_when_reviewer_approved.yml b/.github/workflows/update_only_when_reviewer_approved.yml index 19f9ba48a42..8e4aa43c3b6 100644 --- a/.github/workflows/update_only_when_reviewer_approved.yml +++ b/.github/workflows/update_only_when_reviewer_approved.yml @@ -8,12 +8,16 @@ jobs: runs-on: ubuntu-latest steps: - name: post approved pr event + env: + FJORD_CHOICE_TOKEN: ${{ secrets.FJORD_CHOICE_TOKEN }} + PR_PAYLOAD: | + {"title": ${{ toJson(github.event.pull_request.title) }}, + "number": ${{ github.event.pull_request.number }}, + "state": ${{ toJson(github.event.pull_request.state) }}, + "reviewer_uids": ${{ toJson(github.event.pull_request.requested_reviewers.*.id) }} + } run: | curl -f -X POST https://fjord-choice.herokuapp.com/api/pulls \ -H 'Content-Type: application/json' \ - -H 'Authorization: Token ${{ secrets.FJORD_CHOICE_TOKEN }}' \ - -d '{"title": ${{ toJson(github.event.pull_request.title) }}, - "number": ${{ github.event.pull_request.number }}, - "state": ${{ toJson(github.event.pull_request.state) }}, - "reviewer_uids": ${{ toJson(github.event.pull_request.requested_reviewers.*.id) }} - }' + -H "Authorization: Token $FJORD_CHOICE_TOKEN" \ + -d "$PR_PAYLOAD" diff --git a/app/agents/pjord/agent.rb b/app/agents/pjord/agent.rb index 5bb2cf4bbe6..3f179227f9a 100644 --- a/app/agents/pjord/agent.rb +++ b/app/agents/pjord/agent.rb @@ -2,7 +2,7 @@ class Pjord::Agent < RubyLLM::Agent model ENV.fetch('PJORD_LLM_MODEL', 'claude-sonnet-5') - tools BootcampSearchTool, UserInfoTool, ExternalContentTool + tools AiTools::BootcampSearchTool, AiTools::UserInfoTool, AiTools::ExternalContentTool schema PjordResponse instructions { AiPrompt.body_for('pjord') } diff --git a/app/javascript/controllers/report_preset_controller.js b/app/javascript/controllers/report_preset_controller.js index dcb798646f5..13456b5ace6 100644 --- a/app/javascript/controllers/report_preset_controller.js +++ b/app/javascript/controllers/report_preset_controller.js @@ -1,42 +1,46 @@ import { Controller } from '@hotwired/stimulus' +import { post, put } from '@rails/request.js' import TextareaInitializer from 'textarea-initializer' -import CSRF from 'csrf' import { toast } from 'vanillaToast' export default class extends Controller { static targets = [ - 'report', + 'reportInput', 'modal', - 'templateInput', + 'presetInput', 'submitButton', - 'templateTab', + 'presetEditTab', 'previewTab', - 'templateContent', - 'previewContent' + 'presetEditPanel', + 'previewPanel', + 'replaceReportAction', + 'openModalButton' ] static values = { - registeredTemplate: String, - registeredTemplateId: Number, - editing: Boolean, - isEditingTemplateValid: Boolean + presetId: Number, + savedPresetDescription: String } connect() { - if (this.reportTarget.value === '') { - this.reportTarget.value = this.registeredTemplateValue + if (this.reportInputTarget.value === '') { + this.reportInputTarget.value = this.savedPresetDescriptionValue } - this.registeredTemplateValue = this.templateInputTarget.value TextareaInitializer.initialize('.js-report-content') + this.updatePresetControls() } - replaceReport(e) { + initialize() { + this.isSaving = false + } + + replaceReportWithPreset(e) { e.preventDefault() if ( - this.reportTarget.value === '' || + this.reportInputTarget.value === '' || confirm('日報が上書きされますが、よろしいですか?') ) { - this.reportTarget.value = this.registeredTemplateValue + this.reportInputTarget.value = this.savedPresetDescriptionValue TextareaInitializer.uninitialize('.js-report-content') TextareaInitializer.initialize('.js-report-content') } @@ -46,18 +50,19 @@ export default class extends Controller { if (e.target !== e.currentTarget) { return } - if (this.editingValue === this.registeredTemplateValue) { + if ( + this.presetInputTarget.value === this.savedPresetDescriptionValue || + confirm('テンプレートを登録せずに終了しますか?') + ) { this.closeModal() - } else { - confirm('テンプレートを登録せずに終了しますか?') && this.closeModal() } } openModal(e) { e.preventDefault() this.modalTarget.classList.remove('is-hidden') - this.templateInputTarget.value = this.registeredTemplateValue - TextareaInitializer.initialize('#js-template-content') + this.presetInputTarget.value = this.savedPresetDescriptionValue + TextareaInitializer.initialize('#js-preset-content') this.updateSubmitButtonState() } @@ -65,104 +70,124 @@ export default class extends Controller { this.modalTarget.classList.add('is-hidden') } - registerTemplate(e) { + async savePreset(e) { e.preventDefault() - const editingTemplate = this.templateInputTarget.value + if (this.isSaving) { + return + } + const editingDescription = this.presetInputTarget.value if ( - editingTemplate === '' || - editingTemplate === this.registeredTemplateValue + editingDescription.trim() === '' || + editingDescription.trim() === this.savedPresetDescriptionValue.trim() ) { - return null + return } + this.isSaving = true + this.updateSubmitButtonState() + + try { + if (this.presetIdValue > 0) { + await this.updatePreset(editingDescription) + } else { + await this.registerPreset(editingDescription) + } + } finally { + this.isSaving = false + this.updateSubmitButtonState() + } + } + + registerPreset(editingDescription) { const params = { - report_preset: { description: editingTemplate } + report_preset: { description: editingDescription } } - fetch('/api/report_presets', { - method: 'POST', - headers: { - 'Content-Type': 'application/json; charset=utf-8', - 'X-Requested-With': 'XMLHttpRequest', - 'X-CSRF-Token': CSRF.getToken() - }, - credentials: 'same-origin', - redirect: 'manual', - body: JSON.stringify(params) + + return post('/api/report_presets', { + body: params, + responseKind: 'json', + redirect: 'manual' }) .then((response) => { - return response.json() + if (!response.ok) { + throw new Error('テンプレートの登録に失敗しました') + } + return response.json }) - .then((newTemplate) => { - this.registeredTemplateValue = editingTemplate - this.registeredTemplateIdValue = newTemplate.id + .then((createdPreset) => { + this.presetIdValue = createdPreset.id + this.savedPresetDescriptionValue = editingDescription + this.updatePresetControls() toast('テンプレートを登録しました!') this.closeModal() }) .catch((error) => { console.warn(error) + toast('テンプレートを登録できませんでした。', 'error') }) } - updateTemplate(e) { - e.preventDefault() - const editingTemplate = this.templateInputTarget.value - if ( - editingTemplate === '' || - editingTemplate === this.registeredTemplateValue - ) { - return null - } - + updatePreset(editingDescription) { const params = { - report_preset: { description: editingTemplate } + report_preset: { description: editingDescription } } - fetch(`/api/report_presets/${this.registeredTemplateIdValue}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json; charset=utf-8', - 'X-Requested-With': 'XMLHttpRequest', - 'X-CSRF-Token': CSRF.getToken() - }, - credentials: 'same-origin', - redirect: 'manual', - body: JSON.stringify(params) + + return put(`/api/report_presets/${this.presetIdValue}`, { + body: params, + responseKind: 'json', + redirect: 'manual' }) - .then(() => { - this.registeredTemplateValue = editingTemplate + .then((response) => { + if (!response.ok) { + throw new Error('テンプレートの変更に失敗しました') + } + this.savedPresetDescriptionValue = editingDescription + this.updatePresetControls() toast('テンプレートを変更しました!') this.closeModal() }) .catch((error) => { console.warn(error) + toast('テンプレートを変更できませんでした。', 'error') }) } - clickTemplateTab(e) { + updatePresetControls() { + const isPresetRegistered = this.presetIdValue > 0 + this.replaceReportActionTarget.classList.toggle( + 'is-hidden', + !isPresetRegistered + ) + this.openModalButtonTarget.textContent = isPresetRegistered + ? 'テンプレート変更' + : 'テンプレート登録' + this.submitButtonTarget.textContent = isPresetRegistered ? '変更' : '登録' + this.updateSubmitButtonState() + } + + updateSubmitButtonState() { + const editingDescription = this.presetInputTarget.value.trim() + const savedDescription = this.savedPresetDescriptionValue.trim() + + const isValid = + editingDescription !== '' && editingDescription !== savedDescription + this.submitButtonTarget.disabled = this.isSaving || !isValid + } + + openPresetEditTab(e) { e.preventDefault() - this.editingValue = true - this.templateTabTarget.classList.add('is-active') + this.presetEditTabTarget.classList.add('is-active') this.previewTabTarget.classList.remove('is-active') - this.templateContentTarget.classList.add('is-active') - this.previewContentTarget.classList.remove('is-active') + this.presetEditPanelTarget.classList.add('is-active') + this.previewPanelTarget.classList.remove('is-active') } - clickPreviewTab(e) { + openPreviewTab(e) { e.preventDefault() - this.editingValue = false - this.templateTabTarget.classList.remove('is-active') + this.presetEditTabTarget.classList.remove('is-active') this.previewTabTarget.classList.add('is-active') - this.templateContentTarget.classList.remove('is-active') - this.previewContentTarget.classList.add('is-active') - } - - updateSubmitButtonState() { - const editingTemplate = this.templateInputTarget.value.trim() - const registeredTemplate = this.registeredTemplateValue.trim() - - const isValid = - editingTemplate !== '' && editingTemplate !== registeredTemplate - this.isEditingTemplateValidValue = isValid - this.submitButtonTarget.disabled = !isValid + this.presetEditPanelTarget.classList.remove('is-active') + this.previewPanelTarget.classList.add('is-active') } } diff --git a/app/tools/bootcamp_search_tool.rb b/app/models/ai_tools/bootcamp_search_tool.rb similarity index 96% rename from app/tools/bootcamp_search_tool.rb rename to app/models/ai_tools/bootcamp_search_tool.rb index b3f5b1f33a7..c2a6b6b7006 100644 --- a/app/tools/bootcamp_search_tool.rb +++ b/app/models/ai_tools/bootcamp_search_tool.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class BootcampSearchTool < RubyLLM::Tool +class AiTools::BootcampSearchTool < RubyLLM::Tool description 'bootcampのプラクティス(カリキュラム)、ドキュメント(Docs)、Q&A、お知らせを検索する。' \ 'ユーザーの質問に答えるために必要な情報を探すときに使う。' @@ -18,6 +18,10 @@ class BootcampSearchTool < RubyLLM::Tool RESULT_LIMIT = 5 CONTENT_TRUNCATE_LENGTH = 500 + def self.tool_name + 'bootcamp_search' + end + def execute(query:, category: 'all') targets = select_targets(category) results = search(query, targets) diff --git a/app/tools/external_content_tool.rb b/app/models/ai_tools/external_content_tool.rb similarity index 79% rename from app/tools/external_content_tool.rb rename to app/models/ai_tools/external_content_tool.rb index 708789546ae..ea6e3a73db8 100644 --- a/app/tools/external_content_tool.rb +++ b/app/models/ai_tools/external_content_tool.rb @@ -1,10 +1,14 @@ # frozen_string_literal: true -class ExternalContentTool < RubyLLM::Tool +class AiTools::ExternalContentTool < RubyLLM::Tool description 'URLの先にある内容を確認する。GitHubのPR・コード・ディレクトリ、rawファイル、一般Webページを読み、回答やレビューに必要な文脈を取得するときに使う。' parameter :url, description: '確認したいhttpまたはhttpsのURL' + def self.tool_name + 'external_content' + end + def execute(url:) ExternalContent.fetch(url) end diff --git a/app/tools/user_info_tool.rb b/app/models/ai_tools/user_info_tool.rb similarity index 97% rename from app/tools/user_info_tool.rb rename to app/models/ai_tools/user_info_tool.rb index 3e16f62c7da..550dad81522 100644 --- a/app/tools/user_info_tool.rb +++ b/app/models/ai_tools/user_info_tool.rb @@ -1,11 +1,15 @@ # frozen_string_literal: true -class UserInfoTool < RubyLLM::Tool +class AiTools::UserInfoTool < RubyLLM::Tool description 'メンションしてきたユーザーのプロフィールやカリキュラムの進捗状況を取得する。' \ 'ユーザーに合わせた回答をするときに使う。' parameter :login_name, description: 'ユーザーのログイン名' + def self.tool_name + 'user_info' + end + def execute(login_name:) user = User.find_by(login_name: login_name) return "ユーザー「#{login_name}」が見つかりませんでした。" if user.nil? diff --git a/app/tools/external_content.rb b/app/models/external_content.rb similarity index 100% rename from app/tools/external_content.rb rename to app/models/external_content.rb diff --git a/app/tools/external_content/github_directory_formatter.rb b/app/models/external_content/github_directory_formatter.rb similarity index 100% rename from app/tools/external_content/github_directory_formatter.rb rename to app/models/external_content/github_directory_formatter.rb diff --git a/app/tools/external_content/github_pull_request_formatter.rb b/app/models/external_content/github_pull_request_formatter.rb similarity index 100% rename from app/tools/external_content/github_pull_request_formatter.rb rename to app/models/external_content/github_pull_request_formatter.rb diff --git a/app/tools/external_content/github_raw_file_formatter.rb b/app/models/external_content/github_raw_file_formatter.rb similarity index 100% rename from app/tools/external_content/github_raw_file_formatter.rb rename to app/models/external_content/github_raw_file_formatter.rb diff --git a/app/tools/external_content/github_reader.rb b/app/models/external_content/github_reader.rb similarity index 100% rename from app/tools/external_content/github_reader.rb rename to app/models/external_content/github_reader.rb diff --git a/app/tools/external_content/github_review_reader.rb b/app/models/external_content/github_review_reader.rb similarity index 100% rename from app/tools/external_content/github_review_reader.rb rename to app/models/external_content/github_review_reader.rb diff --git a/app/tools/external_content/http_client.rb b/app/models/external_content/http_client.rb similarity index 80% rename from app/tools/external_content/http_client.rb rename to app/models/external_content/http_client.rb index cda5e9129f2..772d86b9cab 100644 --- a/app/tools/external_content/http_client.rb +++ b/app/models/external_content/http_client.rb @@ -13,7 +13,16 @@ def success? end end - class ResponseTooLarge < StandardError; end + class FetchError < RuntimeError; end + + class ResponseTooLarge < StandardError + attr_reader :code + + def initialize(code = nil) + @code = code + super('response too large') + end + end MAX_REDIRECTS = 5 OPEN_TIMEOUT = 3 @@ -58,13 +67,13 @@ def fetch(uri, redirects_left:, visited:) raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) && uri.userinfo.nil? address = validate_public_endpoint!(uri) - raise "too many redirects: #{uri}" if redirects_left.negative? - raise "redirect loop: #{uri}" if visited.include?(uri.to_s) + raise FetchError, 'too many redirects' if redirects_left.negative? + raise FetchError, 'redirect loop' if visited.include?(uri.to_s) response = request(uri, address) if response.is_a?(Net::HTTPRedirection) location = response['location'].to_s - raise "redirect without location: #{uri}" if location.blank? + raise FetchError, 'redirect without location' if location.blank? next_uri = URI.join(uri, location) return fetch(next_uri, redirects_left: redirects_left - 1, visited: visited + [uri.to_s]) @@ -90,7 +99,7 @@ def request(uri, address) http.request(Net::HTTP::Get.new(uri.request_uri, headers)) do |response| body = +''.b response.read_body do |chunk| - raise ResponseTooLarge if @max_body_bytes && body.bytesize + chunk.bytesize > @max_body_bytes + raise ResponseTooLarge, response.code if @max_body_bytes && body.bytesize + chunk.bytesize > @max_body_bytes body << chunk end @@ -102,8 +111,8 @@ def request(uri, address) def validate_public_endpoint!(uri) addresses = resolved_addresses(uri) - raise "unresolvable host: #{uri}" if addresses.empty? - raise "private endpoint is not allowed: #{uri}" if addresses.any? { |address| private_endpoint?(address) } + raise FetchError, 'unresolvable host' if addresses.empty? + raise FetchError, 'private endpoint is not allowed' if addresses.any? { |address| private_endpoint?(address) } addresses.first end diff --git a/app/tools/external_content/web_page_reader.rb b/app/models/external_content/web_page_reader.rb similarity index 100% rename from app/tools/external_content/web_page_reader.rb rename to app/models/external_content/web_page_reader.rb diff --git a/app/models/link_card/card.rb b/app/models/link_card/card.rb index a7b2e495292..b779c2cd268 100644 --- a/app/models/link_card/card.rb +++ b/app/models/link_card/card.rb @@ -16,20 +16,29 @@ def metadata private def cache_key - ['link_card', @tweet ? 'tweet' : 'metadata', @url] + ['link_card', 'v2', @tweet ? 'tweet' : 'metadata', @url] end def request - return unless LinkChecker::Checker.valid_url?(@url) + uri = Addressable::URI.parse(@url) + return unless uri && uri.userinfo.nil? + + uri = URI.parse(uri.normalize.to_s) + return unless uri.is_a?(URI::HTTP) && uri.hostname.present? @tweet ? fetch_tweet : Metadata.new(@url).fetch + rescue *Metadata::FETCH_ERRORS + nil end def fetch_tweet - embed_tweet_url = "https://publish.twitter.com/oembed?url=#{@url}" - uri = Addressable::URI.parse(embed_tweet_url).normalize - response = Net::HTTP.get_response(uri) - response.is_a?(Net::HTTPSuccess) ? response.body : nil + uri = Addressable::URI.parse('https://publish.twitter.com/oembed') + uri.query_values = { url: @url } + response = ExternalContent::HttpClient.get(uri.normalize.to_s, max_body_bytes: 2.megabytes, request_timeout: 10) + return unless response.success? + + body = response.body.to_s.dup.force_encoding(Encoding::UTF_8) + body if body.valid_encoding? end end end diff --git a/app/models/link_checker/client.rb b/app/models/link_checker/client.rb index 46a4b6c5278..939a0811245 100644 --- a/app/models/link_checker/client.rb +++ b/app/models/link_checker/client.rb @@ -2,10 +2,6 @@ module LinkChecker class Client - SSL_VERIFY_NONE_HOST = [ - 'www.tablesgenerator.com' # 中間証明書を取得できず、SSLサーバー証明書の検証に失敗するため - ].freeze - def self.request(url) new(url).request end @@ -15,14 +11,16 @@ def initialize(url) end def request - uri = Addressable::URI.parse(@url).normalize - options = {} - options[:ssl_verify_mode] = OpenSSL::SSL::VERIFY_NONE if SSL_VERIFY_NONE_HOST.include?(uri.host) - response = OpenURI.open_uri(uri, **options) - response.status.first.to_i - rescue OpenURI::HTTPError => e - e.io.status.first.to_i - rescue StandardError => _e + uri = Addressable::URI.parse(@url) + return false unless uri && uri.userinfo.nil? + + response = ExternalContent::HttpClient.get(uri.normalize.to_s, max_body_bytes: 2.megabytes, request_timeout: 10) + response.code.to_i + rescue ExternalContent::HttpClient::ResponseTooLarge => e + e.code && !e.code.to_i.between?(300, 399) ? e.code.to_i : false + rescue URI::InvalidURIError, Addressable::URI::InvalidURIError, ExternalContent::HttpClient::FetchError, + SocketError, SystemCallError, IOError, + Timeout::Error, OpenSSL::SSL::SSLError, Net::HTTPBadResponse false end end diff --git a/app/models/metadata.rb b/app/models/metadata.rb index a623d493726..1f8c6087227 100644 --- a/app/models/metadata.rb +++ b/app/models/metadata.rb @@ -1,40 +1,65 @@ # frozen_string_literal: true class Metadata - NETWORK_ERRORS = [ + FETCH_ERRORS = [ + ExternalContent::HttpClient::FetchError, + ExternalContent::HttpClient::ResponseTooLarge, + URI::InvalidURIError, + Addressable::URI::InvalidURIError, SocketError, - Errno::ECONNREFUSED, - Errno::ETIMEDOUT, - Net::OpenTimeout, - Net::ReadTimeout, - OpenSSL::SSL::SSLError + SystemCallError, + IOError, + Timeout::Error, + OpenSSL::SSL::SSLError, + Net::HTTPBadResponse, + Encoding::InvalidByteSequenceError, + Encoding::UndefinedConversionError ].freeze def initialize(url) @url = url - @uri = Addressable::URI.parse(url).normalize end def fetch - http = Net::HTTP.new(@uri.host, @uri.inferred_port) - if @uri.scheme == 'https' - http.use_ssl = true - http.verify_mode = OpenSSL::SSL::VERIFY_PEER - end - http.response_body_encoding = true + @uri = Addressable::URI.parse(@url) + return unless @uri && @uri.userinfo.nil? - response = http.request_get(@uri.request_uri) - return fetch_youtube_oembed unless response.is_a?(Net::HTTPSuccess) + @uri = @uri.normalize + response = ExternalContent::HttpClient.get(@uri.to_s, max_body_bytes: 2.megabytes, request_timeout: 10) + return fetch_youtube_oembed unless response.success? - parse(response.body) || fetch_youtube_oembed - rescue *NETWORK_ERRORS + parse(html_document(response)) || fetch_youtube_oembed + rescue *FETCH_ERRORS nil end private - def parse(html) - object = OpenGraphReader.parse(html) + def html_document(response) + body = response.body.to_s.b + encoding = declared_encoding(response.content_type) + return Nokogiri::HTML(body.force_encoding(encoding).encode(Encoding::UTF_8, invalid: :replace, undef: :replace)) if encoding + + document = Nokogiri::HTML(body) + utf8 = body.dup.force_encoding(Encoding::UTF_8) + # Respect HTML charset declarations; unlabelled, valid UTF-8 should not become Latin-1. + return document if document.meta_encoding || !utf8.valid_encoding? + + Nokogiri::HTML(utf8) + end + + def declared_encoding(content_type) + charset = content_type.to_s[/charset\s*=\s*["']?([^\s;"']+)/i, 1] + return unless charset + + encoding = Encoding.find(charset) + encoding == Encoding::Shift_JIS ? Encoding::Windows_31J : encoding + rescue ArgumentError + nil + end + + def parse(document) + object = OpenGraphReader.parse(document) return unless object { @@ -42,7 +67,7 @@ def parse(html) description: object.og.description, images: object.og.image&.url, site_name: object.og.site_name || @uri.host, - favicon: favicon(html), + favicon: favicon(document), url: @url, site_url: site_url } @@ -52,9 +77,8 @@ def site_url "#{@uri.scheme}://#{@uri.host}" end - def favicon(html) - doc = Nokogiri::HTML(html) - favicon_path = doc.at_css('link[rel="icon"], link[rel="shortcut icon"]')&.attr('href') + def favicon(document) + favicon_path = document.at_css('link[rel="icon"], link[rel="shortcut icon"]')&.attr('href') return unless favicon_path absolute_regexp = URI::DEFAULT_PARSER.make_regexp @@ -63,7 +87,7 @@ def favicon(html) if absolute_regexp.match?(favicon_path) favicon_path else - URI.join(@url, favicon_path).to_s + URI.join(@uri.to_s, favicon_path).to_s end end @@ -76,10 +100,10 @@ def fetch_youtube_oembed uri = Addressable::URI.parse('https://www.youtube.com/oembed') uri.query_values = { url: @url, format: 'json' } - response = Net::HTTP.get_response(uri.normalize) - return unless response.is_a?(Net::HTTPSuccess) + response = ExternalContent::HttpClient.get(uri.normalize.to_s, max_body_bytes: 2.megabytes, request_timeout: 10) + return unless response.success? - body = JSON.parse(response.body) + body = JSON.parse(response.body.to_s.dup.force_encoding(Encoding::UTF_8)) { title: body['title'], description: nil, @@ -89,7 +113,7 @@ def fetch_youtube_oembed url: @url, site_url: 'https://www.youtube.com' } - rescue JSON::ParserError, *NETWORK_ERRORS + rescue JSON::ParserError nil end end diff --git a/app/models/report_preset.rb b/app/models/report_preset.rb index 7cf56e1ea86..dd598c40b14 100644 --- a/app/models/report_preset.rb +++ b/app/models/report_preset.rb @@ -7,4 +7,5 @@ class ReportPreset < ApplicationRecord validates :description, presence: true validates :user, presence: true + validates :user_id, uniqueness: true end diff --git a/app/views/mentor/practices/coding_tests/index.html.slim b/app/views/mentor/practices/coding_tests/index.html.slim index c52490a4dbb..a9eea6a465c 100644 --- a/app/views/mentor/practices/coding_tests/index.html.slim +++ b/app/views/mentor/practices/coding_tests/index.html.slim @@ -15,7 +15,7 @@ header.page-header .page-main-header__inner .page-main-header__start h1.page-main-header__title - | #{@practice.title}プラクティクスのコーディングテスト並び替え + | #{@practice.title}プラクティスのコーディングテスト並び替え .page-main-header__end .page-main-header-actions .page-main-header-actions__items diff --git a/app/views/reports/_form.html.slim b/app/views/reports/_form.html.slim index dc846df5291..f0c695594e0 100644 --- a/app/views/reports/_form.html.slim +++ b/app/views/reports/_form.html.slim @@ -65,25 +65,28 @@ .a-page-notice__inner p | この日報はすでに提出済みです。 - .row.js-markdown-parent(data-controller="report-preset") + + - preset_id = current_user.report_preset&.id + - preset_description = current_user.report_preset&.description + + .row.js-markdown-parent( + data-controller='report-preset' + data-report-preset-preset-id-value=preset_id + data-report-preset-saved-preset-description-value=preset_description + ) .col-md-6.col-xs-12 - - registered_preset = current_user.report_preset&.description - - registered_preset_id = current_user.report_preset&.id .form-item__header = f.label :description, class: 'a-form-label' = render 'reports/report_preset', - preset_description: registered_preset, - preset_id: registered_preset_id, - editingValue: true, - is_editing_template_valid: true, - data: { 'registered-template-value': registered_preset } + preset_id: preset_id, + preset_description: preset_description .form-textarea .form-textarea__body - - value_option = report.description.blank? && registered_preset.present? ? { value: registered_preset } : {} + - value_option = report.description.blank? && preset_id.present? ? { value: preset_description } : {} = f.text_area :description, **value_option, class: "a-text-input js-warning-form js-report-content markdown-form__text-area #{params[:action] == 'new' && params[:id].blank? ? '' : 'js-markdown'}", - data: { 'report-preset-target': 'report', 'preview': '.js-preview', 'input': '.file-input', 'report-latest-target': 'description' } + data: { 'report-preset-target': 'reportInput', 'preview': '.js-preview', 'input': '.file-input', 'report-latest-target': 'description' } .form-textarea__footer .form-textarea__insert label.a-file-insert.a-button.is-xs.is-text-reversal.is-block @@ -94,7 +97,7 @@ .col-md-6.col-xs-12 .a-form-label | プレビュー - .js-preview.a-long-text.is-md.markdown-form__preview(data-report-preset-target="previewContent") + .js-preview.a-long-text.is-md.markdown-form__preview .form-actions.is-report ul.form-actions__items diff --git a/app/views/reports/_report_preset.html.slim b/app/views/reports/_report_preset.html.slim index 936f9171fe4..4a8fb03e167 100644 --- a/app/views/reports/_report_preset.html.slim +++ b/app/views/reports/_report_preset.html.slim @@ -1,31 +1,22 @@ -div( - data-report-preset-registered-template-value=preset_description - data-report-preset-registered-template-id-value=preset_id - data-report-preset-editing-value=editingValue - data-report-preset-is-editing-template-valid-value=is_editing_template_valid -) +div .form-item-actions ul.form-item-actions__items - - if preset_id.present? - li.form-item-actions__item - a.form-item-actions__text-link.is-danger( - data-action="click->report-preset#replaceReport" - ) - | テンプレートを反映する + li.form-item-actions__item ( + class=(preset_id.present? ? '' : 'is-hidden') + data-report-preset-target='replaceReportAction' + ) + button.form-item-actions__text-link.is-danger( + type='button' + data-action='click->report-preset#replaceReportWithPreset' + ) + | テンプレートを反映する - li.form-item-actions__item - button.a-button.is-sm.is-secondary.is-block( - type='button' - data-action="click->report-preset#openModal" - ) - | テンプレート変更 - - else - li.form-item-actions__item - button.a-button.is-sm.is-secondary.is-block( - type='button' - data-action="click->report-preset#openModal" - ) - | テンプレート登録 + li.form-item-actions__item + button.a-button.is-sm.is-secondary.is-block( + type='button' + data-action='click->report-preset#openModal' + data-report-preset-target='openModalButton' + ) = preset_id.present? ? 'テンプレート変更' : 'テンプレート登録' li.form-item-actions__item button.a-button.is-sm.is-secondary.is-block( @@ -40,7 +31,6 @@ div( role='dialog' aria-modal='true' aria-labelledby='report-preset-modal-title' - aria-hidden='true' ) .a-card.is-modal header.card-header.is-sm @@ -48,51 +38,43 @@ div( hr.a-border .a-form-tabs.js-tabs - .a-form-tabs__tab.js-tabs__tab( - class=(editingValue ? 'is-active' : '') - data-action='click->report-preset#clickTemplateTab' - data-report-preset-target='templateTab' + .a-form-tabs__tab.js-tabs__tab.is-active( + data-action='click->report-preset#openPresetEditTab' + data-report-preset-target='presetEditTab' ) | テンプレート .a-form-tabs__tab.js-tabs__tab( - class=(editingValue ? '' : 'is-active') - data-action='click->report-preset#clickPreviewTab' + data-action='click->report-preset#openPreviewTab' data-report-preset-target='previewTab' ) | プレビュー .a-markdown-input.js-markdown-parent - .a-markdown-input__inner.js-tabs__content( - class=(editingValue ? 'is-active' : '') - data-report-preset-target='templateContent' + .a-markdown-input__inner.js-tabs__content.is-active( + data-report-preset-target='presetEditPanel' ) - textarea.a-text-input.a-markdown-input__textarea.has-max-height#js-template-content( - data-preview='#js-template-preview' - data-report-preset-target='templateInput' + textarea.a-text-input.a-markdown-input__textarea.has-max-height#js-preset-content( + data-preview='#js-preset-preview' + data-report-preset-target='presetInput' name='report_preset[description]' data-action='input->report-preset#updateSubmitButtonState' ) = preset_description .a-markdown-input__inner.js-tabs__content.a-long-text.is-md.a-markdown-input__preview.has-max-height( - id='js-template-preview' - class=(editingValue ? '' : 'is-active') - data-report-preset-target='previewContent' + id='js-preset-preview' + data-report-preset-target='previewPanel' ) footer.card-footer .card-main-actions ul.card-main-actions__items - - action = preset_id.present? ? 'updateTemplate' : 'registerTemplate' - - label = preset_id.present? ? '変更' : '登録' - li.card-main-actions__item button.a-button.is-primary.is-sm.is-block( type='button' - class=(editingValue ? 'is-active' : '') - data-action='click->report-preset##{action}' + data-action='click->report-preset#savePreset' data-report-preset-target='submitButton' disabled=true - ) = label + ) = preset_id.present? ? '変更' : '登録' li.card-main-actions__item.is-sub .card-main-actions__muted-action( data-action='click->report-preset#closeModal' diff --git a/db/migrate/20261012083419_add_unique_index_to_report_templates_user_id.rb b/db/migrate/20261012083419_add_unique_index_to_report_templates_user_id.rb new file mode 100644 index 00000000000..9206e3372cf --- /dev/null +++ b/db/migrate/20261012083419_add_unique_index_to_report_templates_user_id.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class AddUniqueIndexToReportTemplatesUserId < ActiveRecord::Migration[8.1] + def up + remove_duplicate_report_templates + + remove_index :report_templates, :user_id + add_index :report_templates, :user_id, unique: true + end + + def down + remove_index :report_templates, :user_id + add_index :report_templates, :user_id + end + + private + + def remove_duplicate_report_templates + execute <<~SQL.squish + DELETE FROM report_templates + WHERE id IN ( + SELECT id + FROM ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY user_id + ORDER BY id ASC + ) AS row_number + FROM report_templates + WHERE user_id IS NOT NULL + ) AS ranked_templates + WHERE ranked_templates.row_number > 1 + ) + SQL + end +end diff --git a/db/schema.rb b/db/schema.rb index 6ea6cc132c6..c37af330b3b 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_10_01_020000) do +ActiveRecord::Schema[8.1].define(version: 2026_10_12_083419) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" @@ -831,7 +831,7 @@ t.text "description" t.datetime "updated_at", null: false t.bigint "user_id" - t.index ["user_id"], name: "index_report_templates_on_user_id" + t.index ["user_id"], name: "index_report_templates_on_user_id", unique: true end create_table "reports", id: :serial, force: :cascade do |t| diff --git a/package-lock.json b/package-lock.json index 84077405807..b75f77122bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4765,9 +4765,9 @@ } }, "node_modules/shell-quote": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", - "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz", + "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==", "dev": true, "license": "MIT", "engines": { diff --git a/test/agents/pjord/mention_response_agent_test.rb b/test/agents/pjord/mention_response_agent_test.rb index e243ece9f4e..4f53d14728b 100644 --- a/test/agents/pjord/mention_response_agent_test.rb +++ b/test/agents/pjord/mention_response_agent_test.rb @@ -23,7 +23,7 @@ class Pjord::MentionResponseAgentTest < ActiveSupport::TestCase assert_includes chat.instructions, 'リンク先の内容が返信に不可欠でない場合' assert_includes chat.instructions, 'メンションしてきたユーザーに「見られる状態にしてください」「内容を教えてください」と質問しないでください。' assert_includes chat.asked_message, comment.description - assert_equal [BootcampSearchTool, UserInfoTool, ExternalContentTool], chat.tools + assert_equal [AiTools::BootcampSearchTool, AiTools::UserInfoTool, AiTools::ExternalContentTool], chat.tools assert_equal PjordResponse, chat.schema end diff --git a/test/agents/pjord/report_comment_agent_test.rb b/test/agents/pjord/report_comment_agent_test.rb index 34c312e9ed3..f5afc64eabf 100644 --- a/test/agents/pjord/report_comment_agent_test.rb +++ b/test/agents/pjord/report_comment_agent_test.rb @@ -28,7 +28,7 @@ class Pjord::ReportCommentAgentTest < ActiveSupport::TestCase assert_includes chat.instructions, '日報を書いたユーザーに「見られる状態にしてください」「内容を教えてください」と質問しないでください。' assert_includes chat.asked_message, report.title assert_includes chat.asked_message, report.description - assert_equal [BootcampSearchTool, UserInfoTool, ExternalContentTool], chat.tools + assert_equal [AiTools::BootcampSearchTool, AiTools::UserInfoTool, AiTools::ExternalContentTool], chat.tools assert_equal PjordResponse, chat.schema end diff --git a/test/integration/api/report_presets_test.rb b/test/integration/api/report_presets_test.rb new file mode 100644 index 00000000000..98209911e7c --- /dev/null +++ b/test/integration/api/report_presets_test.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'test_helper' + +class API::ReportPresetsTest < ActionDispatch::IntegrationTest + setup do + @user = users(:kimura) + token = create_token(@user.login_name, 'testtest') + @headers = { 'Authorization' => "Bearer #{token}" } + end + + test 'create report template' do + assert_difference 'ReportPreset.count', 1 do + post api_report_presets_path, + params: { report_preset: { description: 'create test' } }, + headers: @headers, + as: :json + end + + assert_response :ok + + preset = ReportPreset.find(response.parsed_body['id']) + + assert_equal @user.id, preset.user_id + assert_equal 'create test', preset.description + end + + test 'cannot create another report template for the same user' do + existing_preset = ReportPreset.create!( + user: @user, + description: 'existing template' + ) + original_attributes = existing_preset.attributes + + assert_no_difference 'ReportPreset.count' do + post api_report_presets_path, + params: { report_preset: { description: 'another template' } }, + headers: @headers, + as: :json + end + + assert_response :bad_request + assert_equal original_attributes, existing_preset.reload.attributes + end + + test 'update report template' do + preset = ReportPreset.create!( + user: @user, + description: 'existing template' + ) + + assert_no_difference 'ReportPreset.count' do + put api_report_preset_path(preset), + params: { report_preset: { description: 'updated template' } }, + headers: @headers, + as: :json + end + + assert_response :ok + assert_equal 'updated template', preset.reload.description + end +end diff --git a/test/tools/bootcamp_search_tool_test.rb b/test/models/ai_tools/bootcamp_search_tool_test.rb similarity index 91% rename from test/tools/bootcamp_search_tool_test.rb rename to test/models/ai_tools/bootcamp_search_tool_test.rb index 9adb2701c87..f7beab5438f 100644 --- a/test/tools/bootcamp_search_tool_test.rb +++ b/test/models/ai_tools/bootcamp_search_tool_test.rb @@ -2,9 +2,13 @@ require 'test_helper' -class BootcampSearchToolTest < ActiveSupport::TestCase +class AiTools::BootcampSearchToolTest < ActiveSupport::TestCase setup do - @tool = BootcampSearchTool.new + @tool = AiTools::BootcampSearchTool.new + end + + test 'preserves the advertised RubyLLM tool name' do + assert_equal 'bootcamp_search', @tool.name end test 'search practices by keyword' do diff --git a/test/tools/external_content_tool_test.rb b/test/models/ai_tools/external_content_tool_test.rb similarity index 94% rename from test/tools/external_content_tool_test.rb rename to test/models/ai_tools/external_content_tool_test.rb index 2fec5947c97..9c7d3581daa 100644 --- a/test/tools/external_content_tool_test.rb +++ b/test/models/ai_tools/external_content_tool_test.rb @@ -2,10 +2,14 @@ require 'test_helper' -class ExternalContentToolTest < ActiveSupport::TestCase +class AiTools::ExternalContentToolTest < ActiveSupport::TestCase setup do Rails.cache.clear - @tool = ExternalContentTool.new + @tool = AiTools::ExternalContentTool.new + end + + test 'preserves the advertised RubyLLM tool name' do + assert_equal 'external_content', @tool.name end test 'reads regular web pages' do diff --git a/test/tools/user_info_tool_test.rb b/test/models/ai_tools/user_info_tool_test.rb similarity index 86% rename from test/tools/user_info_tool_test.rb rename to test/models/ai_tools/user_info_tool_test.rb index 1857db47395..61fbf9ab2dc 100644 --- a/test/tools/user_info_tool_test.rb +++ b/test/models/ai_tools/user_info_tool_test.rb @@ -2,9 +2,13 @@ require 'test_helper' -class UserInfoToolTest < ActiveSupport::TestCase +class AiTools::UserInfoToolTest < ActiveSupport::TestCase setup do - @tool = UserInfoTool.new + @tool = AiTools::UserInfoTool.new + end + + test 'preserves the advertised RubyLLM tool name' do + assert_equal 'user_info', @tool.name end test 'returns user profile info' do diff --git a/test/tools/external_content/github_reader_test.rb b/test/models/external_content/github_reader_test.rb similarity index 100% rename from test/tools/external_content/github_reader_test.rb rename to test/models/external_content/github_reader_test.rb diff --git a/test/tools/external_content/github_review_reader_test.rb b/test/models/external_content/github_review_reader_test.rb similarity index 100% rename from test/tools/external_content/github_review_reader_test.rb rename to test/models/external_content/github_review_reader_test.rb diff --git a/test/tools/external_content/http_client_test.rb b/test/models/external_content/http_client_test.rb similarity index 69% rename from test/tools/external_content/http_client_test.rb rename to test/models/external_content/http_client_test.rb index c3c2c0b34af..b55173b0764 100644 --- a/test/tools/external_content/http_client_test.rb +++ b/test/models/external_content/http_client_test.rb @@ -104,9 +104,11 @@ class ExternalContent::HttpClientTest < ActiveSupport::TestCase http.stub(:request, ->(_request, &block) { block.call(response) }) do Net::HTTP.stub(:new, http) do Addrinfo.stub(:getaddrinfo, [Addrinfo.ip('93.184.216.34')]) do - assert_raises(ExternalContent::HttpClient::ResponseTooLarge) do + error = assert_raises(ExternalContent::HttpClient::ResponseTooLarge) do ExternalContent::HttpClient.get('https://example.com/large', max_body_bytes: 5) end + assert_equal '200', error.code + assert_equal 'response too large', error.message end end end @@ -122,4 +124,44 @@ class ExternalContent::HttpClientTest < ActiveSupport::TestCase end end end + test 'policy and redirect errors have a narrow type without including supplied URLs' do + url = 'https://example.com/private?secret=fictional' + Addrinfo.stub(:getaddrinfo, [Addrinfo.ip('10.0.0.1')]) do + error = assert_raises(ExternalContent::HttpClient::FetchError) { ExternalContent::HttpClient.get(url) } + assert_kind_of RuntimeError, error + assert_not_includes error.message, url + assert_not_includes error.message, 'secret' + end + Addrinfo.stub(:getaddrinfo, []) do + assert_raises(ExternalContent::HttpClient::FetchError) { ExternalContent::HttpClient.get(url) } + end + Addrinfo.stub(:getaddrinfo, [Addrinfo.ip('93.184.216.34')]) do + stub_request(:get, url).to_return(status: 302) + assert_raises(ExternalContent::HttpClient::FetchError) { ExternalContent::HttpClient.get(url) } + stub_request(:get, url).to_return(status: 302, headers: { 'Location' => url }) + assert_raises(ExternalContent::HttpClient::FetchError) { ExternalContent::HttpClient.get(url) } + end + end + + test 'HTTPS uses peer verification even for the former link checker exception' do + socket = Minitest::Mock.new + socket.expect(:setsockopt, nil, [Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1]) + socket.expect(:close, nil) + WebMock.stub(:net_http_connect_on_start?, true) do + TCPSocket.stub(:open, socket) do + OpenSSL::SSL::SSLSocket.stub(:new, lambda { |_socket, context| + assert_equal OpenSSL::SSL::VERIFY_PEER, context.verify_mode + assert context.verify_hostname + raise OpenSSL::SSL::SSLError, 'certificate verify failed' + }) do + Addrinfo.stub(:getaddrinfo, [Addrinfo.ip('93.184.216.34')]) do + assert_raises(OpenSSL::SSL::SSLError) do + ExternalContent::HttpClient.get('https://www.tablesgenerator.com/markdown_tables') + end + end + end + end + end + socket.verify + end end diff --git a/test/models/link_card/card_test.rb b/test/models/link_card/card_test.rb index d02aef36f74..090245dc95b 100644 --- a/test/models/link_card/card_test.rb +++ b/test/models/link_card/card_test.rb @@ -5,41 +5,47 @@ module LinkCard class CardTest < ActiveSupport::TestCase test '#metadata' do - VCR.use_cassette 'link_card/metadata' do - params = { url: 'https://bootcamp.fjord.jp/faq', tweet: nil } - card = LinkCard::Card.new(params[:url], params[:tweet]) - metadata = card.metadata + with_public_dns do + VCR.use_cassette 'link_card/metadata', record: :none do + params = { url: 'https://bootcamp.fjord.jp/faq', tweet: nil } + card = LinkCard::Card.new(params[:url], params[:tweet]) + metadata = card.metadata - assert_equal 'fjord bootcamp', metadata[:site_name] - assert_equal 'https://bootcamp.fjord.jp', metadata[:site_url] - assert_equal 'FAQ', metadata[:title] - assert_includes metadata[:description], 'フィヨルドブートキャンプに寄せられたよくあるお問い合わせとその回答の一覧です。' + assert_equal 'fjord bootcamp', metadata[:site_name] + assert_equal 'https://bootcamp.fjord.jp', metadata[:site_url] + assert_equal 'FAQ', metadata[:title] + assert_includes metadata[:description], 'フィヨルドブートキャンプに寄せられたよくあるお問い合わせとその回答の一覧です。' + end end end test '#metadata for tweet' do - VCR.use_cassette 'link_card/metadata/tweet' do - params = { url: 'https://x.com/fjordbootcamp/status/1866097842483503117', tweet: '1' } - card = LinkCard::Card.new(params[:url], params[:tweet]) - metadata = JSON.parse(card.metadata, symbolize_names: true) + with_public_dns do + VCR.use_cassette 'link_card/metadata/tweet', record: :none do + params = { url: 'https://x.com/fjordbootcamp/status/1866097842483503117', tweet: '1' } + card = LinkCard::Card.new(params[:url], params[:tweet]) + metadata = JSON.parse(card.metadata, symbolize_names: true) - assert_equal 'フィヨルドブートキャンプ', metadata[:author_name] - assert_equal 'https://twitter.com/fjordbootcamp', metadata[:author_url] - assert_equal 'https://twitter.com/fjordbootcamp/status/1866097842483503117', metadata[:url] - assert_includes metadata[:html], '
12/26(木)19:30から『FBC忘年会2024』を開催します!ご参加お待ちしております😃' + assert_equal 'フィヨルドブートキャンプ', metadata[:author_name] + assert_equal 'https://twitter.com/fjordbootcamp', metadata[:author_url] + assert_equal 'https://twitter.com/fjordbootcamp/status/1866097842483503117', metadata[:url] + assert_includes metadata[:html], '
12/26(木)19:30から『FBC忘年会2024』を開催します!ご参加お待ちしております😃' + end end end test '#metadata does not garble text for specific domains' do - VCR.use_cassette 'link_card/metadata/not_garble_text' do - params = { url: 'https://www.youtube.com/watch?v=8LudKmk7yPM', tweet: nil } - card = LinkCard::Card.new(params[:url], params[:tweet]) - metadata = card.metadata + with_public_dns do + VCR.use_cassette 'link_card/metadata/not_garble_text', record: :none do + params = { url: 'https://www.youtube.com/watch?v=8LudKmk7yPM', tweet: nil } + card = LinkCard::Card.new(params[:url], params[:tweet]) + metadata = card.metadata - assert_equal 'YouTube', metadata[:site_name] - assert_equal 'https://www.youtube.com', metadata[:site_url] - assert_equal '角谷トーク2023 本編', metadata[:title] - assert_includes metadata[:description], 'フィヨルドブートキャンプの顧問、角谷信太郎氏によるプログラミング学習者に向けたトークイベント' + assert_equal 'YouTube', metadata[:site_name] + assert_equal 'https://www.youtube.com', metadata[:site_url] + assert_equal '角谷トーク2023 本編', metadata[:title] + assert_includes metadata[:description], 'フィヨルドブートキャンプの顧問、角谷信太郎氏によるプログラミング学習者に向けたトークイベント' + end end end @@ -57,5 +63,171 @@ class CardTest < ActiveSupport::TestCase end end end + + test '#metadata does not cache a rejected DNS destination' do + url = 'https://example.com/' + store = ActiveSupport::Cache::MemoryStore.new + card = Card.new(url, nil) + html = <<~HTML +
+ + + + + + HTML + stub_request(:get, url).to_return(body: html) + Rails.stub(:cache, store) do + Resolv.stub(:getaddress, '93.184.216.34') do + Addrinfo.stub(:getaddrinfo, [Addrinfo.ip('10.0.0.1')]) do + Net::HTTP.stub(:new, ->(*) { flunk 'unsafe destination reached transport' }) do + assert_nil card.metadata + assert_nil store.read(card.send(:cache_key)) + end + end + Addrinfo.stub(:getaddrinfo, [Addrinfo.ip('93.184.216.34')]) do + metadata = card.metadata + assert metadata + assert_equal 'Recovered', metadata[:title] + end + end + end + assert_requested :get, url, times: 1 + end + + test '#metadata encodes the entire tweet URL as one query parameter and bounds oEmbed' do + url = 'https://x.com/example/status/123?name=日本語&other=value#fragment' + body = { author_name: '日本語', html: '本文
' }.to_json + stub_request(:get, %r{https://publish.twitter.com/oembed}).to_return(status: 503) + stub_request(:get, 'https://publish.twitter.com/oembed').with(query: { url: }).to_return(body: body.b) + original_get = ExternalContent::HttpClient.method(:get) + requests = 0 + ExternalContent::HttpClient.stub(:get, lambda { |target, **options| + requests += 1 + assert_equal 2.megabytes, options[:max_body_bytes] + assert_equal 10, options[:request_timeout] + original_get.call(target, **options) + }) do + with_public_dns do + metadata = Card.new(url, '1').metadata + assert metadata + assert_equal Encoding::UTF_8, metadata.encoding + assert_equal '日本語', JSON.parse(metadata)['author_name'] + end + end + assert_equal 1, requests + end + + test '#metadata does not cache rejected tweet redirects or oversized responses' do + url = 'https://x.com/example/status/123' + store = ActiveSupport::Cache::MemoryStore.new + card = Card.new(url, '1') + oembed = stub_request(:get, 'https://publish.twitter.com/oembed').with(query: { url: }) + oembed.to_return(status: 302, headers: { 'Location' => 'http://127.0.0.1/private' }) + Rails.stub(:cache, store) do + Resolv.stub(:getaddress, '93.184.216.34') do + Addrinfo.stub(:getaddrinfo, lambda { |host, *| + [Addrinfo.ip(host == '127.0.0.1' ? '127.0.0.1' : '93.184.216.34')] + }) do + assert_nil card.metadata + assert_nil store.read(card.send(:cache_key)) + end + oembed.to_return(body: 'x' * (2.megabytes + 1)) + Addrinfo.stub(:getaddrinfo, [Addrinfo.ip('93.184.216.34')]) do + # Keep oversized response bodies out of assertion failure output. + assert card.metadata.nil?, 'oversized retrieval must return nil' # rubocop:disable Minitest/AssertNil + end + assert_nil store.read(card.send(:cache_key)) + oembed.to_return(body: '{"html":"recovered"}') + Addrinfo.stub(:getaddrinfo, [Addrinfo.ip('93.184.216.34')]) { assert_equal '{"html":"recovered"}', card.metadata } + end + end + assert_not_requested :get, 'http://127.0.0.1/private' + assert_requested oembed, times: 3 + end + + test '#metadata fails closed on tweet timeout and invalid UTF8 without caching' do + url = 'https://x.com/example/status/123' + Rails.stub(:cache, ActiveSupport::Cache::MemoryStore.new) do + with_public_dns do + stub_request(:get, 'https://publish.twitter.com/oembed').with(query: { url: }).to_raise(Timeout::Error) + assert_nil Card.new(url, '1').metadata + stub_request(:get, 'https://publish.twitter.com/oembed').with(query: { url: }).to_return(body: "\xFF".b) + assert_nil Card.new(url, '1').metadata + end + end + end + + test '#metadata validates tweet URL syntax without unbounded DNS prechecks' do + ['ftp://example.com/', 'https://user:password@example.com/', 'https://@example.com/', 'http://['].each do |url| + Resolv.stub(:getaddress, ->(*) { flunk 'card must not perform a separate DNS lookup' }) do + Net::HTTP.stub(:new, ->(*) { flunk 'invalid tweet URL reached transport' }) do + assert_nil Card.new(url, '1').metadata + end + end + end + url = 'https://x.com/example/status/123' + stub_request(:get, 'https://publish.twitter.com/oembed').with(query: { url: }).to_return(body: '{}') + Resolv.stub(:getaddress, ->(*) { flunk 'card must not perform a separate DNS lookup' }) do + Addrinfo.stub(:getaddrinfo, [Addrinfo.ip('93.184.216.34')]) do + assert_equal '{}', Card.new(url, '1').metadata + end + end + end + + test '#metadata does not reuse a legacy cache entry for a blocked destination' do + url = 'http://127.0.0.1/' + store = ActiveSupport::Cache::MemoryStore.new + legacy_key = ['link_card', 'metadata', url] + store.write(legacy_key, { title: 'Legacy private response' }) + + Rails.stub(:cache, store) do + Addrinfo.stub(:getaddrinfo, [Addrinfo.ip('127.0.0.1')]) do + Net::HTTP.stub(:new, ->(*) { flunk 'unsafe destination reached transport' }) do + card = Card.new(url, nil) + assert_nil card.metadata + assert_nil store.read(card.send(:cache_key)) + end + end + end + assert_equal({ title: 'Legacy private response' }, store.read(legacy_key)) + end + + test '#metadata refetches legacy public entries once and caches the bounded result' do + url = 'https://example.com/' + store = ActiveSupport::Cache::MemoryStore.new + legacy_key = ['link_card', 'metadata', url] + store.write(legacy_key, { title: 'Legacy unbounded response' }) + html = <<~HTML + + + + + + + HTML + stub_request(:get, url).to_return(body: html) + + Rails.stub(:cache, store) do + with_public_dns do + card = Card.new(url, nil) + metadata = card.metadata + assert_equal 'Fresh bounded response', metadata[:title] + assert_equal url, metadata[:url] + assert_equal metadata, card.metadata + assert_equal metadata, store.read(card.send(:cache_key)) + end + end + assert_requested :get, url, times: 1 + assert_equal({ title: 'Legacy unbounded response' }, store.read(legacy_key)) + end + + private + + def with_public_dns(&block) + Resolv.stub(:getaddress, '93.184.216.34') do + Addrinfo.stub(:getaddrinfo, [Addrinfo.ip('93.184.216.34')], &block) + end + end end end diff --git a/test/models/link_checker/client_test.rb b/test/models/link_checker/client_test.rb index 00c403fe9f7..f2423606a65 100644 --- a/test/models/link_checker/client_test.rb +++ b/test/models/link_checker/client_test.rb @@ -5,45 +5,177 @@ module LinkChecker class ClientTest < ActiveSupport::TestCase test '.request' do - VCR.use_cassette 'link_checker/client/request/example.com' do - assert_equal 200, Client.request('http://example.com/') + with_public_dns do + VCR.use_cassette 'link_checker/client/request/example.com', record: :none do + assert_equal 200, Client.request('http://example.com/') + end end - VCR.use_cassette 'link_checker/client/request/fjord.jp' do - assert_equal 404, Client.request('https://lokka.jp/foo') + with_public_dns do + VCR.use_cassette 'link_checker/client/request/fjord.jp', record: :none do + assert_equal 404, Client.request('https://lokka.jp/foo') + end end - VCR.use_cassette 'link_checker/client/request/foobarbuzzzzzzzzzzzzz.com' do + stub_request(:get, 'http://foobarbuzzzzzzzzzzzzz.com/').to_raise(SocketError) + Addrinfo.stub(:getaddrinfo, ->(*) { raise SocketError }) do assert_not Client.request('http://foobarbuzzzzzzzzzzzzz.com/') end - VCR.use_cassette 'link_checker/client/request/e-words.jp' do - assert_equal 200, Client.request('https://e-words.jp/w/単体テスト.html') + with_public_dns do + VCR.use_cassette 'link_checker/client/request/e-words.jp', record: :none do + assert_equal 200, Client.request('https://e-words.jp/w/単体テスト.html') + end end - VCR.use_cassette 'link_checker/client/request/developer.mozilla.org' do - assert_equal 200, Client.request('https://developer.mozilla.org/ja/docs/Web/JavaScript#Tutorials') + with_public_dns do + VCR.use_cassette 'link_checker/client/request/developer.mozilla.org', record: :none do + assert_equal 200, Client.request('https://developer.mozilla.org/ja/docs/Web/JavaScript#Tutorials') + end end end test '#request' do - VCR.use_cassette 'link_checker/client/request/example.com' do - assert_equal 200, Client.new('http://example.com/').request + with_public_dns do + VCR.use_cassette 'link_checker/client/request/example.com', record: :none do + assert_equal 200, Client.new('http://example.com/').request + end end - VCR.use_cassette 'link_checker/client/request/fjord.jp' do - assert_equal 404, Client.new('https://lokka.jp/foo').request + with_public_dns do + VCR.use_cassette 'link_checker/client/request/fjord.jp', record: :none do + assert_equal 404, Client.new('https://lokka.jp/foo').request + end end - VCR.use_cassette 'link_checker/client/request/foobarbuzzzzzzzzzzzzz.com', record: :once do + stub_request(:get, 'http://foobarbuzzzzzzzzzzzzz.com/').to_raise(SocketError) + Addrinfo.stub(:getaddrinfo, ->(*) { raise SocketError }) do assert_not Client.new('http://foobarbuzzzzzzzzzzzzz.com/').request end - VCR.use_cassette 'link_checker/client/request/e-words.jp' do - assert_equal 200, Client.new('https://e-words.jp/w/単体テスト.html').request + with_public_dns do + VCR.use_cassette 'link_checker/client/request/e-words.jp', record: :none do + assert_equal 200, Client.new('https://e-words.jp/w/単体テスト.html').request + end end - VCR.use_cassette 'link_checker/client/request/developer.mozilla.org' do - assert_equal 200, Client.new('https://developer.mozilla.org/ja/docs/Web/JavaScript#Tutorials').request + with_public_dns do + VCR.use_cassette 'link_checker/client/request/developer.mozilla.org', record: :none do + assert_equal 200, Client.new('https://developer.mozilla.org/ja/docs/Web/JavaScript#Tutorials').request + end end end - test '#request can get a response from the server whose server certificate cannot be verified' do - VCR.use_cassette 'link_checker/client/request/www.tablesgenerator.com' do - assert_equal 200, Client.request('https://www.tablesgenerator.com/markdown_tables') + test '#request fails closed when the TLS certificate cannot be verified' do + url = 'https://www.tablesgenerator.com/markdown_tables' + stub_request(:get, url).to_raise(OpenSSL::SSL::SSLError.new('certificate verify failed')) + + with_public_dns { assert_same false, Client.request(url) } + end + + test '#request rejects private and mixed DNS destinations before transport' do + { + 'http://127.0.0.1/' => ['127.0.0.1'], + 'http://[::ffff:127.0.0.1]/' => ['::ffff:127.0.0.1'], + 'https://example.com/' => ['93.184.216.34', '10.0.0.1'] + }.each do |url, addresses| + Addrinfo.stub(:getaddrinfo, addresses.map { |address| Addrinfo.ip(address) }) do + Net::HTTP.stub(:new, ->(*) { flunk 'unsafe destination reached transport' }) do + assert_same false, Client.request(url) + end + end + end + end + + test '#request rejects invalid schemes and userinfo before DNS or transport' do + ['file:///etc/hosts', 'ftp://example.com/', 'https://user:password@example.com/', 'https://@example.com/', 'http://['].each do |url| + Addrinfo.stub(:getaddrinfo, ->(*) { flunk 'invalid URL reached DNS' }) do + OpenURI.stub(:open_uri, ->(*) { flunk 'invalid URL reached OpenURI transport' }) do + Net::HTTP.stub(:new, ->(*) { flunk 'invalid URL reached transport' }) do + assert_same false, Client.request(url) + end + end + end + end + end + + test '#request rejects unsafe redirects without contacting their destinations' do + stub_request(:get, 'http://127.0.0.1/private').to_return(status: 200) + stub_request(:get, 'https://user:password@example.com/private').to_return(status: 200) + ['http://127.0.0.1/private', 'https://user:password@example.com/private', 'file:///etc/hosts'].each do |destination| + stub_request(:get, 'https://example.com/').to_return(status: 302, headers: { 'Location' => destination }) + Addrinfo.stub(:getaddrinfo, lambda { |host, *| + [Addrinfo.ip(host == '127.0.0.1' ? '127.0.0.1' : '93.184.216.34')] + }) do + assert_same false, Client.request('https://example.com/') + end + end + assert_not_requested :get, 'http://127.0.0.1/private' + assert_not_requested :get, 'https://user:password@example.com/private' + end + + test '#request bounds retrieval and follows relative public redirects' do + stub_request(:get, 'https://example.com/').to_return(status: 302, headers: { 'Location' => '/page' }) + stub_request(:get, 'https://example.com/page').to_return(status: 404) + original_get = ExternalContent::HttpClient.method(:get) + requests = 0 + ExternalContent::HttpClient.stub(:get, lambda { |url, **options| + requests += 1 + assert_equal 2.megabytes, options[:max_body_bytes] + assert_equal 10, options[:request_timeout] + original_get.call(url, **options) + }) do + with_public_dns { assert_equal 404, Client.request('https://example.com/') } + end + assert_equal 1, requests + end + + test '#request preserves the status of oversized final public responses' do + [200, 404].each do |status| + stub_request(:get, 'https://example.com/').to_return(status:, body: 'x' * (2.megabytes + 1)) + + with_public_dns { assert_equal status, Client.request('https://example.com/') } + end + end + + test '#request fails closed when an oversized response has no status' do + ExternalContent::HttpClient.stub(:get, ->(*) { raise ExternalContent::HttpClient::ResponseTooLarge }) do + assert_same false, Client.request('https://example.com/') + end + end + + test '#request rejects oversized redirects without contacting unsafe destinations' do + ['http://127.0.0.1/private', 'https://user:password@example.com/private'].each do |destination| + stub_request(:get, 'https://example.com/').to_return( + status: 302, body: 'x' * (2.megabytes + 1), headers: { 'Location' => destination } + ) + Addrinfo.stub(:getaddrinfo, lambda { |host, *| + [Addrinfo.ip(host == '127.0.0.1' ? '127.0.0.1' : '93.184.216.34')] + }) do + assert_same false, Client.request('https://example.com/') + end end + assert_not_requested :get, 'http://127.0.0.1/private' + assert_not_requested :get, 'https://user:password@example.com/private' + end + + test '#request fails closed on total timeouts' do + ExternalContent::HttpClient.stub(:get, ->(*) { raise Timeout::Error }) do + assert_same false, Client.request('https://example.com/') + end + end + + test '#request does not mask programming errors' do + ExternalContent::HttpClient.stub(:get, ->(*) { raise NoMethodError, 'fictional bug' }) do + assert_raises(NoMethodError) { Client.request('https://example.com/') } + end + end + + test 'checker marks a blocked endpoint as broken without transport' do + link = LinkChecker::Link.new('private', 'http://127.0.0.1/', 'page', 'https://example.com/page') + Addrinfo.stub(:getaddrinfo, [Addrinfo.ip('127.0.0.1')]) do + Net::HTTP.stub(:new, ->(*) { flunk 'unsafe destination reached transport' }) do + assert_same false, Checker.check_response([link]).first.response + end + end + end + + private + + def with_public_dns(&block) + Addrinfo.stub(:getaddrinfo, [Addrinfo.ip('93.184.216.34')], &block) end end end diff --git a/test/models/metadata_test.rb b/test/models/metadata_test.rb index 7b5dd84305f..14086d2e601 100644 --- a/test/models/metadata_test.rb +++ b/test/models/metadata_test.rb @@ -16,8 +16,9 @@ class MetadataTest < ActiveSupport::TestCase }.to_json ) - metadata = Metadata.new(url).fetch + metadata = fetch(url) + assert metadata assert_equal '角谷トーク2023 本編', metadata[:title] assert_equal 'https://i.ytimg.com/vi/8LudKmk7yPM/hqdefault.jpg', metadata[:images] assert_equal 'YouTube', metadata[:site_name] @@ -28,7 +29,7 @@ class MetadataTest < ActiveSupport::TestCase url = 'https://example.com/' stub_request(:get, url).to_raise(SocketError) - assert_nil Metadata.new(url).fetch + assert_nil fetch(url) end test '#fetch returns nil when YouTube oEmbed raises network error' do @@ -38,6 +39,163 @@ class MetadataTest < ActiveSupport::TestCase .with(query: { url:, format: 'json' }) .to_raise(Net::OpenTimeout) - assert_nil Metadata.new(url).fetch + assert_nil fetch(url) + end + test '#fetch rejects unsafe endpoints before transport' do + { + 'http://127.0.0.1/' => ['127.0.0.1'], + 'http://[::ffff:127.0.0.1]/' => ['::ffff:127.0.0.1'], + 'https://example.com/' => ['93.184.216.34', '10.0.0.1'] + }.each do |url, addresses| + Addrinfo.stub(:getaddrinfo, addresses.map { |address| Addrinfo.ip(address) }) do + Net::HTTP.stub(:new, ->(*) { flunk 'unsafe destination reached transport' }) do + assert_nil Metadata.new(url).fetch + end + end + end + end + + test '#fetch rejects invalid schemes and userinfo before DNS or transport' do + ['file:///etc/hosts', 'ftp://example.com/', 'https://user:password@example.com/', 'https://@example.com/', 'http://['].each do |url| + Addrinfo.stub(:getaddrinfo, ->(*) { flunk 'invalid URL reached DNS' }) do + Net::HTTP.stub(:new, ->(*) { flunk 'invalid URL reached transport' }) do + assert_nil Metadata.new(url).fetch + end + end + end + end + + test '#fetch rejects unsafe redirects without retrying YouTube oEmbed' do + url = 'https://www.youtube.com/watch?v=example' + stub_request(:get, %r{https://www.youtube.com/oembed}).to_return(status: 503) + ['http://127.0.0.1/private', 'https://user:password@example.com/private', 'file:///etc/hosts'].each do |destination| + stub_request(:get, url).to_return(status: 302, headers: { 'Location' => destination }) + Addrinfo.stub(:getaddrinfo, lambda { |host, *| + [Addrinfo.ip(host == '127.0.0.1' ? '127.0.0.1' : '93.184.216.34')] + }) do + assert_nil Metadata.new(url).fetch + end + end + assert_not_requested :get, 'http://127.0.0.1/private' + assert_not_requested :get, %r{https://www.youtube.com/oembed} + end + + test '#fetch preserves Unicode URL and Japanese metadata through a relative redirect' do + url = 'https://example.com/日本語' + stub_request(:get, url).to_return(status: 302, headers: { 'Location' => '/page' }) + stub_request(:get, 'https://example.com/page').to_return(body: html, headers: { 'Content-Type' => 'text/html; charset=UTF-8' }) + + metadata = fetch(url) + + assert metadata + assert_equal '日本語の題名', metadata[:title] + assert_equal '日本語の説明', metadata[:description] + assert_equal url, metadata[:url] + assert_equal 'https://example.com/favicon.ico', metadata[:favicon] + end + + test '#fetch decodes declared non UTF8 HTML and HTML meta charset' do + ['text/html; charset="Shift_JIS"', 'text/html'].each do |content_type| + body = html.sub('', '').encode(Encoding::Shift_JIS).b + stub_request(:get, 'https://example.com/').to_return(body:, headers: { 'Content-Type' => content_type }) + + assert_equal '日本語の題名', fetch('https://example.com/')[:title] + end + end + + test '#fetch decodes CP932 extensions declared as Shift JIS and its aliases' do + %w[Shift_JIS SJIS].each do |charset| + body = html.sub('日本語の題名', '日本語の題名①~').encode(Encoding::Windows_31J).b + stub_request(:get, 'https://example.com/').to_return(body:, headers: { 'Content-Type' => "text/html; charset=#{charset}" }) + + metadata = fetch('https://example.com/') + + assert metadata + assert_equal '日本語の題名①~', metadata[:title] + assert_equal '日本語の説明', metadata[:description] + end + end + + test '#fetch replaces malformed bytes in declared encodings while preserving metadata' do + [Encoding::Windows_31J, Encoding::UTF_8].each do |encoding| + body = html.encode(encoding).b.sub('content="'.b, 'content="'.b + "\xFF".b) + stub_request(:get, 'https://example.com/').to_return(body:, headers: { 'Content-Type' => "text/html; charset=#{encoding.name}" }) + + metadata = fetch('https://example.com/') + + assert metadata + assert_equal "\uFFFD日本語の題名", metadata[:title] + assert_equal '日本語の説明', metadata[:description] + end + end + + test '#fetch handles UTF8 HTML without a charset and ignores unknown declared charsets' do + ['text/html', 'text/html; charset=unknown-encoding'].each do |content_type| + stub_request(:get, 'https://example.com/').to_return(body: html.b, headers: { 'Content-Type' => content_type }) + + assert_equal '日本語の題名', fetch('https://example.com/')[:title] + end + end + + test '#fetch bounds both HTML and YouTube oEmbed requests' do + url = 'https://www.youtube.com/watch?v=example' + stub_request(:get, url).to_return(status: 403) + stub_request(:get, 'https://www.youtube.com/oembed').with(query: { url:, format: 'json' }) + .to_return(body: { title: '日本語の題名' }.to_json) + original_get = ExternalContent::HttpClient.method(:get) + requests = [] + ExternalContent::HttpClient.stub(:get, lambda { |target, **options| + requests << target.to_s + assert_equal 2.megabytes, options[:max_body_bytes] + assert_equal 10, options[:request_timeout] + original_get.call(target, **options) + }) do + assert_equal '日本語の題名', fetch(url)[:title] + end + assert_equal 2, requests.size + end + + test '#fetch rejects oversized HTML and oEmbed without fallback after a rejected page' do + url = 'https://www.youtube.com/watch?v=example' + oembed = stub_request(:get, 'https://www.youtube.com/oembed').with(query: { url:, format: 'json' }) + stub_request(:get, url).to_return(body: html + (' ' * 2.megabytes)) + + # Keep oversized response bodies out of assertion failure output. + assert fetch(url).nil?, 'oversized retrieval must return nil' # rubocop:disable Minitest/AssertNil + assert_not_requested oembed + + stub_request(:get, url).to_return(status: 403) + oembed.to_return(body: { title: 'x' * 2.megabytes }.to_json) + # Keep oversized response bodies out of assertion failure output. + assert fetch(url).nil?, 'oversized retrieval must return nil' # rubocop:disable Minitest/AssertNil + end + + test '#fetch fails closed on timeouts but does not hide programming errors' do + stub_request(:get, 'https://example.com/').to_return(body: html) + ExternalContent::HttpClient.stub(:get, ->(*) { raise Timeout::Error }) do + assert_nil fetch('https://example.com/') + end + ExternalContent::HttpClient.stub(:get, ->(*) { raise NoMethodError, 'fictional bug' }) do + assert_raises(NoMethodError) { fetch('https://example.com/') } + end + end + + private + + def fetch(url) + Addrinfo.stub(:getaddrinfo, [Addrinfo.ip('93.184.216.34')]) { Metadata.new(url).fetch } + end + + def html + <<~HTML + + + + + + + + + HTML end end diff --git a/test/models/report_preset_test.rb b/test/models/report_preset_test.rb new file mode 100644 index 00000000000..eae42eec69c --- /dev/null +++ b/test/models/report_preset_test.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ReportPresetTest < ActiveSupport::TestCase + test 'cannot register another template for the same user' do + preset = report_presets(:report_preset1) + duplicate = ReportPreset.new( + user: preset.user, + description: 'another template' + ) + + assert_not duplicate.valid? + assert duplicate.errors.of_kind?(:user_id, :taken) + end + + test 'database rejects duplicate templates without validation' do + preset = report_presets(:report_preset1) + duplicate = ReportPreset.new( + user: preset.user, + description: 'another template' + ) + + assert_raises ActiveRecord::RecordNotUnique do + ReportPreset.transaction(requires_new: true) do + duplicate.save!(validate: false) + end + end + end +end diff --git a/test/system/articles/share_test.rb b/test/system/articles/share_test.rb index 0e78d857b26..d34c073e722 100644 --- a/test/system/articles/share_test.rb +++ b/test/system/articles/share_test.rb @@ -24,8 +24,13 @@ class ShareTest < ApplicationSystemTestCase test 'share button Hatena' do visit "/articles/#{@article.id}" + article_url = "https://bootcamp.fjord.jp/articles/#{@article.id}" + entry_urls = [ + "https://b.hatena.ne.jp/entry/s/bootcamp.fjord.jp/articles/#{@article.id}#bbutton", + "https://b.hatena.ne.jp/entry?url=#{URI.encode_www_form_component(article_url)}#bbutton" + ] within_frame(first('.hatena-bookmark-button-frame')) do - assert_selector "a[href='https://b.hatena.ne.jp/entry/s/bootcamp.fjord.jp/articles/#{@article.id}#bbutton']" + assert_selector entry_urls.map { |url| "a[href='#{url}']" }.join(', ') end end diff --git a/test/system/report_presets_test.rb b/test/system/report_presets_test.rb index 30ce183103b..76c25bb4686 100644 --- a/test/system/report_presets_test.rb +++ b/test/system/report_presets_test.rb @@ -6,21 +6,28 @@ class ReportPresetsTest < ApplicationSystemTestCase test 'register template' do visit_with_auth '/reports/new', 'mentormentaro' click_button 'テンプレート登録' - fill_in('report_preset[description]', with: 'create test') + fill_in 'report_preset[description]', with: 'create test' click_button '登録' - visit_with_auth '/reports/new', 'mentormentaro' - click_button 'テンプレート変更' - assert_text 'create test' + assert_text 'テンプレートを登録しました!' + assert_no_button 'テンプレート登録' + assert_button 'テンプレート変更' + assert_button 'テンプレートを反映する' + fill_in 'report[description]', with: '' + click_button 'テンプレートを反映する' + assert_field 'report[description]', with: 'create test' end test 'update template' do visit_with_auth '/reports/new', 'hajime' click_button 'テンプレート変更' - fill_in('report_preset[description]', with: 'update test') + fill_in 'report_preset[description]', with: 'update test' click_button '変更' - visit_with_auth '/reports/new', 'hajime' + assert_text 'テンプレートを変更しました!' + fill_in 'report[description]', with: '' + click_button 'テンプレートを反映する' + assert_field 'report[description]', with: 'update test' click_button 'テンプレート変更' - assert_text 'update test' + assert_field 'report_preset[description]', with: 'update test' end test 'new report is replaced with template' do @@ -31,7 +38,7 @@ class ReportPresetsTest < ApplicationSystemTestCase test 'replace button works' do visit_with_auth '/reports/new', 'hajime' fill_in('report[description]', with: '') - find('a', text: 'テンプレートを反映する').click + click_button 'テンプレートを反映する' assert_text 'template test hajime' end @@ -47,7 +54,7 @@ class ReportPresetsTest < ApplicationSystemTestCase fill_in('report_preset[description]', with: 'update test') find('div.card-main-actions__muted-action', text: 'キャンセル').click click_button 'テンプレート変更' - textarea = find('#js-template-content') + textarea = find('#js-preset-content') assert_equal 'template test hajime', textarea.value end end