indexmap 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 513c19d20a8373690c66fa99a0251db934298589a19139c2b77654340ce7006a
4
- data.tar.gz: '087bd725e6ecaf81ff07510f78f7c5fa1d613730cd7b735c9f0ed60ecb3f4676'
3
+ metadata.gz: 38fa952c31358e79a900d348041a2475d5f597d19652837b50d27713f2004250
4
+ data.tar.gz: 6889768f6be1e01b6de1938687a757b050a52f7f9dbf2af58d1818cca2fdc977
5
5
  SHA512:
6
- metadata.gz: 2f4065163991559ca5a1df38f1f01aafbdc539936f40aa81d78a3c5aa4abe34bdbc7cdb0df25d970a8a243e1b552c1946ec97c640f8d64350a46ef0d8de36006
7
- data.tar.gz: d0ebff2036cefb30689a22ebddf313c89ac45a6dda11277a4a5322b6c39c6257e6d05e3615e0167ca876f985b14dc5ac21eba096f947876ab72be32bc6a73c33
6
+ metadata.gz: 612858ebdac07d01107af653411182685e161b1e501670e569d84265e1dbcd455dbb6a7e55615f802d112037875d9eb15d94c11747a2c15e6cbd1b9f63b804de
7
+ data.tar.gz: 512df6e55dbad711516e9558cda07b0b90b65f7f950441e36699fe696965296690c52a478247b707ff9e15801b49b73a5d3428cea92dd1e0b49e9861c14580d2
data/CHANGELOG.md CHANGED
@@ -5,12 +5,12 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [0.4.1] - 2026-04-22
8
+ ## [0.5.0] - 2026-04-24
9
9
 
10
10
 
11
- ### Added
11
+ ### Fixed
12
12
 
