# 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