diff options
| author | Jan Lehnardt <jan@apache.org> | 2020-01-04 18:10:03 +0100 |
|---|---|---|
| committer | Jan Lehnardt <jan@apache.org> | 2020-01-04 18:10:03 +0100 |
| commit | b5af4c48bbfc515303907171f2148c1fe4989be1 (patch) | |
| tree | 52b7f513c34526d67adf8707df1bf72a8a37786c | |
| parent | 5415c3a696495730d5de45ddf339711a205a81b7 (diff) | |
| parent | d484b860af6cf41203b666e3e6579ce02c080c17 (diff) | |
| download | couchdb-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.md | 4 | ||||
| -rw-r--r-- | test/elixir/lib/couch.ex | 33 | ||||
| -rw-r--r-- | test/elixir/lib/couch/db_test.ex | 66 | ||||
| -rw-r--r-- | test/elixir/test/auth_cache_test.exs | 212 | ||||
| -rw-r--r-- | test/elixir/test/cookie_auth_test.exs | 403 | ||||
| -rw-r--r-- | test/elixir/test/users_db_test.exs | 322 |
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 |
