summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJan Lehnardt <jan@apache.org>2020-01-04 18:10:03 +0100
committerJan Lehnardt <jan@apache.org>2020-01-04 18:10:03 +0100
commitb5af4c48bbfc515303907171f2148c1fe4989be1 (patch)
tree52b7f513c34526d67adf8707df1bf72a8a37786c
parent5415c3a696495730d5de45ddf339711a205a81b7 (diff)
parentd484b860af6cf41203b666e3e6579ce02c080c17 (diff)
downloadcouchdb-b5af4c48bbfc515303907171f2148c1fe4989be1.tar.gz
Merge remote-tracking branch 'jjrodrig/port-elixir-auth' into multi/elixir-3
* jjrodrig/port-elixir-auth: auth cache test is disabled Disable auth_cache_test Convert test setup/teardown logic into idiomatic ExUnit and use @moduletag config Correct test name Correct test name Cookie auth test fixes Cookie auth test fixes Auth cache test fixes WIP: increase default timeout, revert to fail when login error WIP: make test more robust Port users_db from js test suite to elixir remove debug log level config Remove conflicting functions Port cookie_auth test suite into elixir Add auth cache test into elixir test suite
-rw-r--r--test/elixir/README.md4
-rw-r--r--test/elixir/lib/couch.ex33
-rw-r--r--test/elixir/lib/couch/db_test.ex66
-rw-r--r--test/elixir/test/auth_cache_test.exs212
-rw-r--r--test/elixir/test/cookie_auth_test.exs403
-rw-r--r--test/elixir/test/users_db_test.exs322
6 files changed, 1015 insertions, 25 deletions
diff --git a/test/elixir/README.md b/test/elixir/README.md
index 9e8617257..7984bdcda 100644
--- a/test/elixir/README.md
+++ b/test/elixir/README.md
@@ -40,7 +40,7 @@ X means done, - means partially
- [X] Port compact.js
- [X] Port config.js
- [X] Port conflicts.js
- - [ ] Port cookie_auth.js
+ - [X] Port cookie_auth.js
- [X] Port copy_doc.js
- [ ] Port design_docs.js
- [ ] Port design_options.js
@@ -93,7 +93,7 @@ X means done, - means partially
- [ ] Port show_documents.js
- [ ] Port stats.js
- [ ] Port update_documents.js
- - [ ] Port users_db.js
+ - [X] Port users_db.js
- [ ] Port users_db_security.js
- [X] Port utf8.js
- [X] Port uuids.js
diff --git a/test/elixir/lib/couch.ex b/test/elixir/lib/couch.ex
index 6c7310d56..6a63dffb0 100644
--- a/test/elixir/lib/couch.ex
+++ b/test/elixir/lib/couch.ex
@@ -3,11 +3,10 @@ defmodule Couch.Session do
CouchDB session helpers.
"""
- @enforce_keys [:cookie]
- defstruct [:cookie]
+ defstruct [:cookie, :error]
- def new(cookie) do
- %Couch.Session{cookie: cookie}
+ def new(cookie, error \\ "") do
+ %Couch.Session{cookie: cookie, error: error}
end
def logout(sess) do
@@ -20,6 +19,16 @@ defmodule Couch.Session do
Couch.delete!("/_session", headers: headers)
end
+ def info(sess) do
+ headers = [
+ "Content-Type": "application/x-www-form-urlencoded",
+ "X-CouchDB-WWW-Authenticate": "Cookie",
+ Cookie: sess.cookie
+ ]
+
+ Couch.get("/_session", headers: headers).body
+ end
+
def get(sess, url, opts \\ []), do: go(sess, :get, url, opts)
def get!(sess, url, opts \\ []), do: go!(sess, :get, url, opts)
def put(sess, url, opts \\ []), do: go(sess, :put, url, opts)
@@ -143,12 +152,18 @@ defmodule Couch do
login(user, pass)
end
- def login(user, pass) do
+ def login(user, pass, expect \\ :success) do
resp = Couch.post("/_session", body: %{:username => user, :password => pass})
- true = resp.body["ok"]
- cookie = resp.headers[:"set-cookie"]
- [token | _] = String.split(cookie, ";")
- %Couch.Session{cookie: token}
+
+ if expect == :success do
+ true = resp.body["ok"]
+ cookie = resp.headers[:"set-cookie"]
+ [token | _] = String.split(cookie, ";")
+ %Couch.Session{cookie: token}
+ else
+ true = Map.has_key?(resp.body, "error")
+ %Couch.Session{error: resp.body["error"]}
+ end
end
end
diff --git a/test/elixir/lib/couch/db_test.ex b/test/elixir/lib/couch/db_test.ex
index 47d236ebc..b138937f2 100644
--- a/test/elixir/lib/couch/db_test.ex
+++ b/test/elixir/lib/couch/db_test.ex
@@ -116,16 +116,17 @@ defmodule Couch.DBTest do
end)
end
- def create_user(user) do
- required = [:name, :password, :roles]
+ def prepare_user_doc(user) do
+ required = [:name, :password]
Enum.each(required, fn key ->
assert Keyword.has_key?(user, key), "User missing key: #{key}"
end)
+ id = Keyword.get(user, :id)
name = Keyword.get(user, :name)
password = Keyword.get(user, :password)
- roles = Keyword.get(user, :roles)
+ roles = Keyword.get(user, :roles, [])
assert is_binary(name), "User name must be a string"
assert is_binary(password), "User password must be a string"
@@ -135,14 +136,17 @@ defmodule Couch.DBTest do
assert is_binary(role), "Roles must be a list of strings"
end)
- user_doc = %{
- "_id" => "org.couchdb.user:" <> name,
+ %{
+ "_id" => id || "org.couchdb.user:" <> name,
"type" => "user",
"name" => name,
"roles" => roles,
"password" => password
}
+ end
+ def create_user(user) do
+ user_doc = prepare_user_doc(user)
resp = Couch.get("/_users/#{user_doc["_id"]}")
user_doc =
@@ -182,6 +186,12 @@ defmodule Couch.DBTest do
{:ok, resp}
end
+ def info(db_name) do
+ resp = Couch.get("/#{db_name}")
+ assert resp.status_code == 200
+ resp.body
+ end
+
def bulk_save(db_name, docs) do
resp =
Couch.post(
@@ -290,6 +300,27 @@ defmodule Couch.DBTest do
end
end
+
+ def request_stats(path_steps, is_test) do
+ path =
+ List.foldl(
+ path_steps,
+ "/_node/_local/_stats",
+ fn p, acc ->
+ "#{acc}/#{p}"
+ end
+ )
+
+ path =
+ if is_test do
+ path <> "?flush=true"
+ else
+ path
+ end
+
+ Couch.get(path).body
+ end
+
def retry_until(condition, sleep \\ 100, timeout \\ 30_000) do
retry_until(condition, now(:ms), sleep, timeout)
end
@@ -349,6 +380,7 @@ defmodule Couch.DBTest do
body: :jiffy.encode(setting.value)
)
+ assert resp.status_code == 200
Map.put(acc, node, resp.body)
end)
@@ -364,16 +396,22 @@ defmodule Couch.DBTest do
value = elem(node_value, 1)
if value == ~s(""\\n) do
- Couch.delete(
- "/_node/#{node}/_config/#{setting.section}/#{setting.key}",
- headers: ["X-Couch-Persist": false]
- )
+ resp =
+ Couch.delete(
+ "/_node/#{node}/_config/#{setting.section}/#{setting.key}",
+ headers: ["X-Couch-Persist": false]
+ )
+
+ assert resp.status_code == 200
else
- Couch.put(
- "/_node/#{node}/_config/#{setting.section}/#{setting.key}",
- headers: ["X-Couch-Persist": false],
- body: :jiffy.encode(value)
- )
+ resp =
+ Couch.put(
+ "/_node/#{node}/_config/#{setting.section}/#{setting.key}",
+ headers: ["X-Couch-Persist": false],
+ body: :jiffy.encode(value)
+ )
+
+ assert resp.status_code == 200
end
end)
end)
diff --git a/test/elixir/test/auth_cache_test.exs b/test/elixir/test/auth_cache_test.exs
new file mode 100644
index 000000000..2ba396de7
--- /dev/null
+++ b/test/elixir/test/auth_cache_test.exs
@@ -0,0 +1,212 @@
+defmodule AuthCacheTest do
+ use CouchTestCase
+
+ @moduletag :authentication
+
+ @tag :pending
+ @tag :with_db
+ test "auth cache management", context do
+ db_name = context[:db_name]
+
+ server_config = [
+ %{
+ :section => "chttpd_auth",
+ :key => "authentication_db",
+ :value => db_name
+ },
+ %{
+ :section => "chttpd_auth",
+ :key => "auth_cache_size",
+ :value => "3"
+ },
+ %{
+ :section => "httpd",
+ :key => "authentication_handlers",
+ :value => "{couch_httpd_auth, default_authentication_handler}"
+ },
+ %{
+ :section => "chttpd_auth",
+ :key => "secret",
+ :value => generate_secret(64)
+ }
+ ]
+
+ run_on_modified_server(server_config, fn -> test_fun(db_name) end)
+ end
+
+ defp generate_secret(len) do
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
+ |> String.splitter("", trim: true)
+ |> Enum.take_random(len)
+ |> Enum.join("")
+ end
+
+ defp hits() do
+ hits = request_stats(["couchdb", "auth_cache_hits"], true)
+ hits["value"] || 0
+ end
+
+ defp misses() do
+ misses = request_stats(["couchdb", "auth_cache_misses"], true)
+ misses["value"] || 0
+ end
+
+ defp logout(session) do
+ assert Couch.Session.logout(session).body["ok"]
+ end
+
+ defp login_fail(user, password) do
+ resp = Couch.login(user, password, :fail)
+ assert resp.error, "Login error is expected."
+ end
+
+ defp login(user, password) do
+ sess = Couch.login(user, password)
+ assert sess.cookie, "Login correct is expected"
+ sess
+ end
+
+ defp wait_until_compact_complete(db_name) do
+ retry_until(
+ fn -> Map.get(info(db_name), "compact_running") == false end,
+ 200,
+ 10_000
+ )
+ end
+
+ defp assert_cache(event, user, password, expect \\ :expect_login_success) do
+ hits_before = hits()
+ misses_before = misses()
+
+ session =
+ case expect do
+ :expect_login_success -> login(user, password)
+ :expect_login_fail -> login_fail(user, password)
+ _ -> assert false
+ end
+
+ hits_after = hits()
+ misses_after = misses()
+
+ if expect == :expect_success do
+ logout(session)
+ end
+
+ case event do
+ :expect_miss ->
+ assert misses_after == misses_before + 1,
+ "Cache miss is expected for #{user} after login"
+
+ assert hits_after == hits_before,
+ "No cache hit is expected for #{user} after login"
+
+ :expect_hit ->
+ assert misses_after == misses_before,
+ "No cache miss is expected for #{user} after login"
+
+ assert hits_after == hits_before + 1,
+ "Cache hit is expected for #{user} after login"
+
+ _ ->
+ assert false
+ end
+ end
+
+ defp compact(db_name) do
+ resp = Couch.post("/#{db_name}/_compact")
+ assert resp.status_code == 202
+ resp.body
+ end
+
+ def save_doc(db_name, body) do
+ resp = Couch.put("/#{db_name}/#{body["_id"]}", body: body)
+ assert resp.status_code in [201, 202]
+ assert resp.body["ok"]
+ Map.put(body, "_rev", resp.body["rev"])
+ end
+
+ def delete_doc(db_name, body) do
+ resp = Couch.delete("/#{db_name}/#{body["_id"]}", query: [rev: body["_rev"]])
+ assert resp.status_code in [200, 202]
+ assert resp.body["ok"]
+ {:ok, resp}
+ end
+
+ defp test_fun(db_name) do
+ fdmanana =
+ prepare_user_doc([
+ {:name, "fdmanana"},
+ {:password, "qwerty"},
+ {:roles, ["dev"]}
+ ])
+
+ {:ok, resp} = create_doc(db_name, fdmanana)
+ fdmanana = Map.put(fdmanana, "_rev", resp.body["rev"])
+
+ chris =
+ prepare_user_doc([
+ {:name, "chris"},
+ {:password, "the_god_father"},
+ {:roles, ["dev", "mafia", "white_costume"]}
+ ])
+
+ create_doc(db_name, chris)
+
+ joe =
+ prepare_user_doc([
+ {:name, "joe"},
+ {:password, "functional"},
+ {:roles, ["erlnager"]}
+ ])
+
+ create_doc(db_name, joe)
+
+ johndoe =
+ prepare_user_doc([
+ {:name, "johndoe"},
+ {:password, "123456"},
+ {:roles, ["user"]}
+ ])
+
+ create_doc(db_name, johndoe)
+
+ assert_cache(:expect_miss, "fdmanana", "qwerty")
+ assert_cache(:expect_hit, "fdmanana", "qwerty")
+ assert_cache(:expect_miss, "chris", "the_god_father")
+ assert_cache(:expect_miss, "joe", "functional")
+ assert_cache(:expect_miss, "johndoe", "123456")
+
+ # It's a MRU cache, joe was removed from cache to add johndoe
+ # BUGGED assert_cache(:expect_miss, "joe", "functional")
+
+ assert_cache(:expect_hit, "fdmanana", "qwerty")
+
+ fdmanana = Map.replace!(fdmanana, "password", "foobar")
+ fdmanana = save_doc(db_name, fdmanana)
+
+ # Cache was refreshed
+ # BUGGED
+ # assert_cache(:expect_hit, "fdmanana", "qwerty", :expect_login_fail)
+ # assert_cache(:expect_hit, "fdmanana", "foobar")
+
+ # and yet another update
+ fdmanana = Map.replace!(fdmanana, "password", "javascript")
+ fdmanana = save_doc(db_name, fdmanana)
+
+ # Cache was refreshed
+ # BUGGED
+ # assert_cache(:expect_hit, "fdmanana", "foobar", :expect_login_fail)
+ # assert_cache(:expect_hit, "fdmanana", "javascript")
+
+ delete_doc(db_name, fdmanana)
+
+ assert_cache(:expect_hit, "fdmanana", "javascript", :expect_login_fail)
+
+ # login, compact authentication DB, login again and verify that
+ # there was a cache hit
+ assert_cache(:expect_hit, "johndoe", "123456")
+ compact(db_name)
+ wait_until_compact_complete(db_name)
+ assert_cache(:expect_hit, "johndoe", "123456")
+ end
+end
diff --git a/test/elixir/test/cookie_auth_test.exs b/test/elixir/test/cookie_auth_test.exs
new file mode 100644
index 000000000..ac1110be2
--- /dev/null
+++ b/test/elixir/test/cookie_auth_test.exs
@@ -0,0 +1,403 @@
+defmodule CookieAuthTest do
+ use CouchTestCase
+
+ @moduletag :authentication
+
+ @users_db "_users"
+
+ @moduletag config: [
+ {
+ "chttpd_auth",
+ "authentication_db",
+ @users_db
+ },
+ {
+ "couch_httpd_auth",
+ "authentication_db",
+ @users_db
+ },
+ {
+ "couch_httpd_auth",
+ "iterations",
+ "1"
+ },
+ {
+ "admins",
+ "jan",
+ "apple"
+ }
+ ]
+
+ @password "3.141592653589"
+
+ setup do
+ # Create db if not exists
+ Couch.put("/#{@users_db}")
+
+ resp =
+ Couch.get(
+ "/#{@users_db}/_changes",
+ query: [feed: "longpoll", timeout: 5000, filter: "_design"]
+ )
+
+ assert resp.body
+
+ on_exit(&tear_down/0)
+
+ :ok
+ end
+
+ defp tear_down do
+ # delete users
+ user = URI.encode("org.couchdb.user:jchris")
+ user_doc = Couch.get("/#{@users_db}/#{URI.encode(user)}").body
+ Couch.delete("/#{@users_db}/#{user}", query: [rev: user_doc["_rev"]])
+
+ user = URI.encode("org.couchdb.user:Jason Davies")
+ user_doc = Couch.get("/#{@users_db}/#{user}").body
+ Couch.delete("/#{@users_db}/#{user}", query: [rev: user_doc["_rev"]])
+ end
+
+ defp login(user, password) do
+ sess = Couch.login(user, password)
+ assert sess.cookie, "Login correct is expected"
+ sess
+ end
+
+ defp logout(session) do
+ assert Couch.Session.logout(session).body["ok"]
+ end
+
+ defp login_as(user) do
+ pws = %{
+ "jan" => "apple",
+ "Jason Davies" => @password,
+ "jchris" => "funnybone"
+ }
+
+ user1 = Regex.replace(~r/[0-9]$/, user, "")
+ login(user1, pws[user])
+ end
+
+ defp create_doc_expect_error(db_name, doc, status_code, msg) do
+ resp = Couch.post("/#{db_name}", body: doc)
+ assert resp.status_code == status_code
+ assert resp.body["error"] == msg
+ resp
+ end
+
+ defp open_as(db_name, doc_id, options) do
+ use_session = Keyword.get(options, :use_session)
+ user = Keyword.get(options, :user)
+ expect_response = Keyword.get(options, :expect_response, 200)
+ expect_message = Keyword.get(options, :error_message)
+
+ session = use_session || login_as(user)
+
+ resp =
+ Couch.get(
+ "/#{db_name}/#{URI.encode(doc_id)}",
+ headers: [
+ Cookie: session.cookie,
+ "X-CouchDB-www-Authenticate": "Cookie"
+ ]
+ )
+
+ if use_session == nil do
+ logout(session)
+ end
+
+ assert resp.status_code == expect_response
+
+ if expect_message != nil do
+ assert resp.body["error"] == expect_message
+ end
+
+ resp.body
+ end
+
+ defp save_as(db_name, doc, options) do
+ use_session = Keyword.get(options, :use_session)
+ user = Keyword.get(options, :user)
+ expect_response = Keyword.get(options, :expect_response, [201, 202])
+ expect_message = Keyword.get(options, :error_message)
+
+ session = use_session || login_as(user)
+
+ resp =
+ Couch.put(
+ "/#{db_name}/#{URI.encode(doc["_id"])}",
+ headers: [
+ Cookie: session.cookie,
+ "X-CouchDB-www-Authenticate": "Cookie"
+ ],
+ body: doc
+ )
+
+ if use_session == nil do
+ logout(session)
+ end
+
+ if is_list(expect_response) do
+ assert resp.status_code in expect_response
+ else
+ assert resp.status_code == expect_response
+ end
+
+ if expect_message != nil do
+ assert resp.body["error"] == expect_message
+ end
+
+ resp
+ end
+
+ defp delete_as(db_name, doc, options) do
+ use_session = Keyword.get(options, :use_session)
+ user = Keyword.get(options, :user)
+ expect_response = Keyword.get(options, :expect_response, [200, 202])
+ expect_message = Keyword.get(options, :error_message)
+
+ session = use_session || login_as(user)
+
+ resp =
+ Couch.delete(
+ "/#{db_name}/#{URI.encode(doc["_id"])}",
+ headers: [
+ Cookie: session.cookie,
+ "X-CouchDB-www-Authenticate": "Cookie"
+ ]
+ )
+
+ if use_session == nil do
+ logout(session)
+ end
+
+ if is_list(expect_response) do
+ assert resp.status_code in expect_response
+ else
+ assert resp.status_code == expect_response
+ end
+
+ if expect_message != nil do
+ assert resp.body["error"] == expect_message
+ end
+
+ resp
+ end
+
+ defp test_change_admin_fun do
+ sess = login("jchris", "funnybone")
+ info = Couch.Session.info(sess)
+ assert info["userCtx"]["name"] == "jchris"
+ assert Enum.member?(info["userCtx"]["roles"], "_admin")
+ assert Enum.member?(info["userCtx"]["roles"], "foo")
+
+ jchris_user_doc =
+ open_as(
+ @users_db,
+ "org.couchdb.user:jchris",
+ use_session: sess
+ )
+
+ jchris_user_doc = Map.drop(jchris_user_doc, [:salt, :password_sha])
+ save_as(@users_db, jchris_user_doc, use_session: sess)
+ logout(sess)
+ sess = login("jchris", "funnybone")
+ info = Couch.Session.info(sess)
+ assert info["userCtx"]["name"] == "jchris"
+ assert Enum.member?(info["userCtx"]["roles"], "_admin")
+ assert info["info"]["authenticated"] == "cookie"
+ assert info["info"]["authentication_db"] == @users_db
+ assert Enum.member?(info["userCtx"]["roles"], "foo")
+ logout(sess)
+ end
+
+ test "cookie auth" do
+ # test that the users db is born with the auth ddoc
+ ddoc = open_as(@users_db, "_design/_auth", user: "jan")
+ assert ddoc["validate_doc_update"] != nil
+
+ jason_user_doc =
+ prepare_user_doc([
+ {:name, "Jason Davies"},
+ {:password, @password}
+ ])
+
+ create_doc(@users_db, jason_user_doc)
+ jason_check_doc = open_as(@users_db, jason_user_doc["_id"], user: "jan")
+ assert jason_check_doc["name"] == "Jason Davies"
+
+ jchris_user_doc =
+ prepare_user_doc([
+ {:name, "jchris"},
+ {:password, "funnybone"}
+ ])
+
+ {:ok, resp} = create_doc(@users_db, jchris_user_doc)
+ jchris_rev = resp.body["rev"]
+
+ duplicate_jchris_user_doc =
+ prepare_user_doc([
+ {:name, "jchris"},
+ {:password, "eh, Boo-Boo?"}
+ ])
+
+ # make sure we cant create duplicate users
+ create_doc_expect_error(@users_db, duplicate_jchris_user_doc, 409, "conflict")
+
+ # we can't create _names
+ underscore_user_doc =
+ prepare_user_doc([
+ {:name, "_why"},
+ {:password, "copperfield"}
+ ])
+
+ create_doc_expect_error(@users_db, underscore_user_doc, 403, "forbidden")
+
+ # we can't create malformed ids
+ bad_id_user_doc =
+ prepare_user_doc([
+ {:id, "org.apache.couchdb:w00x"},
+ {:name, "w00x"},
+ {:password, "bar"}
+ ])
+
+ create_doc_expect_error(@users_db, bad_id_user_doc, 403, "forbidden")
+
+ # login works
+ session = login_as("Jason Davies")
+ info = Couch.Session.info(session)
+ assert info["userCtx"]["name"] == "Jason Davies"
+ assert not Enum.member?(info["userCtx"]["roles"], "_admin")
+
+ # update one's own credentials document
+ jason_user_doc =
+ jason_user_doc
+ |> Map.put("_rev", jason_check_doc["_rev"])
+ |> Map.put("foo", 2)
+
+ resp = save_as(@users_db, jason_user_doc, use_session: session)
+ jason_user_doc_rev = resp.body["rev"]
+
+ # can't delete another users doc unless you are admin
+
+ jchris_user_doc = Map.put(jchris_user_doc, "_rev", jchris_rev)
+
+ delete_as(
+ @users_db,
+ jchris_user_doc,
+ use_session: session,
+ expect_response: 404,
+ error_message: "not_found"
+ )
+
+ logout(session)
+
+ # test redirect on success
+ resp =
+ Couch.post(
+ "/_session",
+ query: [next: "/_up"],
+ body: %{
+ :username => "Jason Davies",
+ :password => @password
+ }
+ )
+
+ assert resp.status_code == 302
+ assert resp.body["ok"]
+ assert String.ends_with?(resp.headers["location"], "/_up")
+
+ # test redirect on fail
+ resp =
+ Couch.post(
+ "/_session",
+ query: [fail: "/_up"],
+ body: %{
+ :username => "Jason Davies",
+ :password => "foobar"
+ }
+ )
+
+ assert resp.status_code == 302
+ assert resp.body["error"] == "unauthorized"
+ assert String.ends_with?(resp.headers["location"], "/_up")
+
+ session = login("jchris", "funnybone")
+ info = Couch.Session.info(session)
+ assert info["userCtx"]["name"] == "jchris"
+ assert Enum.empty?(info["userCtx"]["roles"])
+
+ jason_user_doc =
+ jason_user_doc
+ |> Map.put("_rev", jason_user_doc_rev)
+ |> Map.put("foo", 3)
+
+ save_as(
+ @users_db,
+ jason_user_doc,
+ use_session: session,
+ expect_response: 404,
+ error_message: "not_found"
+ )
+
+ jchris_user_doc = Map.put(jchris_user_doc, "roles", ["foo"])
+
+ save_as(
+ @users_db,
+ jchris_user_doc,
+ use_session: session,
+ expect_response: 403,
+ error_message: "forbidden"
+ )
+
+ logout(session)
+
+ jchris_user_doc = Map.put(jchris_user_doc, "foo", ["foo"])
+
+ resp =
+ save_as(
+ @users_db,
+ jchris_user_doc,
+ user: "jan"
+ )
+
+ # test that you can't save system (underscore) roles even if you are admin
+ jchris_user_doc =
+ jchris_user_doc
+ |> Map.put("roles", ["_bar"])
+ |> Map.put("_rev", resp.body["rev"])
+
+ save_as(
+ @users_db,
+ jchris_user_doc,
+ user: "jan",
+ expect_response: 403,
+ error_message: "forbidden"
+ )
+
+ session = login("jchris", "funnybone")
+ info = Couch.Session.info(session)
+
+ assert not Enum.member?(info["userCtx"]["roles"], "_admin")
+ assert(Enum.member?(info["userCtx"]["roles"], "foo"))
+
+ logout(session)
+
+ login("jan", "apple")
+
+ run_on_modified_server(
+ [
+ %{
+ :section => "admins",
+ :key => "jchris",
+ :value => "funnybone"
+ }
+ ],
+ &test_change_admin_fun/0
+ )
+
+ # log in one last time so run_on_modified_server can clean up the admin account
+ login("jan", "apple")
+ end
+end
diff --git a/test/elixir/test/users_db_test.exs b/test/elixir/test/users_db_test.exs
new file mode 100644
index 000000000..71ab2f7e7
--- /dev/null
+++ b/test/elixir/test/users_db_test.exs
@@ -0,0 +1,322 @@
+defmodule UsersDbTest do
+ use CouchTestCase
+
+ @moduletag :authentication
+
+ @users_db_name "_users"
+
+ @moduletag config: [
+ {
+ "chttpd_auth",
+ "authentication_db",
+ @users_db_name
+ },
+ {
+ "couch_httpd_auth",
+ "authentication_db",
+ @users_db_name
+ },
+ {
+ "couch_httpd_auth",
+ "iterations",
+ "1"
+ },
+ {
+ "admins",
+ "jan",
+ "apple"
+ }
+ ]
+
+ setup do
+ # Create db if not exists
+ Couch.put("/#{@users_db_name}")
+
+ resp =
+ Couch.get(
+ "/#{@users_db_name}/_changes",
+ query: [feed: "longpoll", timeout: 5000, filter: "_design"]
+ )
+
+ assert resp.body
+
+ on_exit(&tear_down/0)
+
+ :ok
+ end
+
+ defp tear_down do
+ delete_db(@users_db_name)
+ create_db(@users_db_name)
+ end
+
+ defp replicate(source, target, rep_options \\ []) do
+ headers = Keyword.get(rep_options, :headers, [])
+ body = Keyword.get(rep_options, :body, %{})
+
+ body =
+ body
+ |> Map.put("source", source)
+ |> Map.put("target", target)
+
+ retry_until(
+ fn ->
+ resp = Couch.post("/_replicate", headers: headers, body: body, timeout: 10_000)
+ assert HTTPotion.Response.success?(resp)
+ assert resp.status_code == 200
+ assert resp.body["ok"]
+ resp
+ end,
+ 500,
+ 20_000
+ )
+ end
+
+ defp save_as(db_name, doc, options) do
+ session = Keyword.get(options, :use_session)
+ expect_response = Keyword.get(options, :expect_response, [201, 202])
+ expect_message = Keyword.get(options, :error_message)
+ expect_reason = Keyword.get(options, :error_reason)
+
+ headers =
+ if session != nil do
+ [
+ Cookie: session.cookie,
+ "X-CouchDB-www-Authenticate": "Cookie"
+ ]
+ else
+ []
+ end
+
+ resp =
+ Couch.put(
+ "/#{db_name}/#{URI.encode(doc["_id"])}",
+ headers: headers,
+ body: doc
+ )
+
+ if is_list(expect_response) do
+ assert resp.status_code in expect_response
+ else
+ assert resp.status_code == expect_response
+ end
+
+ if expect_message != nil do
+ assert resp.body["error"] == expect_message
+ end
+
+ if expect_reason != nil do
+ assert resp.body["reason"] == expect_reason
+ end
+
+ resp
+ end
+
+ defp login(user, password) do
+ sess = Couch.login(user, password)
+ assert sess.cookie, "Login correct is expected"
+ sess
+ end
+
+ defp logout(session) do
+ assert Couch.Session.logout(session).body["ok"]
+ end
+
+ @tag :with_db
+ test "users db", context do
+ db_name = context[:db_name]
+ # test that the users db is born with the auth ddoc
+ ddoc = Couch.get("/#{@users_db_name}/_design/_auth")
+ assert ddoc.body["validate_doc_update"] != nil
+
+ jchris_user_doc =
+ prepare_user_doc([
+ {:name, "jchris@apache.org"},
+ {:password, "funnybone"}
+ ])
+
+ {:ok, resp} = create_doc(@users_db_name, jchris_user_doc)
+ jchris_rev = resp.body["rev"]
+
+ resp =
+ Couch.get(
+ "/_session",
+ headers: [authorization: "Basic #{:base64.encode("jchris@apache.org:funnybone")}"]
+ )
+
+ assert resp.body["userCtx"]["name"] == "jchris@apache.org"
+ assert resp.body["info"]["authenticated"] == "default"
+ assert resp.body["info"]["authentication_db"] == @users_db_name
+ assert resp.body["info"]["authentication_handlers"] == ["cookie", "default"]
+
+ resp =
+ Couch.get(
+ "/_session",
+ headers: [authorization: "Basic Xzpf"]
+ )
+
+ assert resp.body["userCtx"]["name"] == :null
+ assert not Enum.member?(resp.body["info"], "authenticated")
+
+ # ok, now create a conflicting edit on the jchris doc, and make sure there's no login.
+ # (use replication to create the conflict) - need 2 be admin
+ session = login("jan", "apple")
+ replicate(@users_db_name, db_name)
+
+ jchris_user_doc = Map.put(jchris_user_doc, "_rev", jchris_rev)
+
+ jchris_user_doc2 = Map.put(jchris_user_doc, "foo", "bar")
+
+ save_as(@users_db_name, jchris_user_doc2, use_session: session)
+ save_as(@users_db_name, jchris_user_doc, use_session: session, expect_response: 409)
+
+ # then in the other
+ jchris_user_doc3 = Map.put(jchris_user_doc, "foo", "barrrr")
+ save_as(db_name, jchris_user_doc3, use_session: session)
+ replicate(db_name, @users_db_name)
+ # now we should have a conflict
+
+ resp =
+ Couch.get(
+ "/#{@users_db_name}/#{jchris_user_doc3["_id"]}",
+ query: [conflicts: true]
+ )
+
+ assert length(resp.body["_conflicts"]) == 1
+ jchris_with_conflict = resp.body
+
+ logout(session)
+
+ # wait for auth_cache invalidation
+ retry_until(
+ fn ->
+ resp =
+ Couch.get(
+ "/_session",
+ headers: [
+ authorization: "Basic #{:base64.encode("jchris@apache.org:funnybone")}"
+ ]
+ )
+
+ assert resp.body["error"] == "unauthorized"
+ assert String.contains?(resp.body["reason"], "conflict")
+ resp
+ end,
+ 500,
+ 20_000
+ )
+
+ # You can delete a user doc
+ session = login("jan", "apple")
+ info = Couch.Session.info(session)
+ assert Enum.member?(info["userCtx"]["roles"], "_admin")
+
+ resp =
+ Couch.delete(
+ "/#{@users_db_name}/#{jchris_with_conflict["_id"]}",
+ query: [rev: jchris_with_conflict["_rev"]],
+ headers: [
+ Cookie: session.cookie,
+ "X-CouchDB-www-Authenticate": "Cookie"
+ ]
+ )
+
+ assert resp.body["ok"]
+
+ # you can't change doc from type "user"
+ resp =
+ Couch.get(
+ "/#{@users_db_name}/#{jchris_user_doc["_id"]}",
+ headers: [
+ Cookie: session.cookie,
+ "X-CouchDB-www-Authenticate": "Cookie"
+ ]
+ )
+
+ assert resp.status_code == 200
+
+ jchris_user_doc = Map.replace!(resp.body, "type", "not user")
+
+ save_as(
+ @users_db_name,
+ jchris_user_doc,
+ use_session: session,
+ expect_response: 403,
+ error_message: "forbidden",
+ error_reason: "doc.type must be user"
+ )
+
+ # "roles" must be an array
+ jchris_user_doc =
+ jchris_user_doc
+ |> Map.replace!("type", "user")
+ |> Map.replace!("roles", "not an array")
+
+ save_as(
+ @users_db_name,
+ jchris_user_doc,
+ use_session: session,
+ expect_response: 403,
+ error_message: "forbidden",
+ error_reason: "doc.roles must be an array"
+ )
+
+ # "roles" must be and array of strings
+ jchris_user_doc = Map.replace!(jchris_user_doc, "roles", [12])
+
+ save_as(
+ @users_db_name,
+ jchris_user_doc,
+ use_session: session,
+ expect_response: 403,
+ error_message: "forbidden",
+ error_reason: "doc.roles can only contain strings"
+ )
+
+ # "roles" must exist
+ jchris_user_doc = Map.drop(jchris_user_doc, ["roles"])
+
+ save_as(
+ @users_db_name,
+ jchris_user_doc,
+ use_session: session,
+ expect_response: 403,
+ error_message: "forbidden",
+ error_reason: "doc.roles must exist"
+ )
+
+ # character : is not allowed in usernames
+ joe_user_doc =
+ prepare_user_doc([
+ {:name, "joe:erlang"},
+ {:password, "querty"}
+ ])
+
+ save_as(
+ @users_db_name,
+ joe_user_doc,
+ use_session: session,
+ expect_response: 403,
+ error_message: "forbidden",
+ error_reason: "Character `:` is not allowed in usernames."
+ )
+
+ # test that you can login as a user with a password starting with :
+ joe_user_doc =
+ prepare_user_doc([
+ {:name, "foo@example.org"},
+ {:password, ":bar"}
+ ])
+
+ {:ok, _} = create_doc(@users_db_name, joe_user_doc)
+ logout(session)
+
+ resp =
+ Couch.get(
+ "/_session",
+ headers: [authorization: "Basic #{:base64.encode("foo@example.org::bar")}"]
+ )
+
+ assert resp.body["userCtx"]["name"] == "foo@example.org"
+ end
+end