feat: initial release of volumen — a file-based Markdown blog engine
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "minitest/autorun"
|
||||
require "volumen"
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user