13
- - add output to ping tasks (#6)
13
+ - namespace rake tasks and harden sitemap validation (#8)
14
14
 
15
15
 
16
16
 
data/README.md CHANGED
@@ -80,12 +80,12 @@ end
80
80
  Then run:
81
81
 
82
82
  ```bash
83
- bin/rails sitemap:create
84
- bin/rails sitemap:format
85
- bin/rails sitemap:validate
83
+ bin/rails indexmap:sitemap:create
84
+ bin/rails indexmap:sitemap:format
85
+ bin/rails indexmap:sitemap:validate
86
86
  ```
87
87
 
88
- `sitemap:create` is the main task. It writes sitemap files, formats them, and validates the result.
88
+ `indexmap:sitemap:create` is the main task. It writes sitemap files, formats them, and validates the result.
89
89
 
90
90
  ### Default Index Mode
91
91
 
@@ -129,8 +129,15 @@ Indexmap::Validator.new.validate!
129
129
  The built-in validator checks for:
130
130
 
131
131
  - missing sitemap files
132
+ - malformed sitemap XML
133
+ - empty sitemap files
134
+ - missing or duplicate child sitemap references
132
135
  - duplicate sitemap URLs
133
136
  - parameterized URLs in sitemap entries
137
+ - fragment URLs in sitemap entries
138
+ - non-HTTP or relative URLs
139
+ - URLs outside the configured `base_url`
140
+ - invalid `lastmod` values
134
141
 
135
142
  ## Search Engine Ping
136
143
 
@@ -139,11 +146,11 @@ The built-in validator checks for:
139
146
  Available rake tasks:
140
147
 
141
148
  ```bash
142
- bin/rails sitemap:validate
143
- bin/rails sitemap:google:ping
144
- bin/rails sitemap:index_now:ping
145
- bin/rails sitemap:index_now:write_key
146
- bin/rails sitemap:ping
149
+ bin/rails indexmap:sitemap:validate
150
+ bin/rails indexmap:google:ping
151
+ bin/rails indexmap:index_now:ping
152
+ bin/rails indexmap:index_now:write_key
153
+ bin/rails indexmap:ping
147
154
  ```
148
155
 
149
156
  ### Google Search Console
@@ -156,7 +163,7 @@ Indexmap.configure do |config|
156
163
  end
157
164
  ```
158
165
 
159
- If `config.google.credentials` is blank, `sitemap:google:ping` skips Google submission.
166
+ If `config.google.credentials` is blank, `indexmap:google:ping` skips Google submission.
160
167
 
161
168
  You can optionally override the Search Console property identifier:
162
169
 
@@ -184,21 +191,21 @@ Indexmap.configure do |config|
184
191
  end
185
192
  ```
186
193
 
187
- If `config.index_now.key` is set, `sitemap:create` also writes the matching `public/<key>.txt` verification file automatically.
194
+ If `config.index_now.key` is set, `indexmap:sitemap:create` also writes the matching `public/<key>.txt` verification file automatically.
188
195
 
189
196
  If you prefer the file-based flow, run:
190
197
 
191
198
  ```bash
192
- bin/rails sitemap:index_now:write_key
199
+ bin/rails indexmap:index_now:write_key
193
200
  ```
194
201
 
195
202
  That task:
196
203
 
197
204
  - reuses an existing valid key file when present
198
205
  - otherwise generates a new key in `public/<key>.txt`
199
- - makes that key available to `sitemap:index_now:ping` without adding `config.index_now.key`
206
+ - makes that key available to `indexmap:index_now:ping` without adding `config.index_now.key`
200
207
 
201
- If neither a configured key nor a valid key file is present, `sitemap:index_now:ping` skips IndexNow submission.
208
+ If neither a configured key nor a valid key file is present, `indexmap:index_now:ping` skips IndexNow submission.
202
209
 
203
210
  ## Development
204
211
 
@@ -38,7 +38,7 @@ module Indexmap
38
38
  sitemap_url = URI.join(host, File.basename(sitemap_file)).to_s
39
39
 
40
40
  unless authorized?
41
- logger.error("Google Search Console does not have access to the site: #{root_domain}")
41
+ logger.debug("Google Search Console does not have access to the site: #{root_domain}")
42
42
  return {
43
43
  status: :failed,
44
44
  reason: :unauthorized,
@@ -62,7 +62,7 @@ module Indexmap
62
62
  end
63
63
 
64
64
  def authorized?
65
- webmasters_service.list_sites.site_entry.any? { |site| site.site_url.include?(root_domain) }
65
+ @authorized ||= accessible_site_urls.include?(property_identifier)
66
66
  end
67
67
 
68
68
  def property_identifier
@@ -104,6 +104,10 @@ module Indexmap
104
104
  failures: failures
105
105
  }
106
106
  end
107
+
108
+ def accessible_site_urls
109
+ @accessible_site_urls ||= Array(webmasters_service.list_sites.site_entry).map(&:site_url)
110
+ end
107
111
  end
108
112
  end
109
113
  end
@@ -8,6 +8,8 @@ require "time"
8
8
  module Indexmap
9
9
  module Pinger
10
10
  class IndexNow < Base
11
+ KEY_FORMAT = /\A[a-f0-9]{32}\z/
12
+
11
13
  def initialize(configuration: Indexmap.configuration, connection: nil)
12
14
  super(configuration: configuration)
13
15
  @connection = connection
@@ -41,20 +43,21 @@ module Indexmap
41
43
  end
42
44
 
43
45
  def write_key_file(key: index_now_configuration.key, path: nil)
44
- key = key.to_s.strip
46
+ key = normalized_configured_key(key)
45
47
  return if key.empty?
46
48
 
47
49
  path ||= index_now_configuration.key_path(public_path: configuration.public_path, key: key)
48
50
  FileUtils.mkdir_p(path.dirname)
49
- File.write(path, "#{key}\n")
51
+ File.write(path, key)
50
52
  path
51
53
  end
52
54
 
53
55
  def ensure_key_file
54
- configured_key = index_now_configuration.key.to_s.strip
56
+ configured_key = normalized_configured_key(index_now_configuration.key)
55
57
  return write_key_file(key: configured_key) unless configured_key.empty?
56
58
 
57
- return existing_key_file if existing_key_file
59
+ existing_path = existing_key_file
60
+ return existing_path if existing_path
58
61
 
59
62
  key = generated_key
60
63
  write_key_file(key: key, path: configuration.public_path.join("#{key}.txt"))
@@ -143,6 +146,9 @@ module Indexmap
143
146
 
144
147
  def submit_batch(api_key:, urls:)
145
148
  payload = {host: hostname, key: api_key, urlList: urls}
149
+ location = key_location(api_key: api_key)
150
+ payload[:keyLocation] = location if location
151
+
146
152
  response = index_now_connection.post("/indexnow") do |request|
147
153
  request.headers["Content-Type"] = "application/json"
148
154
  request.body = payload.to_json
@@ -168,30 +174,50 @@ module Indexmap
168
174
  end
169
175
 
170
176
  def read_api_key
171
- configured_key = index_now_configuration.key.to_s.strip
177
+ configured_key = normalized_configured_key(index_now_configuration.key)
172
178
  return configured_key unless configured_key.empty?
173
179
 
174
- existing_key_file&.read&.strip
180
+ existing_key_file&.read
175
181
  end
176
182
 
177
183
  def existing_key_file
178
184
  configured_path = index_now_configuration.key_path(public_path: configuration.public_path)
179
185
  return configured_path if valid_key_file?(configured_path)
180
186
 
181
- configuration.public_path.glob("*.txt").find { |file| valid_key_file?(file) }
187
+ configuration.public_path.glob("*.txt").sort.find { |file| valid_key_file?(file) }
188
+ end
189
+
190
+ def key_location(api_key:)
191
+ path = index_now_configuration.key_path(public_path: configuration.public_path, key: api_key) || existing_key_file
192
+ return unless path
193
+
194
+ public_path = configuration.public_path.expand_path
195
+ key_path = path.expand_path
196
+ relative_path = key_path.relative_path_from(public_path)
197
+
198
+ URI.join("#{host}/", relative_path.to_s).to_s
199
+ rescue ArgumentError
200
+ nil
182
201
  end
183
202
 
184
203
  def valid_key_file?(path)
185
204
  return false unless path&.file?
186
205
 
187
206
  filename = path.basename(".txt").to_s
188
- return false unless filename.match?(/\A[a-zA-Z0-9-]{8,128}\z/)
207
+ return false unless filename.match?(KEY_FORMAT)
189
208
 
190
- path.read.strip == filename
209
+ path.read == filename
191
210
  end
192
211
 
193
212
  def generated_key
194
- SecureRandom.uuid
213
+ SecureRandom.hex(16)
214
+ end
215
+
216
+ def normalized_configured_key(value)
217
+ key = value.to_s.strip
218
+ return key if key.empty? || key.match?(KEY_FORMAT)
219
+
220
+ raise ConfigurationError, "IndexNow key must be a 32-character lowercase hexadecimal string"
195
221
  end
196
222
 
197
223
  def summarize_results(results)
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "nokogiri"
4
+ require "date"
5
+ require "time"
6
+ require "uri"
7
+
3
8
  module Indexmap
4
9
  class Validator
5
10
  def initialize(configuration: Indexmap.configuration, path: nil)
@@ -14,9 +19,15 @@ module Indexmap
14
19
  )
15
20
  raise ValidationError, "Missing sitemap file: #{sitemap_path}" unless File.exist?(sitemap_path)
16
21
 
22
+ validate_sitemap_file!(sitemap_path)
17
23
  entries = Parser.new(path: sitemap_path).entries
24
+ validate_presence!(entries)
18
25
  validate_duplicates!(entries)
19
26
  validate_parameterized_urls!(entries)
27
+ validate_fragment_urls!(entries)
28
+ validate_absolute_http_urls!(entries)
29
+ validate_same_host_urls!(entries)
30
+ validate_lastmods!(entries)
20
31
  true
21
32
  end
22
33
 
@@ -24,6 +35,65 @@ module Indexmap
24
35
 
25
36
  attr_reader :configuration, :path
26
37
 
38
+ def validate_sitemap_file!(sitemap_path)
39
+ document = read_xml_document(sitemap_path)
40
+ root_name = document.root&.name
41
+
42
+ case root_name
43
+ when "urlset"
44
+ validate_urlset_document!(document, sitemap_path)
45
+ when "sitemapindex"
46
+ validate_sitemap_index_document!(document, sitemap_path)
47
+ else
48
+ raise ValidationError, "Invalid sitemap root element in #{sitemap_path}: #{root_name || "none"}"
49
+ end
50
+ end
51
+
52
+ def read_xml_document(file_path)
53
+ document = Nokogiri::XML(File.read(file_path, encoding: "UTF-8")) { |config| config.strict }
54
+ document.remove_namespaces!
55
+ document
56
+ rescue Nokogiri::XML::SyntaxError => error
57
+ raise ValidationError, "Invalid sitemap XML in #{file_path}: #{error.message.lines.first.strip}"
58
+ end
59
+
60
+ def validate_urlset_document!(document, sitemap_path)
61
+ return if document.xpath("/urlset/url/loc").any?
62
+
63
+ raise ValidationError, "Sitemap has no URLs: #{sitemap_path}"
64
+ end
65
+
66
+ def validate_sitemap_index_document!(document, sitemap_path)
67
+ child_locations = document.xpath("/sitemapindex/sitemap/loc").map { |node| node.text.to_s.strip }.reject(&:empty?)
68
+ raise ValidationError, "Sitemap index has no child sitemap URLs: #{sitemap_path}" if child_locations.empty?
69
+
70
+ duplicate_children = child_locations.group_by(&:itself).select { |_loc, values| values.size > 1 }.keys
71
+ unless duplicate_children.empty?
72
+ raise ValidationError, "Duplicate child sitemap URLs detected: #{duplicate_children.first(5).join(", ")}"
73
+ end
74
+
75
+ child_locations.each do |location|
76
+ child_path = local_child_path(sitemap_path, location)
77
+ raise ValidationError, "Missing child sitemap file: #{child_path}" unless File.exist?(child_path)
78
+
79
+ validate_sitemap_file!(child_path)
80
+ end
81
+ end
82
+
83
+ def local_child_path(sitemap_path, location)
84
+ uri = URI.parse(location)
85
+ filename = (uri.absolute? || location.start_with?("/")) ? File.basename(uri.path) : location
86
+ File.expand_path(filename, File.dirname(sitemap_path))
87
+ rescue URI::InvalidURIError
88
+ File.expand_path(location, File.dirname(sitemap_path))
89
+ end
90
+
91
+ def validate_presence!(entries)
92
+ return unless entries.empty?
93
+
94
+ raise ValidationError, "Sitemap has no URLs"
95
+ end
96
+
27
97
  def validate_duplicates!(entries)
28
98
  duplicates = entries.map(&:loc).group_by(&:itself).select { |_url, values| values.size > 1 }.keys
29
99
  return if duplicates.empty?
@@ -37,5 +107,55 @@ module Indexmap
37
107
 
38
108
  raise ValidationError, "Parameterized sitemap URLs detected: #{param_urls.first(5).join(", ")}"
39
109
  end
110
+
111
+ def validate_fragment_urls!(entries)
112
+ fragment_urls = entries.map(&:loc).select { |url| parse_uri(url)&.fragment }
113
+ return if fragment_urls.empty?
114
+
115
+ raise ValidationError, "Fragment sitemap URLs detected: #{fragment_urls.first(5).join(", ")}"
116
+ end
117
+
118
+ def validate_absolute_http_urls!(entries)
119
+ invalid_urls = entries.map(&:loc).reject do |url|
120
+ uri = parse_uri(url)
121
+ uri&.absolute? && %w[http https].include?(uri.scheme)
122
+ end
123
+ return if invalid_urls.empty?
124
+
125
+ raise ValidationError, "Invalid sitemap URLs detected: #{invalid_urls.first(5).join(", ")}"
126
+ end
127
+
128
+ def validate_same_host_urls!(entries)
129
+ base_uri = parse_uri(configuration.base_url)
130
+ return unless base_uri&.host
131
+
132
+ invalid_urls = entries.map(&:loc).reject do |url|
133
+ uri = parse_uri(url)
134
+ uri&.host == base_uri.host && uri&.scheme == base_uri.scheme && uri&.port == base_uri.port
135
+ end
136
+ return if invalid_urls.empty?
137
+
138
+ raise ValidationError, "Sitemap URLs outside configured base URL detected: #{invalid_urls.first(5).join(", ")}"
139
+ end
140
+
141
+ def validate_lastmods!(entries)
142
+ invalid_entries = entries.select do |entry|
143
+ next false if entry.lastmod.nil?
144
+
145
+ Date.iso8601(entry.lastmod)
146
+ false
147
+ rescue ArgumentError
148
+ true
149
+ end
150
+ return if invalid_entries.empty?
151
+
152
+ raise ValidationError, "Invalid sitemap lastmod values detected: #{invalid_entries.first(5).map(&:loc).join(", ")}"
153
+ end
154
+
155
+ def parse_uri(url)
156
+ URI.parse(url.to_s)
157
+ rescue URI::InvalidURIError
158
+ nil
159
+ end
40
160
  end
41
161
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Indexmap
4
- VERSION = "0.4.1"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -82,7 +82,8 @@ module Indexmap
82
82
  sitemap_sections.each do |section|
83
83
  lines << " <sitemap>"
84
84
  lines << " <loc>#{escape(index_loc(section.filename))}</loc>"
85
- lines << " <lastmod>#{format_lastmod(section_lastmod(section))}</lastmod>"
85
+ lastmod = section_lastmod(section)
86
+ lines << " <lastmod>#{format_lastmod(lastmod)}</lastmod>" if lastmod
86
87
  lines << " </sitemap>"
87
88
  end
88
89
 
@@ -102,7 +103,7 @@ module Indexmap
102
103
 
103
104
  def section_lastmod(section)
104
105
  timestamps = Array(section.entries).map { |entry| comparable_lastmod(normalize_entry(entry).lastmod) }.compact
105
- timestamps.max || Time.now.utc
106
+ timestamps.max
106
107
  end
107
108
 
108
109
  def format_lastmod(value)
@@ -1,35 +1,37 @@
1
- namespace :sitemap do
2
- desc "Create sitemap files"
3
- task create: :environment do
4
- runner = Indexmap::TaskRunner.new
5
- create_result = runner.create
6
- runner.format
7
- validated_files = runner.validate
8
-
9
- puts "Created, formatted, and validated #{file_count(validated_files)} in #{public_directory(runner)}."
10
- puts "IndexNow key file: #{create_result[:index_now_key_path]}" if create_result[:index_now_key_path]
11
- end
1
+ namespace :indexmap do
2
+ namespace :sitemap do
3
+ desc "Create sitemap files"
4
+ task create: :environment do
5
+ runner = Indexmap::TaskRunner.new
6
+ create_result = runner.create
7
+ runner.format
8
+ validated_files = runner.validate
9
+
10
+ puts "Created, formatted, and validated #{file_count(validated_files)} in #{public_directory(runner)}."
11
+ puts "IndexNow key file: #{create_result[:index_now_key_path]}" if create_result[:index_now_key_path]
12
+ end
12
13
 
13
- desc "Format sitemap files for better readability"
14
- task format: :environment do
15
- runner = Indexmap::TaskRunner.new
16
- formatted_files = runner.format
14
+ desc "Format sitemap files for better readability"
15
+ task format: :environment do
16
+ runner = Indexmap::TaskRunner.new
17
+ formatted_files = runner.format
17
18
 
18
- puts "Formatted #{file_count(formatted_files)} in #{public_directory(runner)}."
19
- end
19
+ puts "Formatted #{file_count(formatted_files)} in #{public_directory(runner)}."
20
+ end
20
21
 
21
- desc "Validate sitemap shape and URL hygiene"
22
- task validate: :environment do
23
- runner = Indexmap::TaskRunner.new
24
- validated_files = runner.validate
22
+ desc "Validate sitemap shape and URL hygiene"
23
+ task validate: :environment do
24
+ runner = Indexmap::TaskRunner.new
25
+ validated_files = runner.validate
25
26
 
26
- puts "Validated #{file_count(validated_files)} for sitemap shape and URL hygiene."
27
+ puts "Validated #{file_count(validated_files)} for sitemap shape and URL hygiene."
28
+ end
27
29
  end
28
30
 
29
31
  desc "Ping all configured search engines"
30
32
  task ping: :environment do
31
- Rake::Task["sitemap:index_now:ping"].invoke
32
- Rake::Task["sitemap:google:ping"].invoke
33
+ Rake::Task["indexmap:index_now:ping"].invoke
34
+ Rake::Task["indexmap:google:ping"].invoke
33
35
  end
34
36
 
35
37
  namespace :google do
@@ -8,30 +8,41 @@ class IndexmapConfigurationTest < Minitest::Test
8
8
  end
9
9
 
10
10
  def test_writer_builds_from_configured_callables
11
- Indexmap.configure do |config|
12
- config.base_url = -> { "https://example.com" }
13
- config.public_path = -> { Pathname("tmp/public") }
14
- config.sections = -> do
15
- [Indexmap::Section.new(filename: "sitemap-pages.xml", entries: [Indexmap::Entry.new(loc: "https://example.com/")])]
11
+ Dir.mktmpdir do |dir|
12
+ public_path = Pathname(dir)
13
+
14
+ Indexmap.configure do |config|
15
+ config.base_url = -> { "https://example.com" }
16
+ config.public_path = -> { public_path }
17
+ config.sections = -> do
18
+ [Indexmap::Section.new(filename: "sitemap-pages.xml", entries: [Indexmap::Entry.new(loc: "https://example.com/")])]
19
+ end
16
20
  end
17
- end
18
21
 
19
- writer = Indexmap.configuration.writer
22
+ Indexmap.configuration.writer.write
20
23
 
21
- assert_equal Pathname("tmp/public"), writer.instance_variable_get(:@public_path)
24
+ assert_includes public_path.join("sitemap.xml").read, "<loc>https://example.com/sitemap-pages.xml</loc>"
25
+ assert_includes public_path.join("sitemap-pages.xml").read, "<loc>https://example.com/</loc>"
26
+ end
22
27
  end
23
28
 
24
29
  def test_writer_builds_single_file_writer_from_configured_entries
25
- Indexmap.configure do |config|
26
- config.base_url = "https://example.com"
27
- config.format = :single_file
28
- config.entries = -> { [Indexmap::Entry.new(loc: "https://example.com/")] }
29
- end
30
+ Dir.mktmpdir do |dir|
31
+ public_path = Pathname(dir)
32
+
33
+ Indexmap.configure do |config|
34
+ config.base_url = "https://example.com"
35
+ config.public_path = public_path
36
+ config.format = :single_file
37
+ config.entries = -> { [Indexmap::Entry.new(loc: "https://example.com/")] }
38
+ end
30
39
 
31
- writer = Indexmap.configuration.writer
40
+ Indexmap.configuration.writer.write
32
41
 
33
- assert_equal :single_file, writer.instance_variable_get(:@format)
34
- assert_equal [Indexmap::Entry.new(loc: "https://example.com/")], writer.instance_variable_get(:@entries)
42
+ assert_includes public_path.join("sitemap.xml").read, "<urlset"
43
+ assert_includes public_path.join("sitemap.xml").read, "<loc>https://example.com/</loc>"
44
+ refute public_path.join("sitemap-pages.xml").exist?
45
+ end
35
46
  end
36
47
 
37
48
  def test_writer_raises_without_base_url
@@ -8,13 +8,15 @@ class IndexmapPingerGoogleTest < Minitest::Test
8
8
 
9
9
  class FakeWebmastersService
10
10
  attr_accessor :authorization
11
- attr_reader :submitted
11
+ attr_reader :submitted, :list_sites_calls
12
12
 
13
13
  def initialize(site_urls:)
14
14
  @site_urls = site_urls
15
+ @list_sites_calls = 0
15
16
  end
16
17
 
17
18
  def list_sites
19
+ @list_sites_calls += 1
18
20
  SiteList.new(@site_urls.map { |site_url| SiteEntry.new(site_url) })
19
21
  end
20
22
 
@@ -51,6 +53,7 @@ class IndexmapPingerGoogleTest < Minitest::Test
51
53
  assert_equal ["sc-domain:example.com", "https://www.example.com/sitemap.xml"], service.submitted
52
54
  assert_equal :submitted, result[:status]
53
55
  assert_equal 1, result[:sitemap_count]
56
+ assert_equal 1, service.list_sites_calls
54
57
  end
55
58
  end
56
59
 
@@ -114,4 +117,27 @@ class IndexmapPingerGoogleTest < Minitest::Test
114
117
  assert_nil service.submitted
115
118
  end
116
119
  end
120
+
121
+ def test_google_authorization_requires_exact_property_match
122
+ Dir.mktmpdir do |dir|
123
+ public_path = Pathname(dir)
124
+ public_path.join("sitemap.xml").write("<sitemapindex/>")
125
+
126
+ configuration = Indexmap::Configuration.new
127
+ configuration.base_url = "https://www.example.com"
128
+ configuration.public_path = public_path
129
+ configuration.google.credentials = "{\"type\":\"service_account\"}"
130
+
131
+ service = FakeWebmastersService.new(site_urls: ["sc-domain:myexample.com"])
132
+ result = Indexmap::Pinger::Google.new(
133
+ configuration: configuration,
134
+ service: service,
135
+ credentials_builder: ->(**) { :fake_authorizer }
136
+ ).ping
137
+
138
+ assert_equal :failed, result[:status]
139
+ assert_equal :unauthorized, result[:failures].first[:reason]
140
+ assert_nil service.submitted
141
+ end
142
+ end
117
143
  end
@@ -3,17 +3,19 @@
3
3
  require "test_helper"
4
4
 
5
5
  class IndexmapPingerIndexNowTest < Minitest::Test
6
+ VALID_KEY = "1234567890abcdef1234567890abcdef"
7
+
6
8
  def test_writes_key_file_from_configuration
7
9
  Dir.mktmpdir do |dir|
8
10
  configuration = Indexmap::Configuration.new
9
11
  configuration.base_url = "https://www.example.com"
10
12
  configuration.public_path = Pathname(dir)
11
- configuration.index_now.key = "test-key"
13
+ configuration.index_now.key = VALID_KEY
12
14
 
13
15
  path = Indexmap::Pinger::IndexNow.new(configuration: configuration).write_key_file
14
16
 
15
- assert_equal Pathname(dir).join("test-key.txt"), path
16
- assert_equal "test-key\n", path.read
17
+ assert_equal Pathname(dir).join("#{VALID_KEY}.txt"), path
18
+ assert_equal VALID_KEY, path.read
17
19
  end
18
20
  end
19
21
 
@@ -25,16 +27,16 @@ class IndexmapPingerIndexNowTest < Minitest::Test
25
27
 
26
28
  path = Indexmap::Pinger::IndexNow.new(configuration: configuration).ensure_key_file
27
29
 
28
- assert_match(/\A[a-z0-9-]{8,128}\.txt\z/, path.basename.to_s)
29
- assert_equal "#{path.basename(".txt")}\n", path.read
30
+ assert_match(/\A[a-f0-9]{32}\.txt\z/, path.basename.to_s)
31
+ assert_equal path.basename(".txt").to_s, path.read
30
32
  end
31
33
  end
32
34
 
33
35
  def test_pings_using_existing_key_file_when_key_is_not_configured
34
36
  Dir.mktmpdir do |dir|
35
37
  public_path = Pathname(dir)
36
- key_path = public_path.join("test-key-123.txt")
37
- key_path.write("test-key-123\n")
38
+ key_path = public_path.join("#{VALID_KEY}.txt")
39
+ key_path.write(VALID_KEY)
38
40
  write_sitemap_files(
39
41
  public_path,
40
42
  marketing_lastmod: "2026-04-18T00:00:00Z",
@@ -52,7 +54,8 @@ class IndexmapPingerIndexNowTest < Minitest::Test
52
54
 
53
55
  assert_requested(:post, indexnow_url, times: 1) do |request|
54
56
  payload = JSON.parse(request.body)
55
- assert_equal "test-key-123", payload.fetch("key")
57
+ assert_equal VALID_KEY, payload.fetch("key")
58
+ assert_equal "https://www.example.com/#{VALID_KEY}.txt", payload.fetch("keyLocation")
56
59
  end
57
60
  assert_equal :submitted, result[:status]
58
61
  assert_equal 2, result[:url_count]
@@ -60,6 +63,24 @@ class IndexmapPingerIndexNowTest < Minitest::Test
60
63
  end
61
64
  end
62
65
 
66
+ def test_ignores_existing_key_file_with_trailing_newline
67
+ Dir.mktmpdir do |dir|
68
+ public_path = Pathname(dir)
69
+ invalid_key_path = public_path.join("1234567890abcdef1234567890abcdef.txt")
70
+ invalid_key_path.write("#{VALID_KEY}\n")
71
+
72
+ configuration = Indexmap::Configuration.new
73
+ configuration.base_url = "https://www.example.com"
74
+ configuration.public_path = public_path
75
+
76
+ path = Indexmap::Pinger::IndexNow.new(configuration: configuration).ensure_key_file
77
+
78
+ refute_equal invalid_key_path, path
79
+ assert_match(/\A[a-f0-9]{32}\.txt\z/, path.basename.to_s)
80
+ assert_equal path.basename(".txt").to_s, path.read
81
+ end
82
+ end
83
+
63
84
  def test_pings_all_sitemap_urls_when_no_cutoff_is_provided
64
85
  Dir.mktmpdir do |dir|
65
86
  public_path = Pathname(dir)
@@ -72,7 +93,7 @@ class IndexmapPingerIndexNowTest < Minitest::Test
72
93
  configuration = Indexmap::Configuration.new
73
94
  configuration.base_url = "https://www.example.com"
74
95
  configuration.public_path = public_path
75
- configuration.index_now.key = "test-key"
96
+ configuration.index_now.key = VALID_KEY
76
97
 
77
98
  indexnow_url = "https://api.indexnow.org/indexnow"
78
99
  stub_request(:post, indexnow_url).to_return(status: 200, body: "", headers: {})
@@ -85,6 +106,7 @@ class IndexmapPingerIndexNowTest < Minitest::Test
85
106
  "https://www.example.com/pages/features",
86
107
  "https://www.example.com/insights/us/restaurants/overview"
87
108
  ].sort, payload.fetch("urlList").sort
109
+ assert_equal "https://www.example.com/#{VALID_KEY}.txt", payload.fetch("keyLocation")
88
110
  end
89
111
  assert_equal :submitted, result[:status]
90
112
  assert_equal 2, result[:url_count]
@@ -104,7 +126,7 @@ class IndexmapPingerIndexNowTest < Minitest::Test
104
126
  configuration = Indexmap::Configuration.new
105
127
  configuration.base_url = "https://www.example.com"
106
128
  configuration.public_path = public_path
107
- configuration.index_now.key = "test-key"
129
+ configuration.index_now.key = VALID_KEY
108
130
 
109
131
  indexnow_url = "https://api.indexnow.org/indexnow"
110
132
  stub_request(:post, indexnow_url).to_return(status: 200, body: "", headers: {})
@@ -155,7 +177,7 @@ class IndexmapPingerIndexNowTest < Minitest::Test
155
177
  configuration = Indexmap::Configuration.new
156
178
  configuration.base_url = "https://www.example.com"
157
179
  configuration.public_path = public_path
158
- configuration.index_now.key = "test-key"
180
+ configuration.index_now.key = VALID_KEY
159
181
 
160
182
  with_env("INDEXNOW_DRY_RUN" => "1") do
161
183
  result = Indexmap::Pinger::IndexNow.new(configuration: configuration).ping
@@ -179,7 +201,7 @@ class IndexmapPingerIndexNowTest < Minitest::Test
179
201
  configuration = Indexmap::Configuration.new
180
202
  configuration.base_url = "https://www.example.com"
181
203
  configuration.public_path = public_path
182
- configuration.index_now.key = "test-key"
204
+ configuration.index_now.key = VALID_KEY
183
205
 
184
206
  indexnow_url = "https://api.indexnow.org/indexnow"
185
207
  stub_request(:post, indexnow_url).to_return(status: 500, body: "boom", headers: {})
@@ -192,6 +214,37 @@ class IndexmapPingerIndexNowTest < Minitest::Test
192
214
  end
193
215
  end
194
216
 
217
+ def test_rejects_invalid_configured_key
218
+ Dir.mktmpdir do |dir|
219
+ configuration = Indexmap::Configuration.new
220
+ configuration.base_url = "https://www.example.com"
221
+ configuration.public_path = Pathname(dir)
222
+ configuration.index_now.key = "test-key"
223
+
224
+ error = assert_raises(Indexmap::ConfigurationError) do
225
+ Indexmap::Pinger::IndexNow.new(configuration: configuration).ping
226
+ end
227
+
228
+ assert_equal "IndexNow key must be a 32-character lowercase hexadecimal string", error.message
229
+ end
230
+ end
231
+
232
+ def test_reuses_existing_key_file_deterministically
233
+ Dir.mktmpdir do |dir|
234
+ public_path = Pathname(dir)
235
+ public_path.join("ffffffffffffffffffffffffffffffff.txt").write("ffffffffffffffffffffffffffffffff")
236
+ public_path.join("00000000000000000000000000000000.txt").write("00000000000000000000000000000000")
237
+
238
+ configuration = Indexmap::Configuration.new
239
+ configuration.base_url = "https://www.example.com"
240
+ configuration.public_path = public_path
241
+
242
+ path = Indexmap::Pinger::IndexNow.new(configuration: configuration).ensure_key_file
243
+
244
+ assert_equal public_path.join("00000000000000000000000000000000.txt"), path
245
+ end
246
+ end
247
+
195
248
  private
196
249
 
197
250
  def with_env(overrides)
@@ -3,6 +3,8 @@
3
3
  require "test_helper"
4
4
 
5
5
  class IndexmapTaskRunnerTest < Minitest::Test
6
+ VALID_KEY = "1234567890abcdef1234567890abcdef"
7
+
6
8
  def test_create_removes_existing_sitemap_files_writes_new_sitemap_and_key_file
7
9
  Dir.mktmpdir do |dir|
8
10
  public_path = Pathname(dir)
@@ -18,15 +20,15 @@ class IndexmapTaskRunnerTest < Minitest::Test
18
20
  entries: [Indexmap::Entry.new(loc: "https://example.com/about")]
19
21
  )
