#!/usr/bin/env escript
%% -*- erlang -*-
% Licensed under the Apache License, Version 2.0 (the "License"); you may not
% use this file except in compliance with the License. You may obtain a copy of
% the License at
%
%   http://www.apache.org/licenses/LICENSE-2.0
%
% Unless required by applicable law or agreed to in writing, software
% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
% License for the specific language governing permissions and limitations under
% the License.

-record(user_ctx, {
    name = null,
    roles = [],
    handler
}).

-record(db, {
    main_pid = nil,
    update_pid = nil,
    compactor_pid = nil,
    instance_start_time, % number of microsecs since jan 1 1970 as a binary string
    fd,
    updater_fd,
    fd_ref_counter,
    header,
    committed_update_seq,
    fulldocinfo_by_id_btree,
    docinfo_by_seq_btree,
    local_docs_btree,
    update_seq,
    name,
    filepath,
    validate_doc_funs = [],
    security = [],
    security_ptr = nil,
    user_ctx = #user_ctx{},
    waiting_delayed_commit = nil,
    revs_limit = 1000,
    fsync_options = [],
    options = [],
    compression,
    before_doc_update = nil, % nil | fun(Doc, Db) -> NewDoc
    after_doc_read = nil     % nil | fun(Doc, Db) -> NewDoc
}).

auth_db_name() -> <<"couch_test_auth_db">>.
auth_db_2_name() -> <<"couch_test_auth_db_2">>.
salt() -> <<"SALT">>.


main(_) ->
    test_util:init_code_path(),

    etap:plan(19),
    case (catch test()) of
        ok ->
            etap:end_tests();
        Other ->
            etap:diag(io_lib:format("Test died abnormally: ~p", [Other])),
            etap:bail(Other)
    end,
    ok.


test() ->
    couch_server_sup:start_link(test_util:config_files()),
    OrigName = couch_config:get("couch_httpd_auth", "authentication_db"),
    couch_config:set(
        "couch_httpd_auth", "authentication_db",
        binary_to_list(auth_db_name()), false),
    delete_db(auth_db_name()),
    delete_db(auth_db_2_name()),

    test_auth_db_crash(),

    couch_config:set("couch_httpd_auth", "authentication_db", OrigName, false),
    delete_db(auth_db_name()),
    delete_db(auth_db_2_name()),
    couch_server_sup:stop(),
    ok.


test_auth_db_crash() ->
    Creds0 = couch_auth_cache:get_user_creds("joe"),
    etap:is(Creds0, nil, "Got nil when getting joe's credentials"),

    etap:diag("Adding first version of Joe's user doc"),
    PasswordHash1 = hash_password("pass1"),
    {ok, Rev1} = update_user_doc(auth_db_name(), "joe", "pass1"),

    Creds1 = couch_auth_cache:get_user_creds("joe"),
    etap:is(is_list(Creds1), true, "Got joe's credentials from cache"),
    etap:is(couch_util:get_value(<<"password_sha">>, Creds1), PasswordHash1,
            "Cached credentials have the right password"),

    etap:diag("Updating Joe's user doc password"),
    PasswordHash2 = hash_password("pass2"),
    {ok, _Rev2} = update_user_doc(auth_db_name(), "joe", "pass2", Rev1),

    Creds2 = couch_auth_cache:get_user_creds("joe"),
    etap:is(is_list(Creds2), true, "Got joe's credentials from cache"),
    etap:is(couch_util:get_value(<<"password_sha">>, Creds2), PasswordHash2,
            "Cached credentials have the new password"),

    etap:diag("Shutting down the auth database process"),
    shutdown_db(auth_db_name()),

    {ok, UpdateRev} = get_doc_rev(auth_db_name(), "joe"),
    PasswordHash3 = hash_password("pass3"),
    {ok, _Rev3} = update_user_doc(auth_db_name(), "joe", "pass3", UpdateRev),

    etap:is(get_user_doc_password_sha(auth_db_name(), "joe"),
            PasswordHash3,
            "Latest Joe's doc revision has the new password hash"),

    Creds3 = couch_auth_cache:get_user_creds("joe"),
    etap:is(is_list(Creds3), true, "Got joe's credentials from cache"),
    etap:is(couch_util:get_value(<<"password_sha">>, Creds3), PasswordHash3,
            "Cached credentials have the new password"),

    etap:diag("Deleting Joe's user doc"),
    delete_user_doc(auth_db_name(), "joe"),
    Creds4 = couch_auth_cache:get_user_creds("joe"),
    etap:is(nil, Creds4,
            "Joe's credentials not found in cache after user doc was deleted"),

    etap:diag("Adding new user doc for Joe"),
    PasswordHash5 = hash_password("pass5"),
    {ok, _NewRev1} = update_user_doc(auth_db_name(), "joe", "pass5"),

    Creds5 = couch_auth_cache:get_user_creds("joe"),
    etap:is(is_list(Creds5), true, "Got joe's credentials from cache"),
    etap:is(couch_util:get_value(<<"password_sha">>, Creds5), PasswordHash5,
            "Cached credentials have the right password"),

    full_commit(auth_db_name()),

    etap:diag("Changing the auth database"),
    couch_config:set(
        "couch_httpd_auth", "authentication_db",
        binary_to_list(auth_db_2_name()), false),
    ok = timer:sleep(500),

    Creds6 = couch_auth_cache:get_user_creds("joe"),
    etap:is(nil, Creds6,
            "Joe's credentials not found in cache after auth database changed"),

    etap:diag("Adding first version of Joe's user doc to new auth database"),
    PasswordHash7 = hash_password("pass7"),
    {ok, _} = update_user_doc(auth_db_2_name(), "joe", "pass7"),

    Creds7 = couch_auth_cache:get_user_creds("joe"),
    etap:is(is_list(Creds7), true, "Got joe's credentials from cache"),
    etap:is(couch_util:get_value(<<"password_sha">>, Creds7), PasswordHash7,
            "Cached credentials have the right password"),

    etap:diag("Shutting down the auth database process"),
    shutdown_db(auth_db_2_name()),

    {ok, UpdateRev2} = get_doc_rev(auth_db_2_name(), "joe"),
    PasswordHash8 = hash_password("pass8"),
    {ok, _Rev8} = update_user_doc(auth_db_2_name(), "joe", "pass8", UpdateRev2),

    etap:is(get_user_doc_password_sha(auth_db_2_name(), "joe"),
            PasswordHash8,
            "Latest Joe's doc revision has the new password hash"),

    Creds8 = couch_auth_cache:get_user_creds("joe"),
    etap:is(is_list(Creds8), true, "Got joe's credentials from cache"),
    etap:is(couch_util:get_value(<<"password_sha">>, Creds8), PasswordHash8,
            "Cached credentials have the new password"),

    etap:diag("Changing the auth database again"),
    couch_config:set(
        "couch_httpd_auth", "authentication_db",
        binary_to_list(auth_db_name()), false),
    ok = timer:sleep(500),

    Creds9 = couch_auth_cache:get_user_creds("joe"),
    etap:is(Creds9, Creds5,
            "Got same credentials as before the firt auth database change"),
    etap:is(couch_util:get_value(<<"password_sha">>, Creds9), PasswordHash5,
            "Cached credentials have the right password"),
    ok.


