mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-07-06 09:52:02 +02:00
43a5724b7f
* web: add seo improvements * web: address review comments * web: address review comments
737 lines
25 KiB
Ruby
737 lines
25 KiB
Ruby
# Copyright © 2025-26 l5yth & contributors
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
# frozen_string_literal: true
|
|
|
|
require "spec_helper"
|
|
|
|
RSpec.describe PotatoMesh::App::Pages do
|
|
let(:pages_dir) { File.join(SPEC_TMPDIR, "pages-#{SecureRandom.hex(4)}") }
|
|
|
|
before do
|
|
FileUtils.mkdir_p(pages_dir)
|
|
PotatoMesh::App::Pages.clear_pages_cache!
|
|
end
|
|
|
|
after do
|
|
FileUtils.rm_rf(pages_dir)
|
|
PotatoMesh::App::Pages.clear_pages_cache!
|
|
end
|
|
|
|
# ── parse_page_filename ──────────────────────────────────────
|
|
|
|
describe ".parse_page_filename" do
|
|
it "parses a numeric-prefixed filename" do
|
|
entry = described_class.parse_page_filename("9-contact.md")
|
|
expect(entry).not_to be_nil
|
|
expect(entry.sort_key).to eq("9-contact")
|
|
expect(entry.slug).to eq("contact")
|
|
expect(entry.title).to eq("Contact")
|
|
end
|
|
|
|
it "parses a multi-word slug" do
|
|
entry = described_class.parse_page_filename("10-privacy-policy.md")
|
|
expect(entry.slug).to eq("privacy-policy")
|
|
expect(entry.title).to eq("Privacy Policy")
|
|
end
|
|
|
|
it "parses a filename without numeric prefix" do
|
|
entry = described_class.parse_page_filename("readme.md")
|
|
expect(entry).not_to be_nil
|
|
expect(entry.sort_key).to eq("readme")
|
|
expect(entry.slug).to eq("readme")
|
|
expect(entry.title).to eq("Readme")
|
|
end
|
|
|
|
it "parses a multi-digit prefix" do
|
|
entry = described_class.parse_page_filename("100-faq.md")
|
|
expect(entry.sort_key).to eq("100-faq")
|
|
expect(entry.slug).to eq("faq")
|
|
expect(entry.title).to eq("Faq")
|
|
end
|
|
|
|
it "rejects empty basename" do
|
|
expect(described_class.parse_page_filename(".md")).to be_nil
|
|
end
|
|
|
|
it "downcases uppercase slugs" do
|
|
entry = described_class.parse_page_filename("1-About.md")
|
|
expect(entry).not_to be_nil
|
|
expect(entry.slug).to eq("about")
|
|
end
|
|
|
|
it "rejects slugs with underscores" do
|
|
expect(described_class.parse_page_filename("1-my_page.md")).to be_nil
|
|
end
|
|
|
|
it "rejects slugs with path traversal" do
|
|
expect(described_class.parse_page_filename("../../etc.md")).to be_nil
|
|
end
|
|
|
|
it "rejects slugs starting with a hyphen" do
|
|
expect(described_class.parse_page_filename("1--bad.md")).to be_nil
|
|
end
|
|
|
|
it "rejects slugs ending with a hyphen" do
|
|
expect(described_class.parse_page_filename("bad-.md")).to be_nil
|
|
end
|
|
|
|
it "sets path to nil" do
|
|
entry = described_class.parse_page_filename("1-about.md")
|
|
expect(entry.path).to be_nil
|
|
end
|
|
end
|
|
|
|
# ── load_static_pages ────────────────────────────────────────
|
|
|
|
describe ".load_static_pages" do
|
|
it "returns an empty array when the directory does not exist" do
|
|
result = described_class.load_static_pages("/nonexistent/dir")
|
|
expect(result).to eq([])
|
|
expect(result).to be_frozen
|
|
end
|
|
|
|
it "returns an empty array when the directory argument is nil" do
|
|
result = described_class.load_static_pages(nil)
|
|
expect(result).to eq([])
|
|
expect(result).to be_frozen
|
|
end
|
|
|
|
it "returns an empty array when the directory is empty" do
|
|
result = described_class.load_static_pages(pages_dir)
|
|
expect(result).to eq([])
|
|
end
|
|
|
|
it "discovers and sorts markdown files" do
|
|
File.write(File.join(pages_dir, "5-beta.md"), "# Beta")
|
|
File.write(File.join(pages_dir, "1-alpha.md"), "# Alpha")
|
|
File.write(File.join(pages_dir, "9-gamma.md"), "# Gamma")
|
|
|
|
result = described_class.load_static_pages(pages_dir)
|
|
expect(result.map(&:slug)).to eq(%w[alpha beta gamma])
|
|
expect(result.map(&:sort_key)).to eq(%w[1-alpha 5-beta 9-gamma])
|
|
end
|
|
|
|
it "populates the path field" do
|
|
File.write(File.join(pages_dir, "1-test.md"), "# Test")
|
|
result = described_class.load_static_pages(pages_dir)
|
|
expect(result.first.path).to eq(File.join(pages_dir, "1-test.md"))
|
|
end
|
|
|
|
it "ignores non-md files" do
|
|
File.write(File.join(pages_dir, "1-about.md"), "# About")
|
|
File.write(File.join(pages_dir, "notes.txt"), "text")
|
|
File.write(File.join(pages_dir, "image.png"), "binary")
|
|
|
|
result = described_class.load_static_pages(pages_dir)
|
|
expect(result.length).to eq(1)
|
|
expect(result.first.slug).to eq("about")
|
|
end
|
|
|
|
it "skips files with invalid filenames" do
|
|
File.write(File.join(pages_dir, "1-good.md"), "# Good")
|
|
File.write(File.join(pages_dir, "1-bad_name.md"), "# Bad")
|
|
|
|
result = described_class.load_static_pages(pages_dir)
|
|
expect(result.length).to eq(1)
|
|
expect(result.first.slug).to eq("good")
|
|
end
|
|
|
|
it "deduplicates entries with the same slug keeping the first" do
|
|
File.write(File.join(pages_dir, "1-about.md"), "# First")
|
|
File.write(File.join(pages_dir, "2-about.md"), "# Second")
|
|
|
|
result = described_class.load_static_pages(pages_dir)
|
|
expect(result.length).to eq(1)
|
|
expect(result.first.sort_key).to eq("1-about")
|
|
end
|
|
|
|
it "limits entries to MAX_PAGES" do
|
|
(1..55).each do |i|
|
|
File.write(File.join(pages_dir, "#{i}-page#{i}.md"), "# Page #{i}")
|
|
end
|
|
|
|
result = described_class.load_static_pages(pages_dir)
|
|
expect(result.length).to eq(PotatoMesh::App::Pages::MAX_PAGES)
|
|
end
|
|
|
|
it "returns a frozen array" do
|
|
File.write(File.join(pages_dir, "1-test.md"), "# Test")
|
|
result = described_class.load_static_pages(pages_dir)
|
|
expect(result).to be_frozen
|
|
end
|
|
end
|
|
|
|
# ── render_page_content ──────────────────────────────────────
|
|
|
|
describe ".render_page_content" do
|
|
it "renders markdown headings to HTML" do
|
|
path = File.join(pages_dir, "1-test.md")
|
|
File.write(path, "# Hello World\n\nSome text.")
|
|
entry = PotatoMesh::App::Pages::PageEntry.new(
|
|
sort_key: "1-test", slug: "test", title: "Test", path: path,
|
|
)
|
|
|
|
html = described_class.render_page_content(entry)
|
|
expect(html).to include("<h1")
|
|
expect(html).to include("Hello World")
|
|
expect(html).to include("<p>Some text.</p>")
|
|
end
|
|
|
|
it "renders links" do
|
|
path = File.join(pages_dir, "1-test.md")
|
|
File.write(path, "[example](https://example.com)")
|
|
entry = PotatoMesh::App::Pages::PageEntry.new(
|
|
sort_key: "1-test", slug: "test", title: "Test", path: path,
|
|
)
|
|
|
|
html = described_class.render_page_content(entry)
|
|
expect(html).to include('href="https://example.com"')
|
|
expect(html).to include("example")
|
|
end
|
|
|
|
it "renders fenced code blocks" do
|
|
path = File.join(pages_dir, "1-test.md")
|
|
File.write(path, "```\ncode here\n```")
|
|
entry = PotatoMesh::App::Pages::PageEntry.new(
|
|
sort_key: "1-test", slug: "test", title: "Test", path: path,
|
|
)
|
|
|
|
html = described_class.render_page_content(entry)
|
|
expect(html).to include("<code")
|
|
expect(html).to include("code here")
|
|
end
|
|
|
|
it "renders tables" do
|
|
path = File.join(pages_dir, "1-test.md")
|
|
File.write(path, "| A | B |\n| - | - |\n| 1 | 2 |")
|
|
entry = PotatoMesh::App::Pages::PageEntry.new(
|
|
sort_key: "1-test", slug: "test", title: "Test", path: path,
|
|
)
|
|
|
|
html = described_class.render_page_content(entry)
|
|
expect(html).to include("<table")
|
|
expect(html).to include("<td>")
|
|
end
|
|
|
|
it "does not pass through raw HTML script tags" do
|
|
path = File.join(pages_dir, "1-test.md")
|
|
File.write(path, "<script>alert('xss')</script>\n\nSafe text.")
|
|
entry = PotatoMesh::App::Pages::PageEntry.new(
|
|
sort_key: "1-test", slug: "test", title: "Test", path: path,
|
|
)
|
|
|
|
html = described_class.render_page_content(entry)
|
|
expect(html).not_to include("<script>")
|
|
end
|
|
|
|
it "does not pass through raw HTML iframe tags" do
|
|
path = File.join(pages_dir, "1-test.md")
|
|
File.write(path, '<iframe src="https://evil.com"></iframe>')
|
|
entry = PotatoMesh::App::Pages::PageEntry.new(
|
|
sort_key: "1-test", slug: "test", title: "Test", path: path,
|
|
)
|
|
|
|
html = described_class.render_page_content(entry)
|
|
expect(html).not_to include("<iframe")
|
|
end
|
|
|
|
it "returns nil for a nil entry" do
|
|
expect(described_class.render_page_content(nil)).to be_nil
|
|
end
|
|
|
|
it "returns nil for a missing file" do
|
|
entry = PotatoMesh::App::Pages::PageEntry.new(
|
|
sort_key: "1-gone", slug: "gone", title: "Gone",
|
|
path: File.join(pages_dir, "missing.md"),
|
|
)
|
|
expect(described_class.render_page_content(entry)).to be_nil
|
|
end
|
|
|
|
it "returns nil when the file exceeds the size limit" do
|
|
path = File.join(pages_dir, "1-big.md")
|
|
File.write(path, "x" * (PotatoMesh::Config.max_page_file_bytes + 1))
|
|
entry = PotatoMesh::App::Pages::PageEntry.new(
|
|
sort_key: "1-big", slug: "big", title: "Big", path: path,
|
|
)
|
|
|
|
expect(described_class.render_page_content(entry)).to be_nil
|
|
end
|
|
|
|
it "returns nil when path is nil" do
|
|
entry = PotatoMesh::App::Pages::PageEntry.new(
|
|
sort_key: "1-test", slug: "test", title: "Test", path: nil,
|
|
)
|
|
expect(described_class.render_page_content(entry)).to be_nil
|
|
end
|
|
|
|
it "returns nil on a filesystem error" do
|
|
path = File.join(pages_dir, "1-err.md")
|
|
File.write(path, "# Error")
|
|
entry = PotatoMesh::App::Pages::PageEntry.new(
|
|
sort_key: "1-err", slug: "err", title: "Err", path: path,
|
|
)
|
|
|
|
allow(File).to receive(:read).with(path, encoding: "utf-8").and_raise(Errno::EIO)
|
|
|
|
expect(described_class.render_page_content(entry)).to be_nil
|
|
end
|
|
|
|
it "strips event-handler attributes from allowed tags" do
|
|
path = File.join(pages_dir, "1-test.md")
|
|
File.write(path, '<a href="https://example.com" onclick="alert(1)">link</a>')
|
|
entry = PotatoMesh::App::Pages::PageEntry.new(
|
|
sort_key: "1-test", slug: "test", title: "Test", path: path,
|
|
)
|
|
|
|
html = described_class.render_page_content(entry)
|
|
expect(html).to include('href="https://example.com"')
|
|
expect(html).not_to include("onclick")
|
|
end
|
|
|
|
it "strips nested event-handler bypass attempts" do
|
|
path = File.join(pages_dir, "1-test.md")
|
|
File.write(path, '<a href="#" oonnclick="alert(1)">link</a>')
|
|
entry = PotatoMesh::App::Pages::PageEntry.new(
|
|
sort_key: "1-test", slug: "test", title: "Test", path: path,
|
|
)
|
|
|
|
html = described_class.render_page_content(entry)
|
|
expect(html).not_to include("onclick")
|
|
end
|
|
|
|
it "strips javascript: URIs from href attributes" do
|
|
path = File.join(pages_dir, "1-test.md")
|
|
File.write(path, '<a href="javascript:alert(1)">link</a>')
|
|
entry = PotatoMesh::App::Pages::PageEntry.new(
|
|
sort_key: "1-test", slug: "test", title: "Test", path: path,
|
|
)
|
|
|
|
html = described_class.render_page_content(entry)
|
|
expect(html).not_to include("javascript:")
|
|
end
|
|
|
|
it "preserves allowed HTML tags while stripping disallowed ones" do
|
|
path = File.join(pages_dir, "1-mixed.md")
|
|
File.write(path, "<strong>bold</strong> <script>bad</script>")
|
|
entry = PotatoMesh::App::Pages::PageEntry.new(
|
|
sort_key: "1-mixed", slug: "mixed", title: "Mixed", path: path,
|
|
)
|
|
|
|
html = described_class.render_page_content(entry)
|
|
expect(html).to include("<strong>")
|
|
expect(html).not_to include("<script")
|
|
end
|
|
end
|
|
|
|
# ── find_page_by_slug ───────────────────────────────────────
|
|
|
|
describe ".find_page_by_slug" do
|
|
it "finds a page by slug" do
|
|
File.write(File.join(pages_dir, "1-alpha.md"), "# Alpha")
|
|
File.write(File.join(pages_dir, "2-beta.md"), "# Beta")
|
|
|
|
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
|
|
|
|
page = described_class.find_page_by_slug("beta")
|
|
expect(page).not_to be_nil
|
|
expect(page.slug).to eq("beta")
|
|
end
|
|
|
|
it "returns nil for an unknown slug" do
|
|
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
|
|
expect(described_class.find_page_by_slug("nonexistent")).to be_nil
|
|
end
|
|
end
|
|
|
|
# ── static_pages (caching) ──────────────────────────────────
|
|
|
|
describe ".static_pages" do
|
|
it "returns cached entries from the configured directory" do
|
|
File.write(File.join(pages_dir, "1-cached.md"), "# Cached")
|
|
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
|
|
|
|
result = described_class.static_pages
|
|
expect(result.length).to eq(1)
|
|
expect(result.first.slug).to eq("cached")
|
|
end
|
|
|
|
it "clears the cache when clear_pages_cache! is called" do
|
|
File.write(File.join(pages_dir, "1-first.md"), "# First")
|
|
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
|
|
|
|
first = described_class.static_pages
|
|
expect(first.length).to eq(1)
|
|
|
|
File.write(File.join(pages_dir, "2-second.md"), "# Second")
|
|
described_class.clear_pages_cache!
|
|
|
|
second = described_class.static_pages
|
|
expect(second.length).to eq(2)
|
|
end
|
|
end
|
|
|
|
# ── parse_frontmatter ───────────────────────────────────────
|
|
|
|
describe ".parse_frontmatter" do
|
|
it "returns an empty hash when there is no frontmatter" do
|
|
expect(described_class.parse_frontmatter("# Just markdown")).to eq({})
|
|
end
|
|
|
|
it "returns an empty hash for non-string input" do
|
|
expect(described_class.parse_frontmatter(nil)).to eq({})
|
|
end
|
|
|
|
it "extracts whitelisted keys" do
|
|
doc = "---\ntitle: Example\ndescription: A short summary.\nimage: https://e.com/p.png\nnoindex: true\n---\nbody"
|
|
|
|
result = described_class.parse_frontmatter(doc)
|
|
|
|
expect(result["title"]).to eq("Example")
|
|
expect(result["description"]).to eq("A short summary.")
|
|
expect(result["image"]).to eq("https://e.com/p.png")
|
|
expect(result["noindex"]).to be(true)
|
|
end
|
|
|
|
it "ignores keys outside the whitelist" do
|
|
doc = "---\ntitle: Example\nrandom: malicious\n---\nbody"
|
|
|
|
result = described_class.parse_frontmatter(doc)
|
|
|
|
expect(result).to have_key("title")
|
|
expect(result).not_to have_key("random")
|
|
end
|
|
|
|
it "treats malformed YAML as having no frontmatter" do
|
|
doc = "---\ntitle: : :\n---\nbody"
|
|
|
|
expect(described_class.parse_frontmatter(doc)).to eq({})
|
|
end
|
|
|
|
it "ignores frontmatter that does not parse into a Hash" do
|
|
doc = "---\n- one\n- two\n---\nbody"
|
|
|
|
expect(described_class.parse_frontmatter(doc)).to eq({})
|
|
end
|
|
|
|
it "coerces non-string scalars into strings for text fields" do
|
|
doc = "---\ntitle: 42\n---\nbody"
|
|
|
|
expect(described_class.parse_frontmatter(doc)["title"]).to eq("42")
|
|
end
|
|
|
|
it "drops blank string values" do
|
|
doc = "---\ntitle: ' '\n---\nbody"
|
|
|
|
expect(described_class.parse_frontmatter(doc)["title"]).to be_nil
|
|
end
|
|
|
|
it "accepts an https image URL" do
|
|
doc = "---\nimage: https://e.com/p.png\n---\nbody"
|
|
|
|
expect(described_class.parse_frontmatter(doc)["image"]).to eq("https://e.com/p.png")
|
|
end
|
|
|
|
it "rejects javascript: image URLs" do
|
|
doc = "---\nimage: 'javascript:alert(1)'\n---\nbody"
|
|
|
|
expect(described_class.parse_frontmatter(doc)["image"]).to be_nil
|
|
end
|
|
|
|
it "rejects data: image URLs" do
|
|
doc = "---\nimage: 'data:image/png;base64,iVBORw0KGgo='\n---\nbody"
|
|
|
|
expect(described_class.parse_frontmatter(doc)["image"]).to be_nil
|
|
end
|
|
|
|
it "rejects relative image paths" do
|
|
doc = "---\nimage: /assets/p.png\n---\nbody"
|
|
|
|
expect(described_class.parse_frontmatter(doc)["image"]).to be_nil
|
|
end
|
|
end
|
|
|
|
# ── strip_frontmatter ───────────────────────────────────────
|
|
|
|
describe ".strip_frontmatter" do
|
|
it "removes the leading frontmatter block" do
|
|
input = "---\ntitle: A\n---\n# Body\n"
|
|
expect(described_class.strip_frontmatter(input)).to eq("# Body\n")
|
|
end
|
|
|
|
it "passes through content without frontmatter unchanged" do
|
|
input = "# Body\n"
|
|
expect(described_class.strip_frontmatter(input)).to eq(input)
|
|
end
|
|
|
|
it "passes non-string input through" do
|
|
expect(described_class.strip_frontmatter(nil)).to be_nil
|
|
end
|
|
end
|
|
|
|
# ── truthy_frontmatter? ─────────────────────────────────────
|
|
|
|
describe ".truthy_frontmatter?" do
|
|
it "passes booleans through" do
|
|
expect(described_class.truthy_frontmatter?(true)).to be(true)
|
|
expect(described_class.truthy_frontmatter?(false)).to be(false)
|
|
end
|
|
|
|
it "matches common truthy strings" do
|
|
%w[true Yes 1 ON].each do |literal|
|
|
expect(described_class.truthy_frontmatter?(literal)).to be(true)
|
|
end
|
|
end
|
|
|
|
it "returns false for unknown values" do
|
|
expect(described_class.truthy_frontmatter?("nope")).to be(false)
|
|
end
|
|
end
|
|
|
|
# ── read_frontmatter_probe ──────────────────────────────────
|
|
|
|
describe ".read_frontmatter_probe" do
|
|
let(:dir) { File.join(SPEC_TMPDIR, "probe-#{SecureRandom.hex(4)}") }
|
|
|
|
before { FileUtils.mkdir_p(dir) }
|
|
after { FileUtils.rm_rf(dir) }
|
|
|
|
it "returns an empty string for a missing file" do
|
|
expect(described_class.read_frontmatter_probe(File.join(dir, "missing.md"))).to eq("")
|
|
end
|
|
|
|
it "reads the first FRONTMATTER_PROBE_BYTES bytes of the file" do
|
|
path = File.join(dir, "long.md")
|
|
File.write(path, "x" * (PotatoMesh::App::Pages::FRONTMATTER_PROBE_BYTES + 10))
|
|
|
|
probe = described_class.read_frontmatter_probe(path)
|
|
|
|
expect(probe.length).to eq(PotatoMesh::App::Pages::FRONTMATTER_PROBE_BYTES)
|
|
end
|
|
|
|
it "returns an empty string on filesystem errors" do
|
|
path = File.join(dir, "err.md")
|
|
File.write(path, "data")
|
|
allow(File).to receive(:open).with(path, "r:UTF-8").and_raise(Errno::EIO)
|
|
|
|
expect(described_class.read_frontmatter_probe(path)).to eq("")
|
|
end
|
|
end
|
|
|
|
# ── apply_frontmatter ───────────────────────────────────────
|
|
|
|
describe ".apply_frontmatter" do
|
|
it "returns the original entry when nothing is supplied" do
|
|
base = PotatoMesh::App::Pages::PageEntry.new(slug: "x", title: "X")
|
|
|
|
result = described_class.apply_frontmatter(base, {})
|
|
|
|
expect(result.title).to eq("X")
|
|
expect(result.description).to be_nil
|
|
expect(result.image).to be_nil
|
|
expect(result.noindex).to be(false)
|
|
end
|
|
|
|
it "returns nil when the base entry is nil" do
|
|
expect(described_class.apply_frontmatter(nil, {})).to be_nil
|
|
end
|
|
|
|
it "overrides title when frontmatter provides one" do
|
|
base = PotatoMesh::App::Pages::PageEntry.new(slug: "x", title: "X")
|
|
|
|
result = described_class.apply_frontmatter(base, "title" => "Custom")
|
|
|
|
expect(result.title).to eq("Custom")
|
|
end
|
|
|
|
it "keeps the filename-derived title when frontmatter omits one" do
|
|
base = PotatoMesh::App::Pages::PageEntry.new(slug: "x", title: "X")
|
|
|
|
result = described_class.apply_frontmatter(base, "description" => "Note")
|
|
|
|
expect(result.title).to eq("X")
|
|
end
|
|
|
|
it "captures noindex flag" do
|
|
base = PotatoMesh::App::Pages::PageEntry.new(slug: "x", title: "X")
|
|
|
|
result = described_class.apply_frontmatter(base, "noindex" => true)
|
|
|
|
expect(result.noindex).to be(true)
|
|
end
|
|
|
|
it "captures image and description fields" do
|
|
base = PotatoMesh::App::Pages::PageEntry.new(slug: "x", title: "X")
|
|
|
|
result = described_class.apply_frontmatter(
|
|
base,
|
|
"description" => "A summary.",
|
|
"image" => "https://e.com/p.png",
|
|
)
|
|
|
|
expect(result.description).to eq("A summary.")
|
|
expect(result.image).to eq("https://e.com/p.png")
|
|
end
|
|
end
|
|
|
|
# ── load_static_pages with frontmatter ──────────────────────
|
|
|
|
describe ".load_static_pages with frontmatter" do
|
|
it "applies frontmatter values during directory scans" do
|
|
File.write(
|
|
File.join(pages_dir, "1-about.md"),
|
|
"---\ntitle: About Page\ndescription: A summary.\nnoindex: true\n---\n# Body\n",
|
|
)
|
|
|
|
result = described_class.load_static_pages(pages_dir)
|
|
|
|
expect(result.first.title).to eq("About Page")
|
|
expect(result.first.description).to eq("A summary.")
|
|
expect(result.first.noindex).to be(true)
|
|
end
|
|
end
|
|
|
|
describe ".render_page_content with frontmatter" do
|
|
it "strips frontmatter before rendering" do
|
|
path = File.join(pages_dir, "1-test.md")
|
|
File.write(path, "---\ntitle: Doc\n---\n# Body Heading\n")
|
|
entry = PotatoMesh::App::Pages::PageEntry.new(
|
|
sort_key: "1-test", slug: "test", title: "Test", path: path,
|
|
)
|
|
|
|
html = described_class.render_page_content(entry)
|
|
|
|
expect(html).to include("Body Heading")
|
|
expect(html).not_to include("title: Doc")
|
|
end
|
|
end
|
|
|
|
# ── production_environment? ─────────────────────────────────
|
|
|
|
describe ".production_environment?" do
|
|
it "returns false in the test environment" do
|
|
expect(described_class.production_environment?).to be false
|
|
end
|
|
|
|
it "returns true when RACK_ENV is production" do
|
|
original = ENV["RACK_ENV"]
|
|
begin
|
|
ENV["RACK_ENV"] = "production"
|
|
expect(described_class.production_environment?).to be true
|
|
ensure
|
|
ENV["RACK_ENV"] = original
|
|
end
|
|
end
|
|
|
|
it "returns true when APP_ENV is production" do
|
|
original_rack = ENV["RACK_ENV"]
|
|
original_app = ENV["APP_ENV"]
|
|
begin
|
|
ENV["RACK_ENV"] = "test"
|
|
ENV["APP_ENV"] = "production"
|
|
expect(described_class.production_environment?).to be true
|
|
ensure
|
|
ENV["RACK_ENV"] = original_rack
|
|
ENV["APP_ENV"] = original_app
|
|
end
|
|
end
|
|
end
|
|
|
|
# ── Route integration ───────────────────────────────────────
|
|
|
|
let(:app) { Sinatra::Application }
|
|
|
|
describe "GET /pages/:slug" do
|
|
before do
|
|
FileUtils.mkdir_p(pages_dir)
|
|
File.write(File.join(pages_dir, "1-about.md"), "# About\n\nWelcome.")
|
|
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
|
|
PotatoMesh::App::Pages.clear_pages_cache!
|
|
end
|
|
|
|
it "renders a valid page with 200" do
|
|
get "/pages/about"
|
|
expect(last_response).to be_ok
|
|
expect(last_response.body).to include("About")
|
|
expect(last_response.body).to include("Welcome.")
|
|
expect(last_response.body).to include("static-page")
|
|
end
|
|
|
|
it "renders the page within the site layout" do
|
|
get "/pages/about"
|
|
expect(last_response.body).to include("site-header")
|
|
expect(last_response.body).to include("site-nav")
|
|
end
|
|
|
|
it "marks the page as active in nav" do
|
|
get "/pages/about"
|
|
expect(last_response.body).to include('aria-current="page"')
|
|
end
|
|
|
|
it "returns 404 for an unknown slug" do
|
|
get "/pages/nonexistent"
|
|
expect(last_response.status).to eq(404)
|
|
end
|
|
|
|
it "rejects path traversal attempts" do
|
|
get "/pages/..%2F..%2Fetc"
|
|
expect(last_response.status).to be >= 400
|
|
end
|
|
|
|
it "returns 400 for an uppercase slug" do
|
|
get "/pages/ABOUT"
|
|
expect(last_response.status).to eq(400)
|
|
end
|
|
|
|
it "returns 400 for a slug with encoded special characters" do
|
|
get "/pages/a%3Cb"
|
|
expect(last_response.status).to eq(400)
|
|
end
|
|
|
|
it "returns 500 when page content cannot be rendered" do
|
|
File.write(File.join(pages_dir, "1-about.md"), "# About")
|
|
PotatoMesh::App::Pages.clear_pages_cache!
|
|
|
|
allow(PotatoMesh::App::Pages).to receive(:render_page_content).and_return(nil)
|
|
|
|
get "/pages/about"
|
|
expect(last_response.status).to eq(500)
|
|
end
|
|
|
|
it "includes nav links for static pages" do
|
|
File.write(File.join(pages_dir, "2-contact.md"), "# Contact")
|
|
PotatoMesh::App::Pages.clear_pages_cache!
|
|
|
|
get "/pages/about"
|
|
expect(last_response.body).to include('href="/pages/about"')
|
|
expect(last_response.body).to include('href="/pages/contact"')
|
|
end
|
|
end
|
|
|
|
describe "static page nav links on other pages" do
|
|
before do
|
|
FileUtils.mkdir_p(pages_dir)
|
|
File.write(File.join(pages_dir, "1-info.md"), "# Info")
|
|
allow(PotatoMesh::Config).to receive(:pages_directory).and_return(pages_dir)
|
|
PotatoMesh::App::Pages.clear_pages_cache!
|
|
end
|
|
|
|
it "shows page links in the dashboard nav" do
|
|
get "/"
|
|
expect(last_response.body).to include('href="/pages/info"')
|
|
expect(last_response.body).to include("Info")
|
|
end
|
|
end
|
|
end
|