feat: initial release of volumen — a file-based Markdown blog engine

This commit is contained in:
2026-06-21 13:15:06 +02:00
commit dd3efe870e
54 changed files with 5324 additions and 0 deletions
+251
View File
@@ -0,0 +1,251 @@
# frozen_string_literal: true
require "test_helper"
require "volumen/server"
require "rack/test"
require "tmpdir"
require "fileutils"
class AdminTest < Minitest::Test
include Rack::Test::Methods
def app
Volumen::Server.configured(config: @config, store: @store)
end
def setup
@dir = Dir.mktmpdir
@posts_dir = File.join(@dir, "posts")
FileUtils.mkdir_p(@posts_dir)
@password = "secret"
@hash = Volumen::Password.hash(@password)
@users_path = File.join(@dir, "users.toml")
config_path = File.join(@dir, "config.toml")
File.write(config_path, <<~TOML)
content_dir = "#{@posts_dir}"
users_file = "#{@users_path}"
[admin]
password_hash = "#{@hash}"
TOML
@config = Volumen::Config.load(path: config_path)
@store = Volumen::Store.new(@posts_dir, default_lang: "en")
end
def teardown
FileUtils.remove_entry(@dir)
end
def test_admin_requires_login
get "/admin/"
assert_equal 302, last_response.status
end
def test_login_page_renders
get "/admin/login"
assert_predicate last_response, :ok?
assert_includes last_response.body, "Sign in"
end
def test_session_cookie_is_secure_over_https
get "https://example.org/admin/login"
post "https://example.org/admin/login",
"username" => "admin", "password" => @password, "_csrf" => csrf(last_response.body)
cookie = last_response["Set-Cookie"].to_s
assert_match(/;\s*secure/i, cookie)
assert_match(/;\s*httponly/i, cookie)
assert_match(/;\s*samesite=strict/i, cookie)
end
def test_session_cookie_plain_http_is_not_secure
login
refute_match(/;\s*secure/i, last_response["Set-Cookie"].to_s)
end
def test_wrong_password_is_rejected
get "/admin/login"
post "/admin/login",
"username" => "admin", "password" => "nope", "_csrf" => csrf(last_response.body)
assert_includes last_response.body, "Invalid username or password"
end
def test_login_then_list
login
assert_equal 302, last_response.status
follow_redirect!
assert_includes last_response.body, "Posts"
end
def test_create_post_writes_file
login
get "/admin/posts/new"
post "/admin/posts",
"_csrf" => csrf(last_response.body), "title" => "Test", "slug" => "test",
"lang" => "en", "tags" => "a, b", "body" => "Hello **world**."
assert_equal 302, last_response.status
path = File.join(@posts_dir, "test.md")
assert File.exist?(path)
saved = Volumen::Post.parse(File.read(path))
assert_equal "Test", saved.title
assert_equal %w[a b], saved.tags
end
def test_create_post_with_all_langs
login
get "/admin/posts/new"
post "/admin/posts",
"_csrf" => csrf(last_response.body), "title" => "Global", "slug" => "global",
"lang" => "en", "all_langs" => "on", "body" => "Hi"
assert_equal 302, last_response.status
saved = Volumen::Post.parse(File.read(File.join(@posts_dir, "global.md")))
assert_predicate saved, :all_langs?
end
def test_create_post_with_cover
login
get "/admin/posts/new"
post "/admin/posts",
"_csrf" => csrf(last_response.body), "title" => "Covered", "slug" => "covered",
"lang" => "en", "cover" => "/media/c.png", "body" => "Hi"
assert_equal 302, last_response.status
saved = Volumen::Post.parse(File.read(File.join(@posts_dir, "covered.md")))
assert_equal "/media/c.png", saved.cover
end
def test_preview_requires_login
post "/admin/preview", "body" => "**hi**"
assert_equal 302, last_response.status
end
def test_csrf_is_enforced
login
post "/admin/posts", "title" => "X", "slug" => "x", "body" => "y"
assert_equal 403, last_response.status
end
def test_image_upload_and_serving
login
get "/admin/posts/new"
token = csrf(last_response.body)
image_path = File.join(@dir, "pic.png")
File.binwrite(image_path, "PNGDATA")
post "/admin/uploads",
"_csrf" => token,
"file" => Rack::Test::UploadedFile.new(image_path, "image/png")
assert_predicate last_response, :ok?
url = JSON.parse(last_response.body)["url"]
assert_match(%r{\A/media/}, url)
get url
assert_predicate last_response, :ok?
assert_equal "PNGDATA", last_response.body
end
def test_settings_requires_login
get "/admin/settings"
assert_equal 302, last_response.status
end
def test_change_password
login
get "/admin/settings"
post "/admin/settings/password",
"_csrf" => csrf(last_response.body),
"current_password" => @password, "new_password" => "brand-new"
assert_includes last_response.body, "Password updated"
fresh = Volumen::Users.new(path: @users_path)
refute fresh.authenticate("admin", @password)
assert fresh.authenticate("admin", "brand-new")
end
def test_add_and_login_as_new_user
login
get "/admin/settings"
post "/admin/settings/users",
"_csrf" => csrf(last_response.body), "username" => "editor", "password" => "pw12345"
assert_includes last_response.body, "User added"
post "/admin/logout", "_csrf" => csrf(last_response.body)
get "/admin/login"
post "/admin/login",
"username" => "editor", "password" => "pw12345", "_csrf" => csrf(last_response.body)
assert_equal 302, last_response.status
end
def test_cannot_delete_self
login
get "/admin/settings"
post "/admin/settings/users/admin/delete", "_csrf" => csrf(last_response.body)
assert_includes last_response.body, "cannot delete your own account"
end
def test_admin_can_assign_role
login
add_user("writer", "pw12345", "author")
assert_includes last_response.body, "User added"
assert_equal "author", Volumen::Users.new(path: @users_path).find("writer").role
end
def test_author_cannot_manage_users
login
add_user("writer", "pw12345", "author")
logout
login_as("writer", "pw12345")
follow_redirect!
post "/admin/settings/users",
"_csrf" => csrf(last_response.body), "username" => "intruder", "password" => "pw12345"
assert_equal 403, last_response.status
end
def test_author_settings_omits_user_management
login
add_user("writer", "pw12345", "author")
logout
login_as("writer", "pw12345")
follow_redirect!
get "/admin/settings"
refute_includes last_response.body, "Add user"
end
def test_author_can_create_post
login
add_user("writer", "pw12345", "author")
logout
login_as("writer", "pw12345")
get "/admin/posts/new"
post "/admin/posts",
"_csrf" => csrf(last_response.body), "title" => "By author", "slug" => "by-author",
"lang" => "en", "body" => "Hi"
assert_equal 302, last_response.status
assert File.exist?(File.join(@posts_dir, "by-author.md"))
end
private
def csrf(body)
body[/name="_csrf" value="([^"]+)"/, 1]
end
def login
get "/admin/login"
post "/admin/login",
"username" => "admin", "password" => @password, "_csrf" => csrf(last_response.body)
end
def login_as(username, password)
get "/admin/login"
post "/admin/login",
"username" => username, "password" => password, "_csrf" => csrf(last_response.body)
end
def logout
post "/admin/logout", "_csrf" => csrf(last_response.body)
end
def add_user(username, password, role)
get "/admin/settings"
post "/admin/settings/users",
"_csrf" => csrf(last_response.body),
"username" => username, "password" => password, "role" => role
end
end
+57
View File
@@ -0,0 +1,57 @@
# frozen_string_literal: true
require "test_helper"
require "tmpdir"
class ConfigTest < Minitest::Test
MISSING = "/nonexistent/volumen.toml"
def test_defaults_when_file_missing
config = Volumen::Config.load(path: MISSING)
assert_equal 9090, config.port
assert_equal "en", config.language
end
def test_overrides_apply
config = Volumen::Config.load(
path: MISSING,
overrides: { host: "127.0.0.1", port: 9000, content: "/srv/posts" }
)
assert_equal "127.0.0.1", config.host
assert_equal 9000, config.port
assert_equal "/srv/posts", config.content_dir
end
def test_does_not_mutate_defaults
Volumen::Config.load(path: MISSING, overrides: { host: "10.0.0.1" })
assert_equal "0.0.0.0", Volumen::Config.load(path: MISSING).host
end
def test_loads_and_merges_file
Dir.mktmpdir do |dir|
path = File.join(dir, "config.toml")
File.write(path, "[site]\ntitle = \"Custom\"\n")
config = Volumen::Config.load(path: path)
assert_equal "Custom", config.site["title"]
assert_equal 9090, config.port
end
end
def test_generate_default_writes_template
Dir.mktmpdir do |dir|
path = File.join(dir, "sub", "config.toml")
assert_equal path, Volumen::Config.generate_default(path)
assert_path_exists path
assert_equal 9090, Volumen::Config.load(path: path).port
end
end
def test_generate_default_skips_existing
Dir.mktmpdir do |dir|
path = File.join(dir, "config.toml")
File.write(path, "[server]\nport = 9999\n")
assert_nil Volumen::Config.generate_default(path)
assert_equal 9999, Volumen::Config.load(path: path).port
end
end
end
+47
View File
@@ -0,0 +1,47 @@
# frozen_string_literal: true
require "test_helper"
class FrontmatterTest < Minitest::Test
def test_parse_extracts_metadata_and_body
content = <<~POST
+++
title = "Hello"
tags = ["a", "b"]
+++
Body text.
POST
metadata, body = Volumen::Frontmatter.parse(content)
assert_equal "Hello", metadata["title"]
assert_equal %w[a b], metadata["tags"]
assert_equal "Body text.\n", body
end
def test_parse_without_frontmatter
metadata, body = Volumen::Frontmatter.parse("# Just markdown\n")
assert_empty metadata
assert_equal "# Just markdown\n", body
end
def test_parse_without_closing_delimiter
content = "+++\ntitle = \"x\"\n\nbody"
metadata, body = Volumen::Frontmatter.parse(content)
assert_empty metadata
assert_equal content, body
end
def test_round_trip
metadata = { "title" => "Hello", "tags" => ["intro"], "draft" => false }
dumped = Volumen::Frontmatter.dump(metadata, "Hello **world**.")
parsed_meta, parsed_body = Volumen::Frontmatter.parse(dumped)
assert_equal metadata, parsed_meta
assert_equal "Hello **world**.", parsed_body.strip
end
def test_parse_normalises_crlf
metadata, body = Volumen::Frontmatter.parse("+++\r\ntitle = \"x\"\r\n+++\r\n\r\nBody\r\n")
assert_equal "x", metadata["title"]
assert_equal "Body\n", body
end
end
+33
View File
@@ -0,0 +1,33 @@
# frozen_string_literal: true
require "test_helper"
class MarkdownTest < Minitest::Test
def test_renders_bold
assert_includes Volumen::Markdown.render("**bold**"), "<strong>bold</strong>"
end
def test_renders_heading
assert_includes Volumen::Markdown.render("# Title"), "<h1"
end
def test_renders_gfm_table
table = <<~MD
| A | B |
|---|---|
| 1 | 2 |
MD
assert_includes Volumen::Markdown.render(table), "<table"
end
def test_renders_fenced_code_block
code = <<~MD
```ruby
puts 1
```
MD
html = Volumen::Markdown.render(code)
assert_includes html, "<pre"
assert_includes html, "<code"
end
end
+24
View File
@@ -0,0 +1,24 @@
# frozen_string_literal: true
require "test_helper"
class PasswordTest < Minitest::Test
def test_hash_and_verify_round_trip
encoded = Volumen::Password.hash("s3cret")
assert Volumen::Password.verify("s3cret", encoded)
end
def test_verify_rejects_wrong_password
encoded = Volumen::Password.hash("s3cret")
refute Volumen::Password.verify("nope", encoded)
end
def test_verify_rejects_malformed_input
refute Volumen::Password.verify("x", "not-a-hash")
refute Volumen::Password.verify("x", "")
end
def test_encoded_format
assert Volumen::Password.hash("pw").start_with?("scrypt$16384$8$1$")
end
end
+59
View File
@@ -0,0 +1,59 @@
# frozen_string_literal: true
require "test_helper"
class PostTest < Minitest::Test
def test_parse_builds_post
content = <<~POST
+++
title = "Hello"
slug = "hello"
lang = "en"
tags = ["intro", "ruby"]
date = 2026-01-15
draft = true
+++
First paragraph.
POST
post = Volumen::Post.parse(content)
assert_equal "hello", post.slug
assert_equal "Hello", post.title
assert_equal "en", post.lang
assert_equal %w[intro ruby], post.tags
assert_predicate post, :draft?
assert_equal "2026-01-15", post.date_string
end
def test_excerpt_derived_from_body
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: "# Heading\n\nThe body paragraph.")
assert_equal "The body paragraph.", post.excerpt
end
def test_summary_shape
post = Volumen::Post.new(metadata: { "slug" => "x", "title" => "X" }, body: "Body")
summary = post.summary
assert_equal "/api/volumen/posts/x", summary["url"]
assert_equal "X", summary["title"]
refute_predicate post, :draft?
end
def test_detail_includes_rendered_html
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: "**hi**")
assert_includes post.detail["html"], "<strong>hi</strong>"
end
def test_summary_includes_cover_and_reading_time
post = Volumen::Post.new(
metadata: { "slug" => "x", "cover" => "/media/c.png" },
body: ("word " * 250)
)
assert_equal "/media/c.png", post.summary["cover"]
assert_equal 2, post.summary["reading_time"]
end
def test_reading_time_is_at_least_one
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: "short")
assert_equal 1, post.reading_time
end
end
+95
View File
@@ -0,0 +1,95 @@
# frozen_string_literal: true
require "test_helper"
require "volumen/server"
require "rack/test"
require "tmpdir"
require "fileutils"
require "json"
class ServerTest < Minitest::Test
include Rack::Test::Methods
def app
Volumen::Server.configured(config: @config, store: @store)
end
def setup
@dir = Dir.mktmpdir
File.write(File.join(@dir, "hello.md"), <<~POST)
+++
title = "Hello"
tags = ["intro"]
+++
Hello **world**.
POST
File.write(File.join(@dir, "draft.md"), <<~POST)
+++
title = "Draft"
draft = true
+++
Secret.
POST
@config = Volumen::Config.load(path: "/nonexistent", overrides: { content: @dir })
@store = Volumen::Store.new(@dir, default_lang: "en")
end
def teardown
FileUtils.remove_entry(@dir)
end
def test_site_endpoint
get "/api/volumen/site"
assert_predicate last_response, :ok?
assert_equal "My Blog", JSON.parse(last_response.body)["title"]
end
def test_posts_excludes_drafts
get "/api/volumen/posts"
data = JSON.parse(last_response.body)
assert_equal 1, data["total"]
assert_equal "hello", data["posts"].first["slug"]
end
def test_post_detail_renders_html
get "/api/volumen/posts/hello"
assert_predicate last_response, :ok?
assert_includes JSON.parse(last_response.body)["html"], "<strong>world</strong>"
end
def test_post_not_found
get "/api/volumen/posts/missing"
assert_equal 404, last_response.status
assert_equal "not_found", JSON.parse(last_response.body)["error"]
end
def test_tags_endpoint
get "/api/volumen/tags"
assert_equal [{ "name" => "intro", "count" => 1 }], JSON.parse(last_response.body)["tags"]
end
def test_cors_header_present
get "/api/volumen/site"
header = last_response.headers["Access-Control-Allow-Origin"] ||
last_response.headers["access-control-allow-origin"]
assert_equal "*", header
end
def test_all_langs_post_appears_in_other_languages
File.write(File.join(@dir, "global.md"), <<~POST)
+++
title = "Global"
lang = "en"
all_langs = true
+++
Everywhere.
POST
get "/api/volumen/posts?lang=cs"
slugs = JSON.parse(last_response.body)["posts"].map { |post| post["slug"] }
assert_includes slugs, "global"
refute_includes slugs, "hello"
end
end
+52
View File
@@ -0,0 +1,52 @@
# frozen_string_literal: true
require "test_helper"
require "tmpdir"
require "fileutils"
class StoreTest < Minitest::Test
def setup
@dir = Dir.mktmpdir
File.write(File.join(@dir, "hello.md"), "+++\ntitle = \"Hello\"\n+++\n\nBody\n")
FileUtils.mkdir_p(File.join(@dir, "cs"))
File.write(File.join(@dir, "cs", "ahoj.md"), "+++\ntitle = \"Ahoj\"\n+++\n\nTelo\n")
end
def teardown
FileUtils.remove_entry(@dir)
end
def test_all_loads_every_post
assert_equal 2, Volumen::Store.new(@dir).all.length
end
def test_slug_falls_back_to_filename
slugs = Volumen::Store.new(@dir).all.map(&:slug).sort
assert_equal %w[ahoj hello], slugs
end
def test_lang_derived_from_subdirectory
store = Volumen::Store.new(@dir, default_lang: "en")
assert_equal "cs", store.find("ahoj").lang
assert_equal "en", store.find("hello").lang
end
def test_find_returns_nil_when_missing
assert_nil Volumen::Store.new(@dir).find("nope")
end
def test_all_langs_post_is_visible_in_any_language
File.write(File.join(@dir, "global.md"), <<~POST)
+++
title = "Global"
lang = "en"
all_langs = true
+++
Visible everywhere.
POST
store = Volumen::Store.new(@dir, default_lang: "en")
assert store.find("global", lang: "cs")
assert store.find("global", lang: "en")
end
end
+4
View File
@@ -0,0 +1,4 @@
# frozen_string_literal: true
require "minitest/autorun"
require "volumen"
+114
View File
@@ -0,0 +1,114 @@
# frozen_string_literal: true
require "test_helper"
require "tmpdir"
require "fileutils"
class UsersTest < Minitest::Test
def setup
@dir = Dir.mktmpdir
@path = File.join(@dir, "users.toml")
end
def teardown
FileUtils.remove_entry(@dir)
end
def test_bootstrap_user_from_hash
hash = Volumen::Password.hash("secret")
users = Volumen::Users.new(path: @path, bootstrap_hash: hash)
assert_equal 1, users.all.length
assert users.authenticate("admin", "secret")
end
def test_no_users_without_file_or_hash
refute_predicate Volumen::Users.new(path: @path), :any?
end
def test_add_and_authenticate
users = Volumen::Users.new(path: @path)
assert users.add("alice", "wonderland")
assert_path_exists @path
assert users.authenticate("alice", "wonderland")
refute users.authenticate("alice", "nope")
end
def test_add_rejects_duplicate
users = Volumen::Users.new(path: @path)
users.add("alice", "x")
assert_nil users.add("alice", "y")
end
def test_update_password
users = Volumen::Users.new(path: @path)
users.add("alice", "old")
users.update_password("alice", "new")
refute users.authenticate("alice", "old")
assert users.authenticate("alice", "new")
end
def test_rename
users = Volumen::Users.new(path: @path)
users.add("alice", "x")
assert users.rename("alice", "alicia")
assert_nil users.find("alice")
assert users.find("alicia")
end
def test_delete_keeps_last_user
users = Volumen::Users.new(path: @path)
users.add("alice", "x")
assert_nil users.delete("alice")
users.add("bob", "y")
assert users.delete("bob")
assert_nil users.find("bob")
end
def test_default_role_is_author
users = Volumen::Users.new(path: @path)
users.add("alice", "x")
assert_equal "author", users.find("alice").role
end
def test_add_with_explicit_role
users = Volumen::Users.new(path: @path)
users.add("boss", "x", "admin")
assert_equal "admin", users.find("boss").role
end
def test_invalid_role_falls_back_to_default
users = Volumen::Users.new(path: @path)
users.add("alice", "x", "wizard")
assert_equal "author", users.find("alice").role
end
def test_bootstrap_user_is_admin
users = Volumen::Users.new(path: @path, bootstrap_hash: Volumen::Password.hash("s"))
assert_equal "admin", users.find("admin").role
end
def test_set_role
users = Volumen::Users.new(path: @path)
users.add("alice", "x", "admin")
users.add("bob", "y", "author")
assert users.set_role("bob", "admin")
assert_equal "admin", users.find("bob").role
end
def test_cannot_demote_last_admin
users = Volumen::Users.new(path: @path)
users.add("alice", "x", "admin")
users.add("bob", "y", "author")
assert_nil users.set_role("alice", "author")
assert_equal "admin", users.find("alice").role
end
def test_cannot_delete_last_admin
users = Volumen::Users.new(path: @path)
users.add("alice", "x", "admin")
users.add("bob", "y", "author")
assert_nil users.delete("alice")
users.add("carol", "z", "admin")
assert users.delete("alice")
end
end
+13
View File
@@ -0,0 +1,13 @@
# frozen_string_literal: true
require "test_helper"
class VolumenTest < Minitest::Test
def test_version_is_defined
refute_nil Volumen::VERSION
end
def test_version_is_semver
assert_match(/\A\d+\.\d+\.\d+\z/, Volumen::VERSION)
end
end