From eb36dfbc4587f0031c3e9cb54536471c09584676 Mon Sep 17 00:00:00 2001 From: James Coglan Date: Fri, 9 Jan 2026 12:47:54 +0000 Subject: [PATCH 1/3] fix: Align vdu_rejects counter with actual VDU behaviour This counter is incremented whenever a VDU returns a value other than `1`, whereas `ok` and `true` are also treated as acceptable success values. This fixes the counter to only increment on actual failure responses. --- src/couch/src/couch_query_servers.erl | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/couch/src/couch_query_servers.erl b/src/couch/src/couch_query_servers.erl index 1652fc09a2..7ab662f850 100644 --- a/src/couch/src/couch_query_servers.erl +++ b/src/couch/src/couch_query_servers.erl @@ -477,18 +477,18 @@ builtin_cmp_last(A, B) -> validate_doc_update(Db, DDoc, EditDoc, DiskDoc, Ctx, SecObj) -> JsonEditDoc = couch_doc:to_json_obj(EditDoc, [revs]), JsonDiskDoc = json_doc(DiskDoc), - Resp = ddoc_prompt( - Db, - DDoc, - [<<"validate_doc_update">>], - [JsonEditDoc, JsonDiskDoc, Ctx, SecObj] - ), - if - Resp == 1 -> ok; - true -> couch_stats:increment_counter([couchdb, query_server, vdu_rejects], 1) - end, + Args = [JsonEditDoc, JsonDiskDoc, Ctx, SecObj], + + Resp = + case ddoc_prompt(Db, DDoc, [<<"validate_doc_update">>], Args) of + Code when Code =:= 1; Code =:= ok; Code =:= true -> + ok; + Other -> + couch_stats:increment_counter([couchdb, query_server, vdu_rejects], 1), + Other + end, case Resp of - RespCode when RespCode =:= 1; RespCode =:= ok; RespCode =:= true -> + ok -> ok; {[{<<"forbidden">>, Message}]} -> throw({forbidden, Message}); From ae28558d707ebde7c26468d848ec39ab4bf5ba39 Mon Sep 17 00:00:00 2001 From: James Coglan Date: Thu, 8 Jan 2026 15:19:47 +0000 Subject: [PATCH 2/3] chore: Add some basic testing for the JS-based VDU interface --- test/elixir/test/config/suite.elixir | 6 ++ test/elixir/test/validate_doc_update_test.exs | 79 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 test/elixir/test/validate_doc_update_test.exs diff --git a/test/elixir/test/config/suite.elixir b/test/elixir/test/config/suite.elixir index 520b40a7f3..00dfb5c795 100644 --- a/test/elixir/test/config/suite.elixir +++ b/test/elixir/test/config/suite.elixir @@ -517,6 +517,12 @@ "serial execution is not spuriously counted as loop on test_rewrite_suite_db", "serial execution is not spuriously counted as loop on test_rewrite_suite_db%2Fwith_slashes" ], + "ValidateDocUpdateTest": [ + "JavaScript VDU accepts a valid document", + "JavaScript VDU rejects an invalid document", + "JavaScript VDU accepts a valid change", + "JavaScript VDU rejects an invalid change", + ], "SecurityValidationTest": [ "Author presence and user security", "Author presence and user security when replicated", diff --git a/test/elixir/test/validate_doc_update_test.exs b/test/elixir/test/validate_doc_update_test.exs new file mode 100644 index 0000000000..5d15db1016 --- /dev/null +++ b/test/elixir/test/validate_doc_update_test.exs @@ -0,0 +1,79 @@ +defmodule ValidateDocUpdateTest do + use CouchTestCase + + @moduledoc """ + Test validate_doc_update behaviour + """ + + @js_type_check %{ + language: "javascript", + + validate_doc_update: ~s""" + function (newDoc) { + if (!newDoc.type) { + throw {forbidden: 'Documents must have a type field'}; + } + } + """ + } + + @tag :with_db + test "JavaScript VDU accepts a valid document", context do + db = context[:db_name] + Couch.put("/#{db}/_design/js-test", body: @js_type_check) + + resp = Couch.put("/#{db}/doc", body: %{"type" => "movie"}) + assert resp.status_code == 201 + assert resp.body["ok"] == true + end + + @tag :with_db + test "JavaScript VDU rejects an invalid document", context do + db = context[:db_name] + Couch.put("/#{db}/_design/js-test", body: @js_type_check) + + resp = Couch.put("/#{db}/doc", body: %{"not" => "valid"}) + assert resp.status_code == 403 + assert resp.body["error"] == "forbidden" + end + + @js_change_check %{ + language: "javascript", + + validate_doc_update: ~s""" + function (newDoc, oldDoc) { + if (oldDoc && newDoc.type !== oldDoc.type) { + throw {forbidden: 'Documents cannot change their type field'}; + } + } + """ + } + + @tag :with_db + test "JavaScript VDU accepts a valid change", context do + db = context[:db_name] + Couch.put("/#{db}/_design/js-test", body: @js_change_check) + + Couch.put("/#{db}/doc", body: %{"type" => "movie"}) + + doc = Couch.get("/#{db}/doc").body + updated = doc |> Map.merge(%{"type" => "movie", "title" => "Duck Soup"}) + resp = Couch.put("/#{db}/doc", body: updated) + + assert resp.status_code == 201 + end + + @tag :with_db + test "JavaScript VDU rejects an invalid change", context do + db = context[:db_name] + Couch.put("/#{db}/_design/js-test", body: @js_change_check) + + Couch.put("/#{db}/doc", body: %{"type" => "movie"}) + + doc = Couch.get("/#{db}/doc").body + updated = doc |> Map.put("type", "director") + resp = Couch.put("/#{db}/doc", body: updated) + + assert resp.status_code == 403 + end +end From 789802386f9ceda482574652150edd35da8cf624 Mon Sep 17 00:00:00 2001 From: James Coglan Date: Fri, 9 Jan 2026 14:05:43 +0000 Subject: [PATCH 3/3] feat: Add the ability for VDUs to be written as Mango selectors --- src/couch_mrview/src/couch_mrview.erl | 2 +- src/mango/src/mango_native_proc.erl | 27 ++++ test/elixir/test/config/suite.elixir | 6 + test/elixir/test/validate_doc_update_test.exs | 133 ++++++++++++++++++ 4 files changed, 167 insertions(+), 1 deletion(-) diff --git a/src/couch_mrview/src/couch_mrview.erl b/src/couch_mrview/src/couch_mrview.erl index bc7b1f8abf..244f668af0 100644 --- a/src/couch_mrview/src/couch_mrview.erl +++ b/src/couch_mrview/src/couch_mrview.erl @@ -62,7 +62,7 @@ validate_ddoc_fields(DDoc) -> [{<<"rewrites">>, [string, array]}], [{<<"shows">>, object}, {any, [object, string]}], [{<<"updates">>, object}, {any, [object, string]}], - [{<<"validate_doc_update">>, string}], + [{<<"validate_doc_update">>, [string, object]}], [{<<"views">>, object}, {<<"lib">>, object}], [{<<"views">>, object}, {any, object}, {<<"map">>, MapFuncType}], [{<<"views">>, object}, {any, object}, {<<"reduce">>, string}] diff --git a/src/mango/src/mango_native_proc.erl b/src/mango/src/mango_native_proc.erl index 511a987199..edcecd4b6f 100644 --- a/src/mango/src/mango_native_proc.erl +++ b/src/mango/src/mango_native_proc.erl @@ -29,6 +29,7 @@ -record(st, { indexes = [], + validators = [], timeout = 5000 }). @@ -94,6 +95,32 @@ handle_call({prompt, [<<"nouveau_index_doc">>, Doc]}, _From, St) -> Else end, {reply, Vals, St}; +handle_call({prompt, [<<"ddoc">>, <<"new">>, DDocId, {DDoc}]}, _From, St) -> + NewSt = + case couch_util:get_value(<<"validate_doc_update">>, DDoc) of + undefined -> + St; + Selector0 -> + Selector = mango_selector:normalize(Selector0), + Validators = couch_util:set_value(DDocId, St#st.validators, Selector), + St#st{validators = Validators} + end, + {reply, true, NewSt}; +handle_call({prompt, [<<"ddoc">>, DDocId, [<<"validate_doc_update">>], Args]}, _From, St) -> + case couch_util:get_value(DDocId, St#st.validators) of + undefined -> + Msg = [<<"validate_doc_update">>, DDocId], + {stop, {invalid_call, Msg}, {invalid_call, Msg}, St}; + Selector -> + [NewDoc, OldDoc, _Ctx, _SecObj] = Args, + Struct = {[{<<"newDoc">>, NewDoc}, {<<"oldDoc">>, OldDoc}]}, + Reply = + case mango_selector:match(Selector, Struct) of + true -> true; + _ -> {[{<<"forbidden">>, <<"document is not valid">>}]} + end, + {reply, Reply, St} + end; handle_call(Msg, _From, St) -> {stop, {invalid_call, Msg}, {invalid_call, Msg}, St}. diff --git a/test/elixir/test/config/suite.elixir b/test/elixir/test/config/suite.elixir index 00dfb5c795..44cfe3f3bf 100644 --- a/test/elixir/test/config/suite.elixir +++ b/test/elixir/test/config/suite.elixir @@ -522,6 +522,12 @@ "JavaScript VDU rejects an invalid document", "JavaScript VDU accepts a valid change", "JavaScript VDU rejects an invalid change", + "Mango VDU accepts a valid document", + "Mango VDU rejects an invalid document", + "updating a Mango VDU updates its effects", + "converting a Mango VDU to JavaScript updates its effects", + "deleting a Mango VDU removes its effects", + "Mango VDU rejects a doc if any existing ddoc fails to match", ], "SecurityValidationTest": [ "Author presence and user security", diff --git a/test/elixir/test/validate_doc_update_test.exs b/test/elixir/test/validate_doc_update_test.exs index 5d15db1016..93ed8f177c 100644 --- a/test/elixir/test/validate_doc_update_test.exs +++ b/test/elixir/test/validate_doc_update_test.exs @@ -76,4 +76,137 @@ defmodule ValidateDocUpdateTest do assert resp.status_code == 403 end + + @mango_type_check %{ + language: "query", + + validate_doc_update: %{ + "newDoc" => %{"type" => %{"$exists" => true}} + } + } + + @tag :with_db + test "Mango VDU accepts a valid document", context do + db = context[:db_name] + resp = Couch.put("/#{db}/_design/mango-test", body: @mango_type_check) + assert resp.status_code == 201 + + resp = Couch.put("/#{db}/doc", body: %{"type" => "movie"}) + assert resp.status_code == 201 + assert resp.body["ok"] == true + end + + @tag :with_db + test "Mango VDU rejects an invalid document", context do + db = context[:db_name] + resp = Couch.put("/#{db}/_design/mango-test", body: @mango_type_check) + assert resp.status_code == 201 + + resp = Couch.put("/#{db}/doc", body: %{"no" => "type"}) + assert resp.status_code == 403 + assert resp.body["error"] == "forbidden" + end + + @tag :with_db + test "updating a Mango VDU updates its effects", context do + db = context[:db_name] + + resp = Couch.put("/#{db}/_design/mango-test", body: @mango_type_check) + assert resp.status_code == 201 + + ddoc = %{ + language: "query", + + validate_doc_update: %{ + "newDoc" => %{ + "type" => %{"$type" => "string"}, + "year" => %{"$lt" => 2026} + } + } + } + resp = Couch.put("/#{db}/_design/mango-test", body: ddoc, query: %{rev: resp.body["rev"]}) + assert resp.status_code == 201 + + resp = Couch.put("/#{db}/doc1", body: %{"type" => "movie", "year" => 1994}) + assert resp.status_code == 201 + + resp = Couch.put("/#{db}/doc2", body: %{"type" => 42, "year" => 1994}) + assert resp.status_code == 403 + assert resp.body["error"] == "forbidden" + + resp = Couch.put("/#{db}/doc3", body: %{"type" => "movie", "year" => 2094}) + assert resp.status_code == 403 + assert resp.body["error"] == "forbidden" + end + + @tag :with_db + test "converting a Mango VDU to JavaScript updates its effects", context do + db = context[:db_name] + + resp = Couch.put("/#{db}/_design/mango-test", body: @mango_type_check) + assert resp.status_code == 201 + + ddoc = %{ + language: "javascript", + + validate_doc_update: ~s""" + function (newDoc) { + if (typeof newDoc.year !== 'number') { + throw {forbidden: 'Documents must have a valid year field'}; + } + } + """ + } + resp = Couch.put("/#{db}/_design/mango-test", body: ddoc, query: %{rev: resp.body["rev"]}) + assert resp.status_code == 201 + + resp = Couch.put("/#{db}/doc1", body: %{"year" => 1994}) + assert resp.status_code == 201 + + resp = Couch.put("/#{db}/doc2", body: %{"year" => "1994"}) + assert resp.status_code == 403 + assert resp.body["error"] == "forbidden" + end + + @tag :with_db + test "deleting a Mango VDU removes its effects", context do + db = context[:db_name] + + resp = Couch.put("/#{db}/_design/mango-test", body: @mango_type_check) + assert resp.status_code == 201 + + resp = Couch.delete("/#{db}/_design/mango-test", query: %{rev: resp.body["rev"]}) + assert resp.status_code == 200 + + resp = Couch.put("/#{db}/doc", body: %{"no" => "type"}) + assert resp.status_code == 201 + end + + @tag :with_db + test "Mango VDU rejects a doc if any existing ddoc fails to match", context do + db = context[:db_name] + resp = Couch.put("/#{db}/_design/mango-test", body: @mango_type_check) + assert resp.status_code == 201 + + ddoc = %{ + language: "query", + + validate_doc_update: %{ + "newDoc" => %{"year" => %{"$lt" => 2026}} + } + } + resp = Couch.put("/#{db}/_design/mango-test-2", body: ddoc) + assert resp.status_code == 201 + + resp = Couch.put("/#{db}/doc1", body: %{"type" => "movie", "year" => 1994}) + assert resp.status_code == 201 + + resp = Couch.put("/#{db}/doc2", body: %{"year" => 1994}) + assert resp.status_code == 403 + assert resp.body["error"] == "forbidden" + + resp = Couch.put("/#{db}/doc3", body: %{"type" => "movie", "year" => 2094}) + assert resp.status_code == 403 + assert resp.body["error"] == "forbidden" + end end