update_user_doc(DbName, UserName, Password) ->
    update_user_doc(DbName, UserName, Password, nil).

update_user_doc(DbName, UserName, Password, Rev) ->
    User = iolist_to_binary(UserName),
    Doc = couch_doc:from_json_obj({[
        {<<"_id">>, <<"org.couchdb.user:", User/binary>>},
        {<<"name">>, User},
        {<<"type">>, <<"user">>},
        {<<"salt">>, salt()},
        {<<"password_sha">>, hash_password(Password)},
        {<<"roles">>, []}
    ] ++ case Rev of
        nil -> [];
        _ ->   [{<<"_rev">>, Rev}]
    end}),
    {ok, AuthDb} = open_auth_db(DbName),
    {ok, NewRev} = couch_db:update_doc(AuthDb, Doc, []),
    ok = couch_db:close(AuthDb),
    {ok, couch_doc:rev_to_str(NewRev)}.


hash_password(Password) ->
    list_to_binary(
        couch_util:to_hex(crypto:sha(iolist_to_binary([Password, salt()])))).


shutdown_db(DbName) ->
    {ok, AuthDb} = open_auth_db(DbName),
    ok = couch_db:close(AuthDb),
    couch_util:shutdown_sync(AuthDb#db.main_pid),
    ok = timer:sleep(1000).


get_doc_rev(DbName, UserName) ->
    DocId = iolist_to_binary([<<"org.couchdb.user:">>, UserName]),
    {ok, AuthDb} = open_auth_db(DbName),
    UpdateRev =
    case couch_db:open_doc(AuthDb, DocId, []) of
    {ok, Doc} ->
        {Props} = couch_doc:to_json_obj(Doc, []),
        couch_util:get_value(<<"_rev">>, Props);
    {not_found, missing} ->
        nil
    end,
    ok = couch_db:close(AuthDb),
    {ok, UpdateRev}.


get_user_doc_password_sha(DbName, UserName) ->
    DocId = iolist_to_binary([<<"org.couchdb.user:">>, UserName]),
    {ok, AuthDb} = open_auth_db(DbName),
    {ok, Doc} = couch_db:open_doc(AuthDb, DocId, []),
    ok = couch_db:close(AuthDb),
    {Props} = couch_doc:to_json_obj(Doc, []),
    couch_util:get_value(<<"password_sha">>, Props).


delete_user_doc(DbName, UserName) ->
    DocId = iolist_to_binary([<<"org.couchdb.user:">>, UserName]),
    {ok, AuthDb} = open_auth_db(DbName),
    {ok, Doc} = couch_db:open_doc(AuthDb, DocId, []),
    {Props} = couch_doc:to_json_obj(Doc, []),
    DeletedDoc = couch_doc:from_json_obj({[
        {<<"_id">>, DocId},
        {<<"_rev">>, couch_util:get_value(<<"_rev">>, Props)},
        {<<"_deleted">>, true}
    ]}),
    {ok, _} = couch_db:update_doc(AuthDb, DeletedDoc, []),
    ok = couch_db:close(AuthDb).


full_commit(DbName) ->
    {ok, AuthDb} = open_auth_db(DbName),
    {ok, _} = couch_db:ensure_full_commit(AuthDb),
    ok = couch_db:close(AuthDb).


open_auth_db(DbName) ->
    couch_db:open_int(
        DbName, [{user_ctx, #user_ctx{roles = [<<"_admin">>]}}]).


delete_db(Name) ->
    couch_server:delete(
        Name, [{user_ctx, #user_ctx{roles = [<<"_admin">>]}}]).
