From dbfd8f313f5e8b7db09761651829357297e58e08 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Wed, 10 Jun 2026 09:09:24 +0200 Subject: [PATCH 1/3] close e2e gaps; require OTP 27+ - CI: integration job now gates the build (was continue-on-error) - add e2e coverage for the multipart open_doc/stream_doc path (the most reworked hackney 4.x code, previously untested end-to-end) - drop the OTP-version crypto shim: require OTP 27+ and always use crypto:mac/4 (removes the platform_define that broke on OTP 30) - CHANGELOG: note OTP 27+ requirement and e2e testing --- .github/workflows/test.yml | 4 +- CHANGELOG.md | 11 +++++- rebar.config | 3 +- src/couchbeam_util.erl | 6 +-- test/couchbeam_integration_SUITE.erl | 58 +++++++++++++++++++++++++++- 5 files changed, 68 insertions(+), 14 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 23b66ac..ef485d4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -120,12 +120,10 @@ jobs: rebar3 xref rebar3 as test eunit - # Optional integration tests with real CouchDB + # End-to-end tests against a real CouchDB integration: name: Integration Tests (CouchDB) runs-on: ubuntu-22.04 - # This job is optional - don't block CI if it fails - continue-on-error: true services: couchdb: diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b471f9..e3c00c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,15 @@ All notable changes to this project will be documented in this file. ### Compatibility -- Supports OTP 27, 28 and 29. -- CI now also runs on OTP 29. +- Requires OTP 27+. Dropped the OTP-version crypto shim and now always use + `crypto:mac/4`. +- CI runs on OTP 27, 28 and 29. + +### Testing + +- End-to-end tests run the full client against a real CouchDB. `make e2e` + starts CouchDB in Docker, runs the suite, and tears it down. CI runs all + suite groups and they now gate the build. ### Dependencies diff --git a/rebar.config b/rebar.config index a694c5c..731f7c8 100644 --- a/rebar.config +++ b/rebar.config @@ -1,8 +1,7 @@ %%-*- mode: erlang -*- -{erl_opts, [debug_info, - {platform_define, "^(2[3-9])", 'USE_CRYPTO_MAC'}]}. +{erl_opts, [debug_info]}. %% oauth is an optional dependency - ignore xref warnings for it {xref_ignores, [{oauth, header_params_encode, 1}, {oauth, sign, 6}]}. diff --git a/src/couchbeam_util.erl b/src/couchbeam_util.erl index 7fd4642..a623a17 100644 --- a/src/couchbeam_util.erl +++ b/src/couchbeam_util.erl @@ -285,10 +285,6 @@ hgv(N,L) -> proxy_token(Secret,UserName) -> hackney_bstr:to_hex(hmac(sha, Secret, UserName)). --ifdef(USE_CRYPTO_MAC). +%% couchbeam requires OTP 27+, where crypto:mac/4 is always available. hmac(Alg, Key, Data) -> crypto:mac(hmac, Alg, Key, Data). --else. -hmac(Alg, Key, Data) -> - crypto:hmac(Alg, Key, Data). --endif. diff --git a/test/couchbeam_integration_SUITE.erl b/test/couchbeam_integration_SUITE.erl index f110e4c..2a11ab3 100644 --- a/test/couchbeam_integration_SUITE.erl +++ b/test/couchbeam_integration_SUITE.erl @@ -46,7 +46,8 @@ -export([doc_with_attachment/1, multiple_attachments/1, large_attachment/1, - attachment_streaming/1]). + attachment_streaming/1, + doc_multipart_stream/1]). %% Test cases - Views -export([all_docs/1, @@ -162,7 +163,8 @@ groups() -> doc_with_attachment, multiple_attachments, large_attachment, - attachment_streaming + attachment_streaming, + doc_multipart_stream ]}, {view_ops, [sequence], [ all_docs, @@ -807,6 +809,58 @@ collect_stream(Ref, Acc) -> ct:fail("Stream error: ~p", [Reason]) end. +%% Test reading a document and its attachments as a multipart stream. +doc_multipart_stream(Config) -> + Db = ?config(db, Config), + + %% Create a document with two attachments + Doc = #{<<"_id">> => <<"mp_stream">>, <<"type">> => <<"test">>}, + {ok, DocSaved} = couchbeam:save_doc(Db, Doc), + Rev0 = maps:get(<<"_rev">>, DocSaved), + %% Use a non-compressible content type so CouchDB returns the bytes + %% verbatim in the multipart response (text/* attachments are gzipped). + {ok, Att1} = couchbeam:put_attachment(Db, <<"mp_stream">>, <<"a.bin">>, + <<"alpha">>, + [{rev, Rev0}, + {content_type, <<"application/octet-stream">>}]), + Rev1 = maps:get(<<"rev">>, Att1), + {ok, _} = couchbeam:put_attachment(Db, <<"mp_stream">>, <<"b.bin">>, + <<"bravo">>, + [{rev, Rev1}, + {content_type, <<"application/octet-stream">>}]), + + %% Open it as a multipart stream (attachment bodies included) + {ok, {multipart, State}} = couchbeam:open_doc(Db, <<"mp_stream">>, + [{"attachments", true}]), + + {DocOut, AttsOut} = collect_multipart(State, undefined, <<>>, #{}), + + <<"mp_stream">> = maps:get(<<"_id">>, DocOut), + true = maps:is_key(<<"_attachments">>, DocOut), + <<"alpha">> = maps:get(<<"a.bin">>, AttsOut), + <<"bravo">> = maps:get(<<"b.bin">>, AttsOut), + + ct:pal("Multipart doc stream test passed"), + ok. + +%% Drive couchbeam:stream_doc/1 to completion, collecting the document and +%% each attachment's bytes. +collect_multipart(State, Doc, CurBuf, Atts) -> + case couchbeam:stream_doc(State) of + {doc, NewDoc, NState} -> + collect_multipart(NState, NewDoc, CurBuf, Atts); + {att, _Name, NState} -> + collect_multipart(NState, Doc, <<>>, Atts); + {att_body, _Name, Chunk, NState} -> + collect_multipart(NState, Doc, <>, Atts); + {att_eof, Name, NState} -> + collect_multipart(NState, Doc, <<>>, Atts#{Name => CurBuf}); + eof -> + {Doc, Atts}; + {error, Reason} -> + ct:fail("Multipart stream error: ~p", [Reason]) + end. + %%==================================================================== %% View tests %%==================================================================== From 6acec1afeefacded5f96e39054443a47b26aa5a7 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Wed, 10 Jun 2026 09:33:40 +0200 Subject: [PATCH 2/3] remove OAuth; test against latest CouchDB 3 and latest - remove OAuth support: the {oauth, ...} connection option, couchbeam_util:oauth_header/3, couchbeam_httpc:maybe_oauth_header/4, and the optional oauth dependency (CouchDB dropped server-side OAuth in 2.x). Basic/proxy/cookie auth are unchanged - docker-compose + Makefile: COUCHDB_VERSION (default 3 = latest 3.x) - CI: integration job runs a matrix of CouchDB '3' and 'latest', so 4.x is picked up automatically once a 4.x image ships - CHANGELOG updated --- .github/workflows/test.yml | 13 ++++++++++--- CHANGELOG.md | 6 ++++++ Makefile | 11 ++++++----- docker-compose.yml | 2 +- rebar.config | 12 +++--------- src/couchbeam.erl | 8 -------- src/couchbeam_httpc.erl | 18 +++--------------- src/couchbeam_util.erl | 38 -------------------------------------- 8 files changed, 29 insertions(+), 79 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ef485d4..22f48d9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -120,14 +120,21 @@ jobs: rebar3 xref rebar3 as test eunit - # End-to-end tests against a real CouchDB + # End-to-end tests against a real CouchDB. + # `3` is the latest 3.x; `latest` tracks the newest release and becomes 4.x + # once a 4.x image is published. integration: - name: Integration Tests (CouchDB) + name: Integration Tests (CouchDB ${{ matrix.couchdb }}) runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + couchdb: ['3', 'latest'] + services: couchdb: - image: couchdb:3.3 + image: couchdb:${{ matrix.couchdb }} env: COUCHDB_USER: admin COUCHDB_PASSWORD: admin diff --git a/CHANGELOG.md b/CHANGELOG.md index e3c00c3..d5a16f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,14 +25,20 @@ All notable changes to this project will be documented in this file. - End-to-end tests run the full client against a real CouchDB. `make e2e` starts CouchDB in Docker, runs the suite, and tears it down. CI runs all suite groups and they now gate the build. +- The e2e matrix runs the latest 3.x (`3`) and the `latest` CouchDB image, so + CI adopts CouchDB 4.x automatically once it is released. ### Dependencies - hackney: 4.2.2 (from 2.0.1) - meck (test): 1.2.0 (from 0.9.2) +- Removed the optional `oauth` dependency. ### Removed +- OAuth support. The `{oauth, ...}` connection option and + `couchbeam_util:oauth_header/3` are gone; CouchDB removed server-side OAuth + in 2.x. Basic auth, proxy auth, and cookie auth are unchanged. - `hackney:skip_body/1` usage (removed in hackney 4.x); bodies are read directly. ## [2.0.0] - 2026-01-21 diff --git a/Makefile b/Makefile index d17130c..16913e7 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,12 @@ # couchbeam developer tasks. -COUCHDB_URL ?= http://127.0.0.1:5984 -COUCHDB_USER ?= admin -COUCHDB_PASS ?= admin -COMPOSE ?= docker compose +COUCHDB_URL ?= http://127.0.0.1:5984 +COUCHDB_USER ?= admin +COUCHDB_PASS ?= admin +COUCHDB_VERSION ?= 3 +COMPOSE ?= docker compose -export COUCHDB_URL COUCHDB_USER COUCHDB_PASS +export COUCHDB_URL COUCHDB_USER COUCHDB_PASS COUCHDB_VERSION .PHONY: all compile eunit xref dialyzer test e2e e2e-up e2e-run e2e-down clean diff --git a/docker-compose.yml b/docker-compose.yml index 4de77a8..c0cd7cd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ # See `make e2e` (or support/run-e2e.sh) for the test runner. services: couchdb: - image: couchdb:3.3 + image: couchdb:${COUCHDB_VERSION:-3} environment: COUCHDB_USER: admin COUCHDB_PASSWORD: admin diff --git a/rebar.config b/rebar.config index 731f7c8..c757ebf 100644 --- a/rebar.config +++ b/rebar.config @@ -3,22 +3,16 @@ {erl_opts, [debug_info]}. -%% oauth is an optional dependency - ignore xref warnings for it -{xref_ignores, [{oauth, header_params_encode, 1}, {oauth, sign, 6}]}. - %% Only check for undefined function calls (not unused exports, which are expected for a library) {xref_checks, [undefined_function_calls, undefined_functions, deprecated_function_calls]}. %% Dialyzer configuration -%% - Include optional apps in PLT to avoid unknown function warnings {dialyzer, [ - {plt_extra_apps, [mimerl, xmerl, oauth]} + {plt_extra_apps, [mimerl, xmerl]} ]}. {deps, [ - {hackney, "4.2.2"}, - %% oauth is optional - only needed if using OAuth authentication - {oauth, "2.1.0"} + {hackney, "4.2.2"} ]}. @@ -57,6 +51,6 @@ {profiles, [{test, [ {cover_enabled, true}, {eunit_opts, [verbose]}, - {deps, [{oauth, "2.1.0"}, {meck, "1.2.0"}]} + {deps, [{meck, "1.2.0"}]} ]} ]}. diff --git a/src/couchbeam.erl b/src/couchbeam.erl index 567df89..16593e7 100644 --- a/src/couchbeam.erl +++ b/src/couchbeam.erl @@ -112,19 +112,11 @@ server_connection(Host, Port) when is_integer(Port) -> %% {proxy_password, string()} | %% {basic_auth, {username(), password()}} | %% {cookie, string()} | -%% {oauth, oauthOptions()} | %% {proxyauth, [proxyauthOpt]} %% %% username() = string() %% password() = string() %% SSLOpt = term() -%% oauthOptions() = [oauth()] -%% oauth() = -%% {consumer_key, string()} | -%% {token, string()} | -%% {token_secret, string()} | -%% {consumer_secret, string()} | -%% {signature_method, string()} %% %% proxyauthOpt = {X-Auth-CouchDB-UserName, username :: string()} | %% {X-Auth-CouchDB-Roles, roles :: string} | list_of_user_roles_separated_by_a_comma diff --git a/src/couchbeam_httpc.erl b/src/couchbeam_httpc.erl index a391e25..e64f9d7 100644 --- a/src/couchbeam_httpc.erl +++ b/src/couchbeam_httpc.erl @@ -13,8 +13,7 @@ db_request/5, db_request/6, json_body/1, db_resp/2, - make_headers/4, - maybe_oauth_header/4]). + make_headers/4]). %% urls utils -export([server_url/1, db_url/1, doc_url/2]). %% atts utils @@ -137,25 +136,14 @@ db_request(Method, Url, Headers, Body, Options, Expect) -> json_body(Body) when is_binary(Body) -> couchbeam_ejson:decode(Body). -make_headers(Method, Url, Headers, Options) -> +make_headers(_Method, _Url, Headers, Options) -> Headers1 = case couchbeam_util:get_value(<<"Accept">>, Headers) of undefined -> [{<<"Accept">>, <<"application/json, */*;q=0.9">>} | Headers]; _ -> Headers end, - {Headers2, Options1} = maybe_oauth_header(Method, Url, Headers1, Options), - maybe_proxyauth_header(Headers2, Options1). - - -maybe_oauth_header(Method, Url, Headers, Options) -> - case couchbeam_util:get_value(oauth, Options) of - undefined -> - {Headers, Options}; - OauthProps -> - Hdr = couchbeam_util:oauth_header(Url, Method, OauthProps), - {[Hdr|Headers], proplists:delete(oauth, Options)} - end. + maybe_proxyauth_header(Headers1, Options). maybe_proxyauth_header(Headers, Options) -> case couchbeam_util:get_value(proxyauth, Options) of diff --git a/src/couchbeam_util.erl b/src/couchbeam_util.erl index a623a17..f96bc05 100644 --- a/src/couchbeam_util.erl +++ b/src/couchbeam_util.erl @@ -14,7 +14,6 @@ -export([to_list/1, to_binary/1, to_integer/1, to_atom/1]). -export([binary_env/2]). -export([encode_query/1, encode_query_value/2]). --export([oauth_header/3]). -export([propmerge/3, propmerge1/2]). -export([get_value/2, get_value/3]). -export([deprecated/3, shutdown_sync/1]). @@ -80,43 +79,6 @@ encode_query_value(K, V) when is_binary(K) -> encode_query_value(binary_to_list(K), V); encode_query_value(_K, V) -> V. -% build oauth header -oauth_header(Url, Action, OauthProps) when is_binary(Url) -> - oauth_header(binary_to_list(Url),Action, OauthProps); -oauth_header(Url, Action, OauthProps) -> - #hackney_url{qs=QS} = hackney_url:parse_url(Url), - QSL = [{binary_to_list(K), binary_to_list(V)} || {K,V} <- - hackney_url:parse_qs(QS)], - - % get oauth paramerers - ConsumerKey = to_list(get_value(consumer_key, OauthProps)), - Token = to_list(get_value(token, OauthProps)), - TokenSecret = to_list(get_value(token_secret, OauthProps)), - ConsumerSecret = to_list(get_value(consumer_secret, OauthProps)), - SignatureMethodStr = to_list(get_value(signature_method, - OauthProps, "HMAC-SHA1")), - - SignatureMethodAtom = case SignatureMethodStr of - "PLAINTEXT" -> - plaintext; - "HMAC-SHA1" -> - hmac_sha1; - "RSA-SHA1" -> - rsa_sha1 - end, - Consumer = {ConsumerKey, ConsumerSecret, SignatureMethodAtom}, - Method = case Action of - delete -> "DELETE"; - get -> "GET"; - post -> "POST"; - put -> "PUT"; - head -> "HEAD" - end, - Params = oauth:sign(Method, Url, QSL, Consumer, Token, TokenSecret) -- QSL, - - Realm = "OAuth " ++ oauth:header_params_encode(Params), - {<<"Authorization">>, list_to_binary(Realm)}. - %% @doc merge 2 proplists. All the Key - Value pairs from both proplists %% are included in the new proplists. If a key occurs in both dictionaries From adf7815f3c89ed2b96c71900107334ff8caf3000 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Wed, 10 Jun 2026 09:37:06 +0200 Subject: [PATCH 3/3] ci: run on every pull request regardless of base branch --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 22f48d9..1f4f17c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,8 +3,8 @@ name: Test on: push: branches: [ master, main ] + # Run on every pull request, whatever its base branch (e.g. stacked PRs). pull_request: - branches: [ master, main ] jobs: linux: