merchant

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

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:
Msrc/backend/taler-merchant-httpd_post-private-categories.c | 13+++++++++++++
Msrc/backend/taler-merchant-httpd_post-private-otp-devices.c | 12++++++++++++
Msrc/backend/taler-merchant-httpd_post-private-webhooks.c | 12++++++++++++
Msrc/backenddb/insert_category.c | 1+
Msrc/backenddb/insert_otp_device.c | 3++-
Msrc/backenddb/insert_webhook.c | 3++-
Msrc/backenddb/test_merchantdb.c | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/include/merchant-database/insert_category.h | 3++-
Msrc/include/merchant-database/insert_otp_device.h | 3++-
Msrc/include/merchant-database/insert_webhook.h | 3++-
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,