indexmap 0.8.2 → 0.9.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 +4 -4
- data/CHANGELOG.md +65 -0
- data/README.md +287 -35
- data/UPGRADE.md +361 -0
- data/docs/architecture.md +55 -0
- data/docs/deployment.md +223 -0
- data/docs/domain-terms.md +27 -0
- data/docs/dynamic-sitemaps.md +302 -0
- data/docs/search-engine-ping.md +368 -0
- data/docs/storage.md +94 -0
- data/lib/generators/indexmap/install_generator.rb +1 -1
- data/lib/generators/indexmap/templates/initializer.rb.tt +18 -18
- data/lib/indexmap/artifact.rb +9 -0
- data/lib/indexmap/configuration.rb +12 -1
- data/lib/indexmap/creator.rb +45 -42
- data/lib/indexmap/index_now_configuration.rb +3 -1
- data/lib/indexmap/output.rb +19 -5
- data/lib/indexmap/parser.rb +112 -52
- data/lib/indexmap/pinger/base.rb +6 -5
- data/lib/indexmap/pinger/google.rb +12 -8
- data/lib/indexmap/pinger/index_now.rb +97 -52
- data/lib/indexmap/publication.rb +89 -0
- data/lib/indexmap/robots.rb +67 -0
- data/lib/indexmap/robots_controller.rb +13 -0
- data/lib/indexmap/sitemaps_controller.rb +57 -0
- data/lib/indexmap/storage/active_storage.rb +132 -1
- data/lib/indexmap/storage/filesystem.rb +182 -11
- data/lib/indexmap/storage/memory.rb +1 -1
- data/lib/indexmap/task_runner.rb +21 -28
- data/lib/indexmap/validator/duplicates.rb +83 -0
- data/lib/indexmap/validator.rb +171 -59
- data/lib/indexmap/version.rb +1 -1
- data/lib/indexmap/writer.rb +89 -34
- data/lib/indexmap.rb +14 -1
- data/lib/tasks/indexmap_tasks.rake +21 -2
- data/test/indexmap/correctness_test.rb +153 -0
- data/test/indexmap/install_generator_test.rb +36 -4
- data/test/indexmap/parser_test.rb +77 -0
- data/test/indexmap/pinger/google_test.rb +22 -0
- data/test/indexmap/pinger/index_now_test.rb +168 -0
- data/test/indexmap/publication_test.rb +280 -0
- data/test/indexmap/robots_test.rb +73 -0
- data/test/indexmap/sitemaps_controller_test.rb +50 -0
- data/test/indexmap/storage_test.rb +27 -0
- data/test/indexmap/task_runner_test.rb +24 -0
- data/test/indexmap/validator_test.rb +14 -0
- data/test/indexmap/writer_test.rb +42 -0
- data/test/integration/queue_checks.rb +244 -0
- data/test/release_task_test.rb +153 -0
- metadata +22 -5
- data/lib/indexmap/storage/file.rb +0 -11
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
class IndexmapPublicationTest < Minitest::Test
|
|
7
|
+
def configuration(directory, atomic: true)
|
|
8
|
+
Indexmap::Configuration.new.tap do |config|
|
|
9
|
+
config.base_url = "https://example.com"
|
|
10
|
+
config.storage = Indexmap::Storage::Filesystem.new(path: directory, public_url: config.base_url)
|
|
11
|
+
config.atomic = atomic
|
|
12
|
+
config.max_urls = 2
|
|
13
|
+
config.sections = -> do
|
|
14
|
+
entries = (1..5).lazy.map { |id| Indexmap::Entry.new(loc: "https://example.com/#{id}") }
|
|
15
|
+
[Indexmap::Section.new(filename: "sitemap-pages.xml", entries: entries)]
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def test_partitions_once_and_publishes_immutable_children
|
|
21
|
+
Dir.mktmpdir do |directory|
|
|
22
|
+
config = configuration(directory)
|
|
23
|
+
result = Indexmap::TaskRunner.new(configuration: config).create
|
|
24
|
+
assert_equal [2, 2, 1, 3], result.fetch(:sitemaps).map { |file| file.fetch(:link_count) }
|
|
25
|
+
old_files = result.fetch(:files)
|
|
26
|
+
bodies = old_files.to_h { |file| [file, config.storage.read(file)] }
|
|
27
|
+
assert_raises(Indexmap::ConfigurationError) { config.storage.write(old_files.first, "overwrite") }
|
|
28
|
+
assert_raises(Indexmap::ConfigurationError) { Indexmap::TaskRunner.new(configuration: config).format }
|
|
29
|
+
second = Indexmap::TaskRunner.new(configuration: config).create
|
|
30
|
+
refute_equal old_files, second.fetch(:files)
|
|
31
|
+
old_files.reject { |file| file == "sitemap.xml" }.each { |file| assert_equal bodies[file], config.storage.read(file) }
|
|
32
|
+
publication = Indexmap::Publication.new(root: "sitemap.xml")
|
|
33
|
+
assert config.storage.exist?(publication.archive(publication.generation(old_files.first)))
|
|
34
|
+
assert Indexmap::Validator.new(configuration: config).validate!
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def test_byte_partitioning_counts_escaped_output
|
|
39
|
+
Dir.mktmpdir do |directory|
|
|
40
|
+
config = configuration(directory, atomic: false)
|
|
41
|
+
config.max_urls = 50_000
|
|
42
|
+
config.max_bytes = 400
|
|
43
|
+
config.sections = [Indexmap::Section.new(filename: "sitemap-pages.xml", entries: (1..3).map do |id|
|
|
44
|
+
Indexmap::Entry.new(loc: "https://example.com/#{id}#{"a" * 100}&b")
|
|
45
|
+
end)]
|
|
46
|
+
files = Indexmap.create(configuration: config)
|
|
47
|
+
assert_operator files.size, :>, 2
|
|
48
|
+
files.each { |file| assert_operator config.storage.metadata(file).fetch(:bytes), :<=, 400 }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def test_failed_copy_does_not_change_published_files_or_leave_staging
|
|
53
|
+
Dir.mktmpdir do |directory|
|
|
54
|
+
config = configuration(directory)
|
|
55
|
+
Indexmap.create(configuration: config)
|
|
56
|
+
storage = config.storage
|
|
57
|
+
previous = storage.list.to_h { |filename| [filename, storage.read(filename)] }
|
|
58
|
+
storage.define_singleton_method(:publish) do |source, **options|
|
|
59
|
+
calls = 0
|
|
60
|
+
source.define_singleton_method(:open) do |filename, &block|
|
|
61
|
+
calls += 1
|
|
62
|
+
raise IOError, "interrupted copy" if calls == 2
|
|
63
|
+
|
|
64
|
+
super(filename, &block)
|
|
65
|
+
end
|
|
66
|
+
super(source, **options)
|
|
67
|
+
end
|
|
68
|
+
assert_raises(IOError) { Indexmap.create(configuration: config) }
|
|
69
|
+
assert_equal previous, storage.list.to_h { |filename| [filename, storage.read(filename)] }
|
|
70
|
+
assert_empty Dir.glob(File.join(directory, ".indexmap-builds/*/build-*"))
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def test_atomic_refresh_preserves_existing_root_permissions
|
|
75
|
+
Dir.mktmpdir do |directory|
|
|
76
|
+
config = configuration(directory)
|
|
77
|
+
Indexmap.create(configuration: config)
|
|
78
|
+
root = File.join(directory, "sitemap.xml")
|
|
79
|
+
File.chmod(0o640, root)
|
|
80
|
+
Indexmap.create(configuration: config)
|
|
81
|
+
assert_equal 0o640, File.stat(root).mode & 0o777
|
|
82
|
+
assert Indexmap::Validator.new(configuration: config).validate!
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def test_failed_root_activation_removes_new_children
|
|
87
|
+
Dir.mktmpdir do |directory|
|
|
88
|
+
config = configuration(directory)
|
|
89
|
+
old = Indexmap.create(configuration: config)
|
|
90
|
+
previous = config.storage.read("sitemap.xml")
|
|
91
|
+
rename = File.method(:rename)
|
|
92
|
+
File.stub(:rename, ->(source, destination) do
|
|
93
|
+
raise Errno::EACCES, "root activation denied" if destination.to_s == File.join(directory, "sitemap.xml")
|
|
94
|
+
|
|
95
|
+
rename.call(source, destination)
|
|
96
|
+
end) do
|
|
97
|
+
assert_raises(Errno::EACCES) { Indexmap.create(configuration: config) }
|
|
98
|
+
end
|
|
99
|
+
assert_equal previous, config.storage.read("sitemap.xml")
|
|
100
|
+
children = config.storage.list(prefix: "sitemap-", suffix: ".xml").reject { |name| name.end_with?("-index.xml") }
|
|
101
|
+
assert_equal (old - ["sitemap.xml"]).sort, children
|
|
102
|
+
assert_empty Dir.glob(File.join(directory, ".indexmap-builds/*/build-*"))
|
|
103
|
+
assert Indexmap::Validator.new(configuration: config).validate!
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def test_copying_does_not_block_a_competing_publisher
|
|
108
|
+
Dir.mktmpdir do |directory|
|
|
109
|
+
config = configuration(directory)
|
|
110
|
+
Indexmap.create(configuration: config)
|
|
111
|
+
storage = config.storage
|
|
112
|
+
entered = Queue.new
|
|
113
|
+
release = Queue.new
|
|
114
|
+
storage.define_singleton_method(:publish) do |source, **options|
|
|
115
|
+
source.define_singleton_method(:open) do |filename, &block|
|
|
116
|
+
unless @paused
|
|
117
|
+
@paused = true
|
|
118
|
+
entered << true
|
|
119
|
+
release.pop
|
|
120
|
+
end
|
|
121
|
+
super(filename, &block)
|
|
122
|
+
end
|
|
123
|
+
super(source, **options)
|
|
124
|
+
end
|
|
125
|
+
thread = Thread.new do
|
|
126
|
+
Indexmap.create(configuration: config)
|
|
127
|
+
rescue Indexmap::SupersededPublication => error
|
|
128
|
+
error
|
|
129
|
+
end
|
|
130
|
+
Timeout.timeout(5) { entered.pop }
|
|
131
|
+
competing = configuration(directory)
|
|
132
|
+
winner = Timeout.timeout(5) { Indexmap.create(configuration: competing) }
|
|
133
|
+
release << true
|
|
134
|
+
assert_instance_of Indexmap::SupersededPublication, Timeout.timeout(5) { thread.value }
|
|
135
|
+
assert winner.all? { |filename| storage.exist?(filename) }
|
|
136
|
+
assert Indexmap::Parser.new(storage: storage).entries.all? { |entry| winner.include?(entry.source_sitemap) }
|
|
137
|
+
assert Indexmap::Validator.new(configuration: config).validate!
|
|
138
|
+
assert_empty Dir.glob(File.join(directory, ".indexmap-builds/*/build-*"))
|
|
139
|
+
ensure
|
|
140
|
+
thread&.kill if thread&.alive?
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def test_pruning_reclaims_crashed_staging_and_skips_live_builds
|
|
145
|
+
Dir.mktmpdir do |directory|
|
|
146
|
+
config = configuration(directory)
|
|
147
|
+
Indexmap.create(configuration: config)
|
|
148
|
+
reader, writer = IO.pipe
|
|
149
|
+
pid = fork do
|
|
150
|
+
reader.close
|
|
151
|
+
config.storage.define_singleton_method(:publish) do |source, **options|
|
|
152
|
+
source.define_singleton_method(:open) do |filename, &block|
|
|
153
|
+
writer.write("staging")
|
|
154
|
+
writer.flush
|
|
155
|
+
sleep
|
|
156
|
+
super(filename, &block)
|
|
157
|
+
end
|
|
158
|
+
super(source, **options)
|
|
159
|
+
end
|
|
160
|
+
Indexmap.create(configuration: config)
|
|
161
|
+
end
|
|
162
|
+
writer.close
|
|
163
|
+
assert IO.select([reader], nil, nil, 5), "build did not reach destination staging"
|
|
164
|
+
reader.read(7)
|
|
165
|
+
staged = Dir.glob(File.join(directory, ".indexmap-builds/*/build-*")).fetch(0)
|
|
166
|
+
File.utime(Time.at(0), Time.at(0), staged)
|
|
167
|
+
assert_empty config.storage.prune(root: "sitemap.xml", before: Time.now.utc - 3600)
|
|
168
|
+
assert File.directory?(staged)
|
|
169
|
+
Process.kill("KILL", pid)
|
|
170
|
+
Process.wait(pid)
|
|
171
|
+
pid = nil
|
|
172
|
+
deleted = config.storage.prune(root: "sitemap.xml", before: Time.now.utc - 3600)
|
|
173
|
+
assert_equal [Pathname(staged).relative_path_from(Pathname(directory)).to_s], deleted
|
|
174
|
+
refute File.exist?(staged)
|
|
175
|
+
assert Indexmap::Validator.new(configuration: config).validate!
|
|
176
|
+
ensure
|
|
177
|
+
if pid
|
|
178
|
+
Process.kill("KILL", pid)
|
|
179
|
+
Process.wait(pid)
|
|
180
|
+
end
|
|
181
|
+
reader&.close
|
|
182
|
+
writer&.close unless writer&.closed?
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def test_rejects_stale_builds_and_keeps_other_outputs_during_cleanup
|
|
187
|
+
Dir.mktmpdir do |directory|
|
|
188
|
+
config = configuration(directory)
|
|
189
|
+
old = Indexmap.create(configuration: config)
|
|
190
|
+
config.output(:other) do |output|
|
|
191
|
+
output.index_filename = "other.xml"
|
|
192
|
+
output.sections = -> { [Indexmap::Section.new(filename: "sitemap-other.xml", entries: [{loc: "https://example.com/other"}])] }
|
|
193
|
+
end
|
|
194
|
+
other = Indexmap.create(:other, configuration: config)
|
|
195
|
+
expected = config.storage.revision("sitemap.xml")
|
|
196
|
+
Indexmap.create(configuration: config)
|
|
197
|
+
assert_raises(Indexmap::SupersededPublication) do
|
|
198
|
+
config.storage.publish(nil, files: [], root: "sitemap.xml", expected: expected)
|
|
199
|
+
end
|
|
200
|
+
config.storage.list(suffix: ".xml").each { |name| File.utime(Time.at(0), Time.at(0), File.join(directory, name)) }
|
|
201
|
+
deleted = config.storage.prune(root: "sitemap.xml", before: Time.now.utc - 3600)
|
|
202
|
+
assert (old - ["sitemap.xml"]).all? { |file| deleted.include?(file) }
|
|
203
|
+
assert other.all? { |file| config.storage.exist?(file) }
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def test_duplicates_across_partitions_fail_before_publication
|
|
208
|
+
Dir.mktmpdir do |directory|
|
|
209
|
+
config = configuration(directory)
|
|
210
|
+
Indexmap.create(configuration: config)
|
|
211
|
+
previous = config.storage.read("sitemap.xml")
|
|
212
|
+
config.sections = [Indexmap::Section.new(filename: "sitemap-pages.xml", entries: [
|
|
213
|
+
{loc: "https://example.com/a"}, {loc: "https://example.com/b"}, {loc: "https://example.com/a"}
|
|
214
|
+
])]
|
|
215
|
+
assert_raises(Indexmap::ValidationError) { Indexmap.create(configuration: config) }
|
|
216
|
+
assert_equal previous, config.storage.read("sitemap.xml")
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def test_filesystem_rejects_symlinks_escaping_storage
|
|
221
|
+
Dir.mktmpdir do |directory|
|
|
222
|
+
Dir.mktmpdir do |outside|
|
|
223
|
+
storage = Indexmap::Storage::Filesystem.new(path: directory)
|
|
224
|
+
File.symlink(outside, File.join(directory, "escape"))
|
|
225
|
+
assert_raises(ArgumentError) { storage.write("escape/private.xml", "private") }
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def test_concurrent_builds_cannot_overwrite_a_newer_root
|
|
231
|
+
Dir.mktmpdir do |directory|
|
|
232
|
+
config = configuration(directory)
|
|
233
|
+
entered = Queue.new
|
|
234
|
+
release = Queue.new
|
|
235
|
+
config.sections = -> do
|
|
236
|
+
source = Enumerator.new do |stream|
|
|
237
|
+
entered << true
|
|
238
|
+
release.pop
|
|
239
|
+
stream << Indexmap::Entry.new(loc: "https://example.com/page")
|
|
240
|
+
end
|
|
241
|
+
[Indexmap::Section.new(filename: "sitemap-pages.xml", entries: source)]
|
|
242
|
+
end
|
|
243
|
+
threads = 2.times.map do
|
|
244
|
+
Thread.new do
|
|
245
|
+
Indexmap.create(configuration: config)
|
|
246
|
+
rescue Indexmap::SupersededPublication => error
|
|
247
|
+
error
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
Timeout.timeout(5) { 2.times { entered.pop } }
|
|
251
|
+
2.times { release << true }
|
|
252
|
+
results = Timeout.timeout(5) { threads.map(&:value) }
|
|
253
|
+
assert_equal 1, results.count { |result| result.is_a?(Indexmap::SupersededPublication) }
|
|
254
|
+
assert Indexmap::Validator.new(configuration: config).validate!
|
|
255
|
+
ensure
|
|
256
|
+
threads&.each { |thread| thread.kill if thread.alive? }
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def test_atomic_mode_requires_an_explicit_adapter_capability
|
|
261
|
+
config = Indexmap::Configuration.new
|
|
262
|
+
config.atomic = true
|
|
263
|
+
config.storage = Indexmap::Storage::Memory.new
|
|
264
|
+
config.sections = -> { raise "must not evaluate an unsupported build" }
|
|
265
|
+
assert_raises(Indexmap::ConfigurationError) { Indexmap.create(configuration: config) }
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def test_duplicates_across_sort_runs_fail_before_publication
|
|
269
|
+
Dir.mktmpdir do |directory|
|
|
270
|
+
config = configuration(directory)
|
|
271
|
+
Indexmap.create(configuration: config)
|
|
272
|
+
previous = config.storage.read("sitemap.xml")
|
|
273
|
+
config.max_urls = 5000
|
|
274
|
+
entries = (0..10_000).lazy.map { |id| {loc: "https://example.com/#{id % 10_000}"} }
|
|
275
|
+
config.sections = [Indexmap::Section.new(filename: "sitemap-pages.xml", entries: entries)]
|
|
276
|
+
assert_raises(Indexmap::ValidationError) { Indexmap.create(configuration: config) }
|
|
277
|
+
assert_equal previous, config.storage.read("sitemap.xml")
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
require "indexmap/robots_controller"
|
|
5
|
+
require "rack/mock"
|
|
6
|
+
|
|
7
|
+
class IndexmapRobotsTest < Minitest::Test
|
|
8
|
+
def setup
|
|
9
|
+
Indexmap.reset!
|
|
10
|
+
Indexmap.configuration.base_url = "https://example.com"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def teardown
|
|
14
|
+
Indexmap.reset!
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def test_defaults_to_disallow_without_resolving_sitemap_data
|
|
18
|
+
Indexmap.configuration.base_url = -> { raise "must not resolve" }
|
|
19
|
+
Indexmap.configuration.sections = -> { raise "must not query" }
|
|
20
|
+
Indexmap.configuration.storage = -> { raise "must not read storage" }
|
|
21
|
+
assert_equal "User-agent: *\nDisallow: /\n", Indexmap.configuration.robots.body
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def test_uses_explicit_indexability_and_the_configured_root
|
|
25
|
+
indexable = true
|
|
26
|
+
config = Indexmap.configuration
|
|
27
|
+
config.index_filename = "sitemaps/main.xml"
|
|
28
|
+
config.robots.indexable = -> { indexable }
|
|
29
|
+
config.robots.groups = [{user_agent: ["*", "ExampleBot"], allow: ["/"], disallow: ["/admin/", "/search"]}]
|
|
30
|
+
assert_includes config.robots.body, "Sitemap: https://example.com/sitemaps/main.xml"
|
|
31
|
+
assert_includes config.robots.body, "Disallow: /admin/"
|
|
32
|
+
assert_includes config.robots.body, "User-agent: ExampleBot"
|
|
33
|
+
indexable = false
|
|
34
|
+
assert_equal "User-agent: *\nDisallow: /\n", config.robots.body
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def test_rejects_injected_directives_and_invalid_sitemap_urls
|
|
38
|
+
config = Indexmap.configuration
|
|
39
|
+
config.robots.indexable = true
|
|
40
|
+
config.robots.groups = [{user_agent: "*", disallow: ["/admin/\nAllow: /"]}]
|
|
41
|
+
assert_raises(Indexmap::ConfigurationError) { config.robots.body }
|
|
42
|
+
config.robots.groups = [{user_agent: "*"}]
|
|
43
|
+
["/sitemap.xml", "https://user:password@example.com/sitemap.xml", "https://example.com/sitemap.xml\nDisallow: /", "file:///sitemap.xml"].each do |url|
|
|
44
|
+
config.robots.sitemaps = [url]
|
|
45
|
+
assert_raises(Indexmap::ConfigurationError) { config.robots.body }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def test_opt_in_route_serves_get_head_and_conditional_responses_without_cookies
|
|
50
|
+
Indexmap.configuration.robots.indexable = true
|
|
51
|
+
Indexmap.configuration.sections = -> { raise "must not query content" }
|
|
52
|
+
routes = ActionDispatch::Routing::RouteSet.new
|
|
53
|
+
routes.draw { get "/robots.txt", to: Indexmap::RobotsController.action(:show) }
|
|
54
|
+
client = Rack::MockRequest.new(Rack::Head.new(routes))
|
|
55
|
+
response = client.get("/robots.txt", "HTTP_HOST" => "attacker.example")
|
|
56
|
+
assert_equal 200, response.status
|
|
57
|
+
assert_match "text/plain", response["content-type"]
|
|
58
|
+
assert_includes response.body, "Sitemap: https://example.com/sitemap.xml"
|
|
59
|
+
assert_includes response["cache-control"], "max-age=3600"
|
|
60
|
+
assert_nil response["set-cookie"]
|
|
61
|
+
head = client.head("/robots.txt")
|
|
62
|
+
assert_equal 200, head.status
|
|
63
|
+
assert_empty head.body
|
|
64
|
+
cached = client.get("/robots.txt", "HTTP_IF_NONE_MATCH" => response["etag"])
|
|
65
|
+
assert_equal 304, cached.status
|
|
66
|
+
assert_empty cached.body
|
|
67
|
+
assert_equal 404, client.post("/robots.txt").status
|
|
68
|
+
Indexmap.configuration.robots.indexable = false
|
|
69
|
+
disabled = client.get("/robots.txt", "HTTP_IF_NONE_MATCH" => response["etag"])
|
|
70
|
+
assert_equal 200, disabled.status
|
|
71
|
+
assert_equal "User-agent: *\nDisallow: /\n", disabled.body
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
require "indexmap/sitemaps_controller"
|
|
5
|
+
require "rack/mock"
|
|
6
|
+
require "active_support/cache"
|
|
7
|
+
|
|
8
|
+
class IndexmapSitemapsControllerTest < Minitest::Test
|
|
9
|
+
def test_serves_only_published_and_retained_artifacts_without_source_queries
|
|
10
|
+
Dir.mktmpdir do |directory|
|
|
11
|
+
Indexmap.reset!
|
|
12
|
+
Rails.cache = ActiveSupport::Cache::MemoryStore.new
|
|
13
|
+
config = Indexmap.configuration
|
|
14
|
+
config.base_url = "https://example.com"
|
|
15
|
+
config.storage = Indexmap::Storage::Filesystem.new(path: directory)
|
|
16
|
+
config.atomic = true
|
|
17
|
+
config.sections = -> { [Indexmap::Section.new(filename: "sitemap-pages.xml", entries: [{loc: "https://example.com/page"}])] }
|
|
18
|
+
old = Indexmap.create.first
|
|
19
|
+
child = Indexmap.create.first
|
|
20
|
+
unpublished = Indexmap::Publication.new(root: "sitemap.xml").filename(generation: "f" * 32, section: 1, part: 1)
|
|
21
|
+
config.storage.write(unpublished, "not published")
|
|
22
|
+
config.sections = -> { raise "must not query source content" }
|
|
23
|
+
routes = ActionDispatch::Routing::RouteSet.new
|
|
24
|
+
routes.draw do
|
|
25
|
+
get "/sitemap.xml", to: Indexmap::SitemapsController.action(:show), defaults: {filename: "sitemap.xml"}, format: false
|
|
26
|
+
get "/:filename", to: Indexmap::SitemapsController.action(:show), constraints: {filename: /[^\/]+/}, format: false
|
|
27
|
+
end
|
|
28
|
+
client = Rack::MockRequest.new(Rack::Head.new(routes))
|
|
29
|
+
response = client.get("/#{child}")
|
|
30
|
+
assert_equal 200, response.status
|
|
31
|
+
assert_includes response.body, "https://example.com/page"
|
|
32
|
+
assert_match "application/xml", response["content-type"]
|
|
33
|
+
assert_includes response["cache-control"], "immutable"
|
|
34
|
+
assert_nil response["set-cookie"]
|
|
35
|
+
assert_equal 200, client.get("/#{old}").status
|
|
36
|
+
assert_equal 404, client.get("/#{unpublished}").status
|
|
37
|
+
assert_equal 404, client.get("/robots.txt").status
|
|
38
|
+
assert_equal 200, client.get("/#{child}?prefix=../../").status
|
|
39
|
+
config.storage.define_singleton_method(:open) { |_filename, &_block| raise "must not download body" }
|
|
40
|
+
cached = client.get("/#{child}", "HTTP_IF_NONE_MATCH" => response["etag"])
|
|
41
|
+
assert_equal 304, cached.status
|
|
42
|
+
assert_empty cached.body
|
|
43
|
+
head = client.head("/#{child}")
|
|
44
|
+
assert_equal 200, head.status
|
|
45
|
+
assert_empty head.body
|
|
46
|
+
ensure
|
|
47
|
+
Indexmap.reset!
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -94,6 +94,33 @@ class IndexmapStorageTest < Minitest::Test
|
|
|
94
94
|
end
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
+
def test_filesystem_listing_scopes_literal_prefixes_before_inspecting_files
|
|
98
|
+
Dir.mktmpdir do |directory|
|
|
99
|
+
storage = Indexmap::Storage::Filesystem.new(path: directory)
|
|
100
|
+
storage.write("sitemaps/current-1.xml", "xml")
|
|
101
|
+
storage.write("sitemaps/current-2.xml", "xml")
|
|
102
|
+
storage.write("sitemaps/other/old.xml", "xml")
|
|
103
|
+
storage.write("uploads/unrelated.xml", "xml")
|
|
104
|
+
storage.write("sitemaps/literal[1].xml", "xml")
|
|
105
|
+
file_test = FileTest.method(:file?)
|
|
106
|
+
FileTest.stub(:file?, ->(name) do
|
|
107
|
+
raise "inspected unrelated files" if name.to_s.include?("uploads/") || name.to_s.include?("sitemaps/other/")
|
|
108
|
+
|
|
109
|
+
file_test.call(name)
|
|
110
|
+
end) do
|
|
111
|
+
assert_equal ["sitemaps/current-1.xml", "sitemaps/current-2.xml"], storage.list(prefix: "sitemaps/current-", suffix: ".xml")
|
|
112
|
+
end
|
|
113
|
+
assert_equal ["sitemaps/literal[1].xml"], storage.list(prefix: "sitemaps/literal[", suffix: ".xml")
|
|
114
|
+
assert_raises(ArgumentError) { storage.list(prefix: "../outside/") }
|
|
115
|
+
assert_raises(ArgumentError) { storage.list(prefix: "/sitemaps/") }
|
|
116
|
+
assert_raises(ArgumentError) { storage.list(prefix: "sitemaps/../uploads/") }
|
|
117
|
+
storage.write(".private/hidden.xml", "private")
|
|
118
|
+
File.symlink(File.join(directory, "sitemaps"), File.join(directory, "alias"))
|
|
119
|
+
assert_empty storage.list(prefix: ".private/")
|
|
120
|
+
assert_empty storage.list(prefix: "alias/")
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
97
124
|
def test_filesystem_storage_rejects_absolute_filenames
|
|
98
125
|
Dir.mktmpdir do |dir|
|
|
99
126
|
storage = Indexmap::Storage::Filesystem.new(path: dir)
|
|
@@ -116,6 +116,30 @@ class IndexmapTaskRunnerTest < Minitest::Test
|
|
|
116
116
|
assert_includes storage.read("sitemap.xml"), "\n <url>"
|
|
117
117
|
end
|
|
118
118
|
|
|
119
|
+
def test_generation_reports_do_not_download_published_artifacts
|
|
120
|
+
storage = Class.new(Indexmap::Storage::Memory) do
|
|
121
|
+
def read(_filename)
|
|
122
|
+
raise "must not download published artifacts for counts"
|
|
123
|
+
end
|
|
124
|
+
end.new
|
|
125
|
+
result = Indexmap::TaskRunner.new(configuration: configuration_with(storage: storage)).create
|
|
126
|
+
assert_equal [1, 1], result.fetch(:sitemaps).map { |file| file.fetch(:link_count) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def test_create_resolves_storage_once_for_publication_and_key_handling
|
|
130
|
+
stores = []
|
|
131
|
+
config = configuration_with
|
|
132
|
+
config.storage = -> { stores.push(Indexmap::Storage::Memory.new).last }
|
|
133
|
+
config.index_now.key = VALID_KEY
|
|
134
|
+
result = Indexmap::TaskRunner.new(configuration: config).create
|
|
135
|
+
assert_equal 1, stores.size
|
|
136
|
+
assert result.fetch(:files).all? { |filename| stores.first.exist?(filename) }
|
|
137
|
+
assert_equal VALID_KEY, stores.first.read("#{VALID_KEY}.txt")
|
|
138
|
+
Indexmap::TaskRunner.new(configuration: config).create
|
|
139
|
+
assert_equal 2, stores.size
|
|
140
|
+
assert stores.last.exist?("sitemap.xml")
|
|
141
|
+
end
|
|
142
|
+
|
|
119
143
|
private
|
|
120
144
|
|
|
121
145
|
def configuration_with(storage: Indexmap::Storage::Memory.new)
|
|
@@ -167,6 +167,20 @@ class IndexmapValidatorTest < Minitest::Test
|
|
|
167
167
|
XML
|
|
168
168
|
end
|
|
169
169
|
|
|
170
|
+
def test_validation_keeps_one_resolved_storage_for_the_entire_graph
|
|
171
|
+
config = configuration_with
|
|
172
|
+
config.sections = [Indexmap::Section.new(filename: "sitemap-pages.xml", entries: [{loc: "https://example.com/"}])]
|
|
173
|
+
Indexmap.create(configuration: config)
|
|
174
|
+
storage = config.storage
|
|
175
|
+
calls = 0
|
|
176
|
+
config.storage = -> {
|
|
177
|
+
calls += 1
|
|
178
|
+
storage
|
|
179
|
+
}
|
|
180
|
+
assert Indexmap::Validator.new(configuration: config).validate!(collect: false)
|
|
181
|
+
assert_equal 1, calls
|
|
182
|
+
end
|
|
183
|
+
|
|
170
184
|
private
|
|
171
185
|
|
|
172
186
|
def validate_sitemap(body)
|
|
@@ -85,4 +85,46 @@ class IndexmapWriterTest < Minitest::Test
|
|
|
85
85
|
|
|
86
86
|
refute_includes index_xml, "<lastmod>"
|
|
87
87
|
end
|
|
88
|
+
|
|
89
|
+
def test_consumes_section_entries_once_and_preserves_output_and_latest_date
|
|
90
|
+
entries = [
|
|
91
|
+
{loc: "https://example.com/first", lastmod: Date.new(2026, 4, 21)},
|
|
92
|
+
{loc: "https://example.com/second", lastmod: "2026-04-22T09:15:00Z"},
|
|
93
|
+
{loc: "https://example.com/third", lastmod: nil}
|
|
94
|
+
]
|
|
95
|
+
iterations = 0
|
|
96
|
+
source = Enumerator.new do |yielder|
|
|
97
|
+
iterations += 1
|
|
98
|
+
raise "source was enumerated twice" if iterations > 1
|
|
99
|
+
|
|
100
|
+
entries.each { |entry| yielder << entry }
|
|
101
|
+
end
|
|
102
|
+
expected = Indexmap::Writer.new(base_url: "https://example.com",
|
|
103
|
+
sections: [{filename: "sitemap-pages.xml", entries: entries}]).write
|
|
104
|
+
actual = Indexmap::Writer.new(base_url: "https://example.com",
|
|
105
|
+
sections: [{filename: "sitemap-pages.xml", entries: source}]).write
|
|
106
|
+
|
|
107
|
+
assert_equal expected.map(&:body), actual.map(&:body)
|
|
108
|
+
assert_equal 1, iterations
|
|
109
|
+
assert_includes actual.last.body, "<lastmod>2026-04-22T09:15:00Z</lastmod>"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def test_create_publishes_a_one_shot_section_source
|
|
113
|
+
configuration = Indexmap::Configuration.new
|
|
114
|
+
configuration.base_url = "https://example.com"
|
|
115
|
+
configuration.storage = Indexmap::Storage::Memory.new
|
|
116
|
+
iterations = 0
|
|
117
|
+
source = Enumerator.new do |yielder|
|
|
118
|
+
iterations += 1
|
|
119
|
+
raise "source was enumerated twice" if iterations > 1
|
|
120
|
+
|
|
121
|
+
yielder << Indexmap::Entry.new(loc: "https://example.com/page", lastmod: Time.utc(2026, 4, 21))
|
|
122
|
+
end
|
|
123
|
+
configuration.sections = [Indexmap::Section.new(filename: "sitemap-pages.xml", entries: source)]
|
|
124
|
+
|
|
125
|
+
assert_equal ["sitemap-pages.xml", "sitemap.xml"], Indexmap.create(configuration: configuration)
|
|
126
|
+
assert_equal 1, iterations
|
|
127
|
+
assert_includes configuration.storage.read("sitemap.xml"), "<lastmod>2026-04-21T00:00:00Z</lastmod>"
|
|
128
|
+
assert_includes configuration.storage.read("sitemap-pages.xml"), "<loc>https://example.com/page</loc>"
|
|
129
|
+
end
|
|
88
130
|
end
|