20
22
  ]
21
- configuration.index_now.key = "test-key"
23
+ configuration.index_now.key = VALID_KEY
22
24
 
23
25
  result = Indexmap::TaskRunner.new(configuration: configuration).create
24
26
 
25
27
  assert_equal false, public_path.join("sitemap-pages.xml.gz").exist?
26
28
  assert_includes public_path.join("sitemap.xml").read, "<sitemapindex"
27
- assert_equal "test-key\n", public_path.join("test-key.txt").read
29
+ assert_equal VALID_KEY, public_path.join("#{VALID_KEY}.txt").read
28
30
  assert_equal [public_path.join("sitemap-pages.xml").to_s, public_path.join("sitemap.xml").to_s], result[:files]
29
- assert_equal public_path.join("test-key.txt"), result[:index_now_key_path]
31
+ assert_equal public_path.join("#{VALID_KEY}.txt"), result[:index_now_key_path]
30
32
  end
31
33
  end
32
34
 
@@ -56,8 +58,8 @@ class IndexmapTaskRunnerTest < Minitest::Test
56
58
 
57
59
  result = Indexmap::TaskRunner.new(configuration: configuration).write_index_now_key(generate_if_missing: true)
58
60
 
59
- assert_match(/\A[a-z0-9-]{8,128}\.txt\z/, result.basename.to_s)
60
- assert_equal "#{result.basename(".txt")}\n", result.read
61
+ assert_match(/\A[a-f0-9]{32}\.txt\z/, result.basename.to_s)
62
+ assert_equal result.basename(".txt").to_s, result.read
61
63
  end
