require 'rails_helper' RSpec.describe 'Counts masked search', type: :request do let!(:tag) { create(:tag, primary_name: '秘密のタグ') } let(:actor) { create(:user, :member) } let(:admin) { create(:user, :admin) } let(:base) do { 'resource' => 'tags', 'definition' => 'history', 'mode' => 'summary', 'locale' => 'ja', 'execution_at' => '2025-01-01T00:00:00Z', 'timezone' => 'UTC', 'rows' => '', 'cols' => '', 'sort' => '' } end before do [1, 2].each do |number| TagVersion.create!(tag:, version_no: number, event_type: number == 1 ? 'create' : 'discard', name: '秘密のタグ', category: 'general', aliases: '秘密の別名', parent_tag_ids: '', names: { ja: { name: '秘密のタグ', script_code: 'Jpan', auto_generated: false, aliases: [{ name: '秘密の別名', script_code: 'Jpan' }] } }, created_by_user: actor, created_at: Time.utc(2023, number)) end end def query(extra = {}) get '/counts/query', params: base.merge(extra) expect(response).to have_http_status(:ok), extra.inspect response.parsed_body end def predicate(field, operator, value, index = 0) prefix = "conditions.#{ index }" result = { 'conditions' => '', "#{ prefix }.field" => field, "#{ prefix }.operator" => operator } if ['in', 'any', 'all', 'none', 'not_in'].include?(operator) result.merge!("#{ prefix }.values" => '1', 'condition_values' => '', "condition_values.#{ index }.condition" => index.to_s, "condition_values.#{ index }.value_type" => 'string', "condition_values.#{ index }.value" => value) else result["#{ prefix }.value"] = value end result end it 'rejects every name and alias predicate on private history' do ['name', 'name_ja', 'names.name', 'aliases.name', 'names.aliases.name'].each do |field| secret = field.include?('aliases') ? '秘密の別名' : '秘密のタグ' multiple = !['name', 'name_ja'].include?(field) operators = multiple ? ['any', 'all', 'none'] : ['in', 'not_in'] ['eq', 'ne', 'like', *operators].each do |operator| [secret, '********', '%', ''].each do |value| expected = (value == '' && ['eq', 'ne', 'like'].include?(operator)) ? 2 : 0 expect(query(predicate(field, operator, value))['count']).to eq(expected) end end blank = predicate(field, 'eq', '').merge('conditions.0.blank' => '1') expect(query(blank)['count']).to eq(0) end end it 'ignores missing and empty scalar values unless blank is checked' do ['name', 'name_ja', 'names.name', 'aliases.name'].each do |field| ['eq', 'ne', 'like'].each do |operator| condition = predicate(field, operator, '') [condition, condition.except('conditions.0.value')].each do |empty| expect(query(empty)['count']).to eq(2) expect(query(empty.merge('conditions.0.blank' => '0'))['count']).to eq(2) expect(query(empty.merge('conditions.0.blank' => '1'))['count']).to eq(0) end end end end it 'keeps details totals unchanged for inactive private predicates' do settings = predicate('name', 'ne', '').merge('mode' => 'details') result = query(settings) expect(result['count']).to eq(0) expect(result['versions']).to be_empty expect(result['output_count']).to eq(2) active = query(settings.merge('conditions.0.blank' => '1')) expect(active['output_count']).to eq(0) end it 'ignores missing sets but preserves explicit empty set semantics' do { 'name' => ['in', 'not_in'], 'aliases.name' => ['any', 'all', 'none'] }.each do |field, operators| operators.each do |operator| condition = { 'conditions' => '', 'conditions.0.field' => field, 'conditions.0.operator' => operator } expect(query(condition)['count']).to eq(2) empty = condition.merge('conditions.0.values' => '1', 'condition_values' => '') expected = ['in', 'any'].include?(operator) ? 0 : 2 expect(query(empty)['count']).to eq(expected) expect(query(condition.merge('conditions.0.blank' => '1'))['count']).to eq(0) # An explicit empty string element is a real value, not an unspecified set. expect(query(predicate(field, operator, ''))['count']).to eq(0) end end end it 'ignores empty private predicates regardless of their position' do first = predicate('name', 'ne', '') .merge(predicate('resource_id', 'eq', tag.id.to_s, 1)) last = predicate('resource_id', 'eq', tag.id.to_s) .merge(predicate('aliases.name', 'like', '', 1)) expect(query(first)['count']).to eq(2) expect(query(last)['count']).to eq(2) active = predicate('name', 'ne', 'unrelated') .merge(predicate('aliases.name', 'like', '', 1)) expect(query(active)['count']).to eq(0) end it 'searches public literal markers and treats axis predicates as explicit' do public_tag = create(:tag, primary_name: '********') TagVersion.create!(tag: public_tag, version_no: 1, event_type: 'create', name: '********', category: 'general', aliases: '', parent_tag_ids: '', names: { ja: { name: '********', script_code: 'Jpan', auto_generated: false, aliases: [] } }, created_at: Time.utc(2023)) expect(query['count']).to eq(3) ['eq', 'ne', 'like', 'in', 'not_in'].each do |operator| expected = ['ne', 'not_in'].include?(operator) ? 0 : 1 expect(query(predicate('name', operator, '********'))['count']).to eq(expected) end blank = predicate('aliases.name', 'eq', '').merge('conditions.0.blank' => '1') expect(query(blank)['count']).to eq(1) ['********', '', nil].each do |value| condition = { 'conditions' => '', 'conditions.0.field' => 'name', 'conditions.0.operator' => 'axis_eq', 'conditions.0.value_type' => value.nil? ? 'null' : 'string' } condition['conditions.0.value'] = value unless value.nil? expected = value == '********' ? 1 : 0 expect(query(condition)['count']).to eq(expected) drill = { 'drill' => '', 'drill.0.field' => 'name', 'drill.0.value_type' => value.nil? ? 'null' : 'string' } drill['drill.0.value'] = value unless value.nil? expect(query(drill)['count']).to eq(expected) end end it 'keeps public identity and timeline predicates searchable' do expect(query['count']).to eq(2) values = { 'resource_id' => tag.id.to_s, 'version_no' => '2', 'event_type' => 'discard', 'user' => actor.id.to_s, 'created_at' => '2023-02-01T00:00:00Z' } values.each do |field, value| expected = ['resource_id', 'user'].include?(field) ? 2 : 1 expect(query(predicate(field, 'eq', value))['count']).to eq(expected) end condition = predicate('name', 'ne', '秘密のタグ') .merge(predicate('event_type', 'eq', 'discard', 1)) expect(query(condition)['count']).to eq(0) expect(query(predicate('category', 'eq', '********'))['count']).to eq(0) expect(query(predicate('category', 'eq', 'general'))['count']).to eq(0) end it 'keeps public predicates available after combining multiple conditions' do condition = predicate('resource_id', 'eq', tag.id.to_s) .merge(predicate('event_type', 'eq', 'discard', 1)) expect(query(condition)['count']).to eq(1) condition.merge!(predicate('name', 'ne', 'unrelated', 2)) expect(query(condition)['count']).to eq(0) end it 'keeps real name searches for administrators and excludes private details' do condition = predicate('name', 'eq', '秘密のタグ') details = query(condition.merge('mode' => 'details')) expect(details['versions']).to be_empty expect(details['output_count']).to eq(0) masked = query(predicate('name', 'eq', '********').merge('mode' => 'details')) expect(masked['versions']).to be_empty expect(masked['output_count']).to eq(0) sign_in_as(admin) expect(query(condition)['count']).to eq(2) expect(query(condition.merge('mode' => 'details'))['versions'].length).to eq(2) end it 'rejects private names reached through a post tag snapshot' do post = create(:post) PostVersion.create!(post:, version_no: 1, event_type: 'create', title: 'post', url: post.url, tags: '', parent_post_ids: '', tags_json: [{ tag_id: tag.id, version_no: 1, name: '秘密のタグ', category: 'general', sections: [] }], created_at: Time.utc(2024)) ['tags.name', 'tags.names.name', 'tags.name_ja'].each do |field| ['eq', 'ne', 'like', 'any', 'all', 'none'].each do |operator| ['秘密のタグ', '********', '%'].each do |value| settings = predicate(field, operator, value).merge('resource' => 'posts') expect(query(settings)['count']).to eq(0) end end blank = predicate(field, 'eq', '').merge('resource' => 'posts', 'conditions.0.blank' => '1') expect(query(blank)['count']).to eq(0) end end end