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:
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",
+ ¬_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