62
64
  end
63
65
  end
@@ -52,6 +52,120 @@ class IndexmapValidatorTest < Minitest::Test
52
52
  end
53
53
  end
54
54
 
55
+ def test_validate_raises_for_fragment_urls
56
+ Dir.mktmpdir do |directory|
57
+ path = Pathname(directory).join("sitemap.xml")
58
+ path.write(<<~XML)
59
+ <?xml version="1.0" encoding="UTF-8"?>
60
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
61
+ <url><loc>https://example.com/about#team</loc></url>
62
+ </urlset>
63
+ XML
64
+
65
+ error = assert_raises(Indexmap::ValidationError) do
66
+ Indexmap::Validator.new(path: path).validate!
67
+ end
68
+
69
+ assert_equal "Fragment sitemap URLs detected: https://example.com/about#team", error.message
70
+ end
71
+ end
72
+
73
+ def test_validate_raises_for_relative_urls
74
+ Dir.mktmpdir do |directory|
75
+ path = Pathname(directory).join("sitemap.xml")
76
+ path.write(<<~XML)
77
+ <?xml version="1.0" encoding="UTF-8"?>
78
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
79
+ <url><loc>/about</loc></url>
80
+ </urlset>
81
+ XML
82
+
83
+ error = assert_raises(Indexmap::ValidationError) do
84
+ Indexmap::Validator.new(path: path).validate!
85
+ end
86
+
87
+ assert_equal "Invalid sitemap URLs detected: /about", error.message
88
+ end
89
+ end
90
+
91
+ def test_validate_raises_for_urls_outside_configured_base_url
92
+ Dir.mktmpdir do |directory|
93
+ path = Pathname(directory).join("sitemap.xml")
94
+ path.write(<<~XML)
95
+ <?xml version="1.0" encoding="UTF-8"?>
96
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
97
+ <url><loc>https://other.example.com/about</loc></url>
98
+ </urlset>
99
+ XML
100
+
101
+ configuration = Indexmap::Configuration.new
102
+ configuration.base_url = "https://example.com"
103
+
104
+ error = assert_raises(Indexmap::ValidationError) do
105
+ Indexmap::Validator.new(configuration: configuration, path: path).validate!
106
+ end
107
+
108
+ assert_equal "Sitemap URLs outside configured base URL detected: https://other.example.com/about", error.message
109
+ end
110
+ end
111
+
112
+ def test_validate_raises_for_invalid_lastmod_values
113
+ Dir.mktmpdir do |directory|
114
+ path = Pathname(directory).join("sitemap.xml")
115
+ path.write(<<~XML)
116
+ <?xml version="1.0" encoding="UTF-8"?>
117
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
118
+ <url>
119
+ <loc>https://example.com/about</loc>
120
+ <lastmod>not-a-date</lastmod>
121
+ </url>
122
+ </urlset>
123
+ XML
124
+
125
+ error = assert_raises(Indexmap::ValidationError) do
126
+ Indexmap::Validator.new(path: path).validate!
127
+ end
128
+
129
+ assert_equal "Invalid sitemap lastmod values detected: https://example.com/about", error.message
130
+ end
131
+ end
132
+
133
+ def test_validate_raises_for_empty_sitemaps
134
+ Dir.mktmpdir do |directory|
135
+ path = Pathname(directory).join("sitemap.xml")
136
+ path.write(<<~XML)
137
+ <?xml version="1.0" encoding="UTF-8"?>
138
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
139
+ </urlset>
140
+ XML
141
+
142
+ error = assert_raises(Indexmap::ValidationError) do
143
+ Indexmap::Validator.new(path: path).validate!
144
+ end
145
+
146
+ assert_equal "Sitemap has no URLs: #{path}", error.message
147
+ end
148
+ end
149
+
150
+ def test_validate_raises_for_missing_child_sitemap_files
151
+ Dir.mktmpdir do |directory|
152
+ path = Pathname(directory).join("sitemap.xml")
153
+ child_path = Pathname(directory).join("sitemap-pages.xml")
154
+ path.write(<<~XML)
155
+ <?xml version="1.0" encoding="UTF-8"?>
156
+ <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
157
+ <sitemap><loc>https://example.com/sitemap-pages.xml</loc></sitemap>
158
+ </sitemapindex>
159
+ XML
160
+
161
+ error = assert_raises(Indexmap::ValidationError) do
162
+ Indexmap::Validator.new(path: path).validate!
163
+ end
164
+
165
+ assert_equal "Missing child sitemap file: #{child_path}", error.message
166
+ end
167
+ end
168
+
55
169
  def test_validate_passes_for_valid_sitemap
56
170
  Dir.mktmpdir do |directory|
57
171
  path = Pathname(directory).join("sitemap.xml")
@@ -77,4 +77,23 @@ class IndexmapWriterTest < Minitest::Test
77
77
  refute File.exist?(File.join(directory, "sitemap-pages.xml"))
78
78
  end
79
79
  end
80
+
81
+ def test_omits_sitemap_index_lastmod_when_sections_have_no_lastmod
82
+ Dir.mktmpdir do |directory|
83
+ Indexmap::Writer.new(
84
+ sections: [
85
+ Indexmap::Section.new(
86
+ filename: "sitemap-pages.xml",
87
+ entries: [Indexmap::Entry.new(loc: "https://example.com/about")]
88
+ )
89
+ ],
90
+ public_path: directory,
91
+ base_url: "https://example.com"
92
+ ).write
93
+
94
+ index_xml = File.read(File.join(directory, "sitemap.xml"))
95
+
96
+ refute_includes index_xml, "<lastmod>"
97
+ end
98
+ end
80
99
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: indexmap
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paulo Fidalgo