merchant

Merchant backend to process payments, run by merchants
Log | Files | Refs | Submodules | README | LICENSE

commit e85767337f0deb7188190ea950a47104a3d2fe87
parent 8782e65d5e53965459cc3d90efc1270f012ca225
Author: Christian Grothoff <christian@grothoff.org>
Date:   Wed,  5 Aug 2026 23:03:39 +0200

fix 404 vs 409 on category rename to an existing category name

Diffstat:
Msrc/backend/taler-merchant-httpd_patch-private-categories-CATEGORY_ID.c | 16+++++++++++++++-
Msrc/backenddb/sql-schema/meson.build | 1+
Msrc/backenddb/test_merchantdb.c | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/backenddb/update_category.c | 33+++++++++++++++++++++++++--------
Asrc/backenddb/update_category.sql | 45+++++++++++++++++++++++++++++++++++++++++++++
Msrc/include/merchant-database/update_category.h | 5++++-
6 files changed, 194 insertions(+), 10 deletions(-)

diff --git a/src/backend/taler-merchant-httpd_patch-private-categories-CATEGORY_ID.c b/src/backend/taler-merchant-httpd_patch-private-categories-CATEGORY_ID.c @@ -50,6 +50,7 @@ TMH_private_patch_categories_ID (const struct TMH_RequestHandler *rh, GNUNET_JSON_spec_end () }; enum GNUNET_DB_QueryStatus qs; + bool conflict = false; GNUNET_assert (NULL != mi); GNUNET_assert (NULL != hc->infix); @@ -87,8 +88,21 @@ TMH_private_patch_categories_ID (const struct TMH_RequestHandler *rh, mi->settings.id, cnum, category_name, - category_name_i18n); + category_name_i18n, + &conflict); json_decref (oempty); + if (conflict) + { + enum MHD_Result ret; + + ret = TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_CONFLICT, + TALER_EC_MERCHANT_PRIVATE_POST_CATEGORIES_CONFLICT_CATEGORY_EXISTS, + category_name); + GNUNET_JSON_parse_free (spec); + return ret; + } { enum MHD_Result ret = MHD_NO; diff --git a/src/backenddb/sql-schema/meson.build b/src/backenddb/sql-schema/meson.build @@ -49,6 +49,7 @@ sql_instance_procedures = [ '../update_product.sql', '../insert_kyc_status.sql', '../insert_kyc_failure.sql', + '../update_category.sql', '../update_product_group.sql', '../update_money_pot.sql', '../update_money_pot_totals.sql', diff --git a/src/backenddb/test_merchantdb.c b/src/backenddb/test_merchantdb.c @@ -43,6 +43,8 @@ #include "merchant-database/update_to_account_inactive.h" #include "merchant-database/do_increase_refund.h" #include "merchant-database/insert_account.h" +#include "merchant-database/insert_category.h" +#include "merchant-database/update_category.h" #include "merchant-database/insert_contract_terms.h" #include "merchant-database/insert_deposit.h" #include "merchant-database/insert_deposit_confirmation.h" @@ -1667,6 +1669,106 @@ cleanup: /** + * Tests that renaming a category onto the name of an existing + * category is reported as a conflict (and not as + * #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS, which the HTTP layer would + * turn into a bogus 404 "category unknown"), and that the failed + * rename does not poison the enclosing transaction. + * + * @param instance the instance to run the test against. + * @return 0 when successful, 1 otherwise. + */ +static int +test_update_category_conflict (const struct InstanceData *instance) +{ + json_t *i18n = json_object (); + uint64_t cat1; + uint64_t cat2; + bool conflict = true; + int ret = 1; + + GNUNET_assert (NULL != i18n); + TEST_SET_INSTANCE (instance->instance.id, + GNUNET_DB_STATUS_SUCCESS_ONE_RESULT); + if ( (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_MERCHANTDB_insert_category (pg, + instance->instance.id, + "category-conflict-a", + i18n, + &cat1)) || + (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_MERCHANTDB_insert_category (pg, + instance->instance.id, + "category-conflict-b", + i18n, + &cat2)) ) + { + GNUNET_break (0); + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Insert category failed\n"); + goto cleanup; + } + /* Renaming 'a' to 'b' must be a conflict, not 'unknown category'. */ + if (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_MERCHANTDB_update_category (pg, + instance->instance.id, + cat1, + "category-conflict-b", + i18n, + &conflict)) + { + GNUNET_break (0); + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Conflicting category rename not reported as conflict\n"); + goto cleanup; + } + if (! conflict) + { + GNUNET_break (0); + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Conflicting category rename did not set 'conflict'\n"); + goto cleanup; + } + /* ... and the connection must still be usable afterwards. */ + conflict = true; + if ( (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + TALER_MERCHANTDB_update_category (pg, + instance->instance.id, + cat1, + "category-conflict-c", + i18n, + &conflict)) || + conflict) + { + GNUNET_break (0); + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Category rename after conflict failed\n"); + goto cleanup; + } + /* Unknown category must still be reported as 'no results'. */ + conflict = true; + if ( (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != + TALER_MERCHANTDB_update_category (pg, + instance->instance.id, + cat1 + cat2 + 424242, + "category-conflict-d", + i18n, + &conflict)) || + conflict) + { + GNUNET_break (0); + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Rename of unknown category not reported as 'no results'\n"); + goto cleanup; + } + ret = 0; +cleanup: + json_decref (i18n); + return ret; +} + + +/** * Runs the tests for products. * * @param cls the container of the test data. @@ -1691,6 +1793,8 @@ run_test_products (struct TestProducts_Closure *cls) /* Insert the instance */ TEST_RET_ON_FAIL (test_insert_instance (&cls->instance, GNUNET_DB_STATUS_SUCCESS_ONE_RESULT)); + /* Test that a conflicting category rename is reported as a conflict */ + TEST_RET_ON_FAIL (test_update_category_conflict (&cls->instance)); /* Test that duplicate inserts do not poison the transaction */ TEST_RET_ON_FAIL (test_insert_duplicate_conflicts (&cls->instance)); /* Test inserting a product */ diff --git a/src/backenddb/update_category.c b/src/backenddb/update_category.c @@ -30,7 +30,8 @@ TALER_MERCHANTDB_update_category ( const char *instance_id, uint64_t category_id, const char *category_name, - const json_t *category_name_i18n) + const json_t *category_name_i18n, + bool *conflict) { struct GNUNET_PQ_QueryParam params[] = { GNUNET_PQ_query_param_uint64 (&category_id), @@ -38,16 +39,32 @@ TALER_MERCHANTDB_update_category ( TALER_PQ_query_param_json (category_name_i18n), GNUNET_PQ_query_param_end }; + bool not_found; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_bool ("conflict", + conflict), + GNUNET_PQ_result_spec_bool ("not_found", + &not_found), + GNUNET_PQ_result_spec_end + }; + enum GNUNET_DB_QueryStatus qs; GNUNET_assert (NULL != pg->current_merchant_id); GNUNET_assert (0 == strcmp (instance_id, pg->current_merchant_id)); TMH_PQ_prepare_anon (pg, - "UPDATE merchant_categories SET" - " category_name=$2" - ",category_name_i18n=$3::TEXT::JSONB" - " WHERE category_serial=$1"); - return GNUNET_PQ_eval_prepared_non_select (pg->conn, - "", - params); + "SELECT" + " out_conflict AS conflict" + ",out_not_found AS not_found" + " FROM merchant_do_update_category" + "($1,$2,$3::TEXT::JSONB);"); + qs = GNUNET_PQ_eval_prepared_singleton_select (pg->conn, + "", + params, + rs); + if (qs <= 0) + return qs; + if (not_found) + return GNUNET_DB_STATUS_SUCCESS_NO_RESULTS; + return GNUNET_DB_STATUS_SUCCESS_ONE_RESULT; } diff --git a/src/backenddb/update_category.sql b/src/backenddb/update_category.sql @@ -0,0 +1,45 @@ +-- +-- This file is part of TALER +-- Copyright (C) 2025 Taler Systems SA +-- +-- TALER is free software; you can redistribute it and/or modify it under the +-- terms of the GNU General Public License as published by the Free Software +-- Foundation; either version 3, or (at your option) any later version. +-- +-- TALER is distributed in the hope that it will be useful, but WITHOUT ANY +-- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +-- A PARTICULAR PURPOSE. See the GNU General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License along with +-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +-- + +DROP FUNCTION IF EXISTS merchant_do_update_category; +CREATE FUNCTION merchant_do_update_category ( + IN in_category_serial INT8, + IN in_category_name TEXT, + IN in_category_name_i18n JSONB, + OUT out_conflict BOOL, + OUT out_not_found BOOL) +LANGUAGE plpgsql +AS $$ +BEGIN + +BEGIN + UPDATE merchant_categories SET + category_name=in_category_name + ,category_name_i18n=in_category_name_i18n + WHERE category_serial=in_category_serial; + out_not_found = NOT FOUND; + out_conflict = FALSE; + RETURN; +EXCEPTION + -- category_name already used by another category + WHEN unique_violation + THEN + out_not_found = FALSE; + out_conflict = TRUE; + RETURN; +END; + +END $$; diff --git a/src/include/merchant-database/update_category.h b/src/include/merchant-database/update_category.h @@ -35,6 +35,8 @@ struct TALER_MERCHANTDB_PostgresContext; * @param category_id category to update * @param category_name name of the category * @param category_name_i18n translations of the category name + * @param[out] conflict set to true if @a category_name is already + * used by a different category * @return database result code, #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS if the template * does not yet exist. */ @@ -43,6 +45,7 @@ TALER_MERCHANTDB_update_category (struct TALER_MERCHANTDB_PostgresContext *pg, const char *instance_id, uint64_t category_id, const char *category_name, - const json_t *category_name_i18n); + const json_t *category_name_i18n, + bool *conflict); #endif