commit b6c749ca995b21144aa1a7196ac2228d3dc12f36
parent e5b2109c9d3996a849cb464e857b01254d1f7f18
Author: Christian Grothoff <christian@grothoff.org>
Date: Wed, 5 Aug 2026 22:35:35 +0200
duplicate name/id is a 409
Diffstat:
10 files changed, 189 insertions(+), 5 deletions(-)
diff --git a/src/backend/taler-merchant-httpd_post-private-categories.c b/src/backend/taler-merchant-httpd_post-private-categories.c
@@ -150,6 +150,19 @@ TMH_private_post_categories (const struct TMH_RequestHandler *rh,
TALER_MERCHANTDB_rollback (TMH_db);
break;
}
+ if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS == qs)
+ {
+ /* A concurrent request created a category with the same name
+ between our lookup above and this INSERT. */
+ TALER_MERCHANTDB_rollback (TMH_db);
+ GNUNET_JSON_parse_free (spec);
+ json_decref (oempty);
+ return TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_CONFLICT,
+ TALER_EC_MERCHANT_PRIVATE_POST_CATEGORIES_CONFLICT_CATEGORY_EXISTS,
+ category_name);
+ }
if (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == qs)
{
qs = TALER_MERCHANTDB_commit (TMH_db);
diff --git a/src/backend/taler-merchant-httpd_post-private-otp-devices.c b/src/backend/taler-merchant-httpd_post-private-otp-devices.c
@@ -169,6 +169,18 @@ TMH_private_post_otp_devices (const struct TMH_RequestHandler *rh,
TALER_MERCHANTDB_rollback (TMH_db);
break;
}
+ if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS == qs)
+ {
+ /* A concurrent request created an OTP device with the same ID
+ between our lookup above and this INSERT. */
+ TALER_MERCHANTDB_rollback (TMH_db);
+ GNUNET_JSON_parse_free (spec);
+ return TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_CONFLICT,
+ TALER_EC_MERCHANT_PRIVATE_POST_OTP_DEVICES_CONFLICT_OTP_DEVICE_EXISTS,
+ device_id);
+ }
if (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == qs)
{
qs = TALER_MERCHANTDB_commit (TMH_db);
diff --git a/src/backend/taler-merchant-httpd_post-private-webhooks.c b/src/backend/taler-merchant-httpd_post-private-webhooks.c
@@ -184,6 +184,18 @@ TMH_private_post_webhooks (const struct TMH_RequestHandler *rh,
TALER_MERCHANTDB_rollback (TMH_db);
break;
}
+ if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS == qs)
+ {
+ /* A concurrent request created a webhook with the same ID
+ between our lookup above and this INSERT. */
+ TALER_MERCHANTDB_rollback (TMH_db);
+ GNUNET_JSON_parse_free (spec);
+ return TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_CONFLICT,
+ TALER_EC_MERCHANT_PRIVATE_POST_WEBHOOKS_CONFLICT_WEBHOOK_EXISTS,
+ webhook_id);
+ }
if (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == qs)
{
qs = TALER_MERCHANTDB_commit (TMH_db);
diff --git a/src/backenddb/insert_category.c b/src/backenddb/insert_category.c
@@ -52,6 +52,7 @@ TALER_MERCHANTDB_insert_category (
",category_name_i18n"
")"
" VALUES ($1, $2::TEXT::JSONB)"
+ " ON CONFLICT (category_name) DO NOTHING"
" RETURNING category_serial");
return GNUNET_PQ_eval_prepared_singleton_select (pg->conn,
"",
diff --git a/src/backenddb/insert_otp_device.c b/src/backenddb/insert_otp_device.c
@@ -61,7 +61,8 @@ TALER_MERCHANTDB_insert_otp_device (
",otp_algorithm"
",otp_ctr"
")"
- " VALUES ($1, $2, $3, $4, $5)");
+ " VALUES ($1, $2, $3, $4, $5)"
+ " ON CONFLICT (otp_id) DO NOTHING");
return GNUNET_PQ_eval_prepared_non_select (pg->conn,
"",
params);
diff --git a/src/backenddb/insert_webhook.c b/src/backenddb/insert_webhook.c
@@ -57,7 +57,8 @@ TALER_MERCHANTDB_insert_webhook (
",header_template"
",body_template"
")"
- " VALUES ($1, $2, $3, $4, $5, $6)");
+ " VALUES ($1, $2, $3, $4, $5, $6)"
+ " ON CONFLICT (webhook_id) DO NOTHING");
return GNUNET_PQ_eval_prepared_non_select (pg->conn,
"",
diff --git a/src/backenddb/test_merchantdb.c b/src/backenddb/test_merchantdb.c
@@ -1325,6 +1325,145 @@ post_test_products (struct TestProducts_Closure *cls)
/**
+ * Tests that inserting a category, an OTP device or a webhook that
+ * already exists is reported as #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS
+ * and, crucially, does *not* abort the enclosing transaction.
+ *
+ * Regression test: these three used to be plain INSERTs without an
+ * ON CONFLICT clause. A duplicate key raises 23505, which GNUnet
+ * maps to SUCCESS_NO_RESULTS (0); the POST handlers have no branch
+ * for 0 and fell through to 'GNUNET_assert (SOFT_ERROR == qs)',
+ * aborting taler-merchant-httpd. On top of that the failed INSERT
+ * left the transaction in the aborted state, so every following
+ * statement failed with 25P02 -- which is what this test checks for,
+ * as the query status alone cannot tell the two variants apart.
+ *
+ * @param instance the instance to run the test against.
+ * @return 0 when successful, 1 otherwise.
+ */
+static int
+test_insert_duplicate_conflicts (const struct InstanceData *instance)
+{
+ json_t *i18n = json_object ();
+ struct TALER_MERCHANTDB_OtpDeviceDetails td = {
+ .otp_description = (char *) "conflict test device",
+ .otp_key = (char *) "my key",
+ .otp_algorithm = 1,
+ .otp_ctr = 42
+ };
+ struct TALER_MERCHANTDB_WebhookDetails wb = {
+ .event_type = (char *) "pay",
+ .url = (char *) "http://example.com/",
+ .http_method = (char *) "POST",
+ .header_template = (char *) "",
+ .body_template = (char *) ""
+ };
+ uint64_t cat;
+ 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,
+ "duplicate-conflict",
+ i18n,
+ &cat)) ||
+ (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_MERCHANTDB_insert_otp_device (pg,
+ instance->instance.id,
+ "duplicate-conflict",
+ &td)) ||
+ (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_MERCHANTDB_insert_webhook (pg,
+ instance->instance.id,
+ "duplicate-conflict",
+ &wb)) )
+ {
+ GNUNET_break (0);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Initial insert for the conflict test failed\n");
+ goto cleanup;
+ }
+ if (GNUNET_OK !=
+ TALER_MERCHANTDB_start (pg,
+ "insert duplicate conflicts"))
+ {
+ GNUNET_break (0);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Failed to start transaction\n");
+ goto cleanup;
+ }
+ /* Each of these is a duplicate: expected to be 'no results', and the
+ transaction must survive, otherwise the next one fails with a hard
+ error (25P02) instead. */
+ if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_MERCHANTDB_insert_category (pg,
+ instance->instance.id,
+ "duplicate-conflict",
+ i18n,
+ &cat))
+ {
+ GNUNET_break (0);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Duplicate category insert not reported as 'no results'\n");
+ goto rollback;
+ }
+ if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_MERCHANTDB_insert_otp_device (pg,
+ instance->instance.id,
+ "duplicate-conflict",
+ &td))
+ {
+ GNUNET_break (0);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Duplicate OTP device insert not reported as 'no results'\n");
+ goto rollback;
+ }
+ if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_MERCHANTDB_insert_webhook (pg,
+ instance->instance.id,
+ "duplicate-conflict",
+ &wb))
+ {
+ GNUNET_break (0);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Duplicate webhook insert not reported as 'no results'\n");
+ goto rollback;
+ }
+ /* ... and the transaction must still be able to do useful work. */
+ if (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_MERCHANTDB_insert_category (pg,
+ instance->instance.id,
+ "duplicate-conflict-other",
+ i18n,
+ &cat))
+ {
+ GNUNET_break (0);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Insert after duplicate conflict failed\n");
+ goto rollback;
+ }
+ if (0 >
+ TALER_MERCHANTDB_commit (pg))
+ {
+ GNUNET_break (0);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Commit after duplicate conflict failed\n");
+ goto cleanup;
+ }
+ ret = 0;
+ goto cleanup;
+rollback:
+ TALER_MERCHANTDB_rollback (pg);
+cleanup:
+ json_decref (i18n);
+ return ret;
+}
+
+
+/**
* Runs the tests for products.
*
* @param cls the container of the test data.
@@ -1349,6 +1488,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 duplicate inserts do not poison the transaction */
+ TEST_RET_ON_FAIL (test_insert_duplicate_conflicts (&cls->instance));
/* Test inserting a product */
TEST_RET_ON_FAIL (test_insert_product (&cls->instance,
&cls->products[0],
diff --git a/src/include/merchant-database/insert_category.h b/src/include/merchant-database/insert_category.h
@@ -35,7 +35,8 @@ struct TALER_MERCHANTDB_PostgresContext;
* @param category_name name of the category
* @param category_name_i18n translations of the category name
* @param[out] category_id set to the category id on success
- * @return database result code
+ * @return database result code, #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS
+ * if a category with the same @a category_name already exists
*/
enum GNUNET_DB_QueryStatus
TALER_MERCHANTDB_insert_category (struct TALER_MERCHANTDB_PostgresContext *pg,
diff --git a/src/include/merchant-database/insert_otp_device.h b/src/include/merchant-database/insert_otp_device.h
@@ -34,7 +34,8 @@ struct TALER_MERCHANTDB_PostgresContext;
* @param instance_id instance to insert OTP device for
* @param otp_id otp identifier of OTP device to insert
* @param td the OTP device details to insert
- * @return database result code
+ * @return database result code, #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS
+ * if an OTP device with the same @a otp_id already exists
*/
enum GNUNET_DB_QueryStatus
TALER_MERCHANTDB_insert_otp_device (struct TALER_MERCHANTDB_PostgresContext *pg,
diff --git a/src/include/merchant-database/insert_webhook.h b/src/include/merchant-database/insert_webhook.h
@@ -34,7 +34,8 @@ struct TALER_MERCHANTDB_PostgresContext;
* @param instance_id instance to insert webhook for
* @param webhook_id webhook identifier of webhook to insert
* @param wb the webhook details to insert
- * @return database result code
+ * @return database result code, #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS
+ * if a webhook with the same @a webhook_id already exists
*/
enum GNUNET_DB_QueryStatus
TALER_MERCHANTDB_insert_webhook (struct TALER_MERCHANTDB_PostgresContext *pg,