From 346acfb53f6a5a3f5505f6099bc955c82884964d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Tue, 12 Mar 2024 04:38:16 +0000 Subject: [PATCH] Fix lack of idempotency on bump_iap_set_expiry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit fixes an important bug in the logic of bump_iap_set_expiry. I discovered that it lacks idempotency when sending the same IAP (In-app purchase) receipt more than once. It would cause the end user to end up with a higher expiry date than entitled to them. Since there was no way to create a logic that solves this by only using a simple expiry date number, the system was changed to keep track of transactions, and then calculate the expiry date on the fly. Furthermore, these changes improve the overall system as mistakes such as this one become more easily fixable, as the transaction data is a much better source of truth from which we can calculate expiry. Multiple changes were made to support this logical and structural change: 1. A new set of functions were introduced to perform the calculation in O(N) time 2. New tests and test cases were introduced to cover both logic and the original bug 3. bump_expiry and bump_iap_set_expiry were replaced by a single function that appends a transaction to the user's transaction list 4. A new standard transaction data type was introduced 5. Low-level functions that fetch and write user data on the database were changed to calculate or handle expiry dates on the fly to reduce other API changes to a minimum 6. IAP and LN successful purchase logics were modified to generate transaction data for the transaction history. 7. Logic was added to handle the old legacy expiry date data as a "legacy" transaction. Testing 1 ---------- PASS Made sure all automated tests are passing (They are quite extensive and realistic at this point) Testing 2 ---------- PASS Devices: iPhone 13 Mini (physical device), iPhone 15 simulator iOS: 17.3.1 and 17.2 damus-api: This commit Damus: Tip of master (5e530bfc9c584dcb93729b6a21863c97125ffb21, very close to 1.8(1)) Damus-website: 927bf8bacae71d59ab0c5f2386e1c50b8e172790 (Equivalent to the one released with 1.7.2) Setup: Local server setup, App Store sandbox environment. Coverage: 1. Loaded a copy of the production DB on the local test environment, and verified: 1. A lot of the users I knew had Damus Purple membership still had their star next to them after loading the new version. 2. My own account was displaying correct information 3. I still saw reminder notifications to renew as expected 4. Going through LN checkout worked and lead to the correct expiry date to be shown 5. Going through the LN checkout a second time worked and lead to the expected extension of expiry 6. Saw no crashes on the server 7. Saw no lags on the server 2. Loaded an older test database I used a while ago with an older version 1. My own account was displaying correct information 2. Tried renewing with an IAP purchase via Sandbox. It worked as expected 3. Expiry date is as expected Changelog-Fixed: Fixed IAP receipt verification idempotency problems Changelog-Changed: Changed structure of expiry to be a function of transaction data Signed-off-by: Daniel D’Aquino Signed-off-by: William Casarin --- src/app_store_receipt_verifier.js | 56 ++- src/invoicing.js | 17 +- src/router_config.js | 18 +- src/transaction_management.js | 123 +++++++ src/user_management.js | 102 +++--- test/controllers/mock_iap_controller.js | 3 + test/iap_flow.test.js | 67 +++- test/mixed_iap_ln_flow.test.js | 5 +- test/transaction_management.test.js | 437 ++++++++++++++++++++++++ 9 files changed, 736 insertions(+), 92 deletions(-) create mode 100644 src/transaction_management.js create mode 100644 test/transaction_management.test.js diff --git a/src/app_store_receipt_verifier.js b/src/app_store_receipt_verifier.js index 11468d2..fddcbfc 100644 --- a/src/app_store_receipt_verifier.js +++ b/src/app_store_receipt_verifier.js @@ -3,6 +3,9 @@ /** * @typedef {import('@apple/app-store-server-library').JWSTransactionDecodedPayload} JWSTransactionDecodedPayload */ +/** + * @typedef {import('./transaction_management.js').Transaction} Transaction + */ const { AppStoreServerAPIClient, Environment, ReceiptUtility, Order, ProductType, SignedDataVerifier } = require("@apple/app-store-server-library") const { current_time } = require("./utils") @@ -14,15 +17,22 @@ const debug = require('debug')('api:iap') * * @param {string} receipt_data - The receipt data to verify in base64 format. * @param {string} authenticated_account_token - The UUID account token of the user who is authenticated in this request. - * - * @returns {Promise} The expiry date of the receipt if valid, null otherwise. + * + * @returns {Promise} The validated transactions */ async function verify_receipt(receipt_data, authenticated_account_token) { debug("Verifying receipt with authenticated account token: %s", authenticated_account_token); // Mocking logic for testing purposes if (process.env.MOCK_VERIFY_RECEIPT == "true") { debug("Mocking verify_receipt with expiry date 30 days from now"); - return current_time() + 60 * 60 * 24 * 30; + return [{ + type: "iap", + id: "1", + start_date: current_time(), + end_date: current_time() + 60 * 60 * 24 * 30, + purchased_date: current_time(), + duration: null + }] } // Setup the environment and client @@ -37,25 +47,32 @@ async function verify_receipt(receipt_data, authenticated_account_token) { // If the transaction ID is present, fetch the transaction history, verify the transactions, and return the latest expiry date if (transactionId != null) { - return await fetchLastVerifiedExpiryDate(client, transactionId, rootCaDir, environment, bundleId, authenticated_account_token); + return await fetchValidatedTransactions(client, transactionId, rootCaDir, environment, bundleId, authenticated_account_token); } return Promise.resolve(null); } /** - * Verifies the transaction id and returns the expiry date if the transaction is valid. + * Verifies the transaction id and returns the verified transaction history if valid * * @param {number} transaction_id - The transaction id to verify. * @param {string} authenticated_account_token - The UUID account token of the user who is authenticated in this request. * - * @returns {Promise} The expiry date of the receipt if valid, null otherwise. + * @returns {Promise} The validated transactions */ async function verify_transaction_id(transaction_id, authenticated_account_token) { debug("Verifying transaction id '%d' with authenticated account token: %s", transaction_id, authenticated_account_token); // Mocking logic for testing purposes if (process.env.MOCK_VERIFY_RECEIPT == "true") { debug("Mocking verify_receipt with expiry date 30 days from now"); - return current_time() + 60 * 60 * 24 * 30; + return [{ + type: "iap", + id: "1", + start_date: current_time(), + end_date: current_time() + 60 * 60 * 24 * 30, + purchased_date: current_time(), + duration: null + }]; } // Setup the environment and client @@ -66,14 +83,14 @@ async function verify_transaction_id(transaction_id, authenticated_account_token // If the transaction ID is present, fetch the transaction history, verify the transactions, and return the latest expiry date if (transaction_id != null) { - return await fetchLastVerifiedExpiryDate(client, transaction_id, rootCaDir, environment, bundleId, authenticated_account_token); + return await fetchValidatedTransactions(client, transaction_id, rootCaDir, environment, bundleId, authenticated_account_token); } return Promise.resolve(null); } /** - * Fetches transaction history with the App Store API, verifies the transactions, and returns the last valid expiry date. + * Fetches transaction history with the App Store API, verifies the transactions, and returns formatted transactions. * It also verifies if the transaction belongs to the account who made the request. * * @param {AppStoreServerAPIClient} client - The App Store API client. @@ -82,10 +99,10 @@ async function verify_transaction_id(transaction_id, authenticated_account_token * @param {Environment} environment - The App Store environment. * @param {string} bundleId - The bundle ID of the app. * @param {string} authenticatedAccountToken - The UUID account token of the user who is authenticated in this request. - - * @returns {Promise} The expiry date (As Unix timestamp measured in seconds) of the receipt if valid, null otherwise. + + * @returns {Promise} The validated transactions */ -async function fetchLastVerifiedExpiryDate(client, transactionId, rootCaDir, environment, bundleId, authenticatedAccountToken) { +async function fetchValidatedTransactions(client, transactionId, rootCaDir, environment, bundleId, authenticatedAccountToken) { const transactions = await fetchTransactionHistory(client, transactionId); debug("[Account token: %s] Fetched transaction history for transaction ID: %s; Found %d transactions", authenticatedAccountToken, transactionId, transactions.length); const rootCAs = readCertificateFiles(rootCaDir); @@ -96,11 +113,16 @@ async function fetchLastVerifiedExpiryDate(client, transactionId, rootCaDir, env if (validDecodedTransactions.length === 0) { return null; } - const expiryDates = decodedTransactions.map((decodedTransaction) => decodedTransaction.expiresDate); - debug("[Account token: %s] Found expiry dates: %o", authenticatedAccountToken, expiryDates); - const latestExpiryDate = Math.max(...expiryDates); - debug("[Account token: %s] Latest expiry date: %d", authenticatedAccountToken, latestExpiryDate); - return latestExpiryDate / 1000; // Return the latest expiry date in seconds + return validDecodedTransactions.map((decodedTransaction) => { + return { + type: "iap", + id: decodedTransaction.transactionId, + start_date: decodedTransaction.purchaseDate / 1000, + end_date: decodedTransaction.expiresDate / 1000, + purchased_date: decodedTransaction.purchaseDate / 1000, + duration: null + } + }) } /** diff --git a/src/invoicing.js b/src/invoicing.js index 5f5239e..e1edd8b 100644 --- a/src/invoicing.js +++ b/src/invoicing.js @@ -1,7 +1,8 @@ const LNSocket = require('lnsocket') -const { bump_expiry } = require('./user_management') +const { add_successful_transactions_to_account } = require('./user_management') const { nip19 } = require('nostr-tools') const { v4: uuidv4 } = require('uuid') +const { current_time } = require('./utils') const PURPLE_ONE_MONTH = "purple_one_month" const PURPLE_ONE_YEAR = "purple_one_year" @@ -141,7 +142,7 @@ class PurpleInvoiceManager { if (checkout_object?.invoice) { checkout_object.invoice.paid = await this.check_invoice_is_paid(checkout_object.invoice.label) if (checkout_object.invoice.paid) { - this.handle_successful_payment(checkout_object.invoice.bolt11) + this.handle_successful_payment(checkout_object) checkout_object.completed = true await this.checkout_sessions_db.put(checkout_id, checkout_object) // Update the checkout object since the state has changed } @@ -174,7 +175,8 @@ class PurpleInvoiceManager { } // This is called when an invoice is successfully paid - async handle_successful_payment(bolt11) { + async handle_successful_payment(checkout_object) { + const bolt11 = checkout_object.invoice.bolt11; const invoice_request_info = this.invoices_db.get(bolt11) if (!invoice_request_info) { throw new Error("Invalid bolt11 or not found") @@ -183,7 +185,14 @@ class PurpleInvoiceManager { this.invoices_db.put(bolt11, invoice_request_info) const npub = invoice_request_info.npub const pubkey = nip19.decode(npub).data.toString('hex') - const result = bump_expiry(this.api, pubkey, this.invoice_templates[invoice_request_info.template_name].expiry) + const result = add_successful_transactions_to_account(this.api, pubkey, [{ + type: "ln", + id: checkout_object.id, + start_date: null, + end_date: null, + purchased_date: current_time(), + duration: this.invoice_templates[invoice_request_info.template_name].expiry + }]); if (!result.account) { throw new Error("Could not bump expiry") } diff --git a/src/router_config.js b/src/router_config.js index 5a60a3d..d4f8850 100644 --- a/src/router_config.js +++ b/src/router_config.js @@ -1,5 +1,5 @@ const { json_response, simple_response, error_response, invalid_request, unauthorized_response } = require('./server_helpers') -const { create_account, get_account_info_payload, check_account, get_account, put_account, get_account_and_user_id, get_user_uuid, bump_iap_set_expiry, delete_account } = require('./user_management') +const { create_account, get_account_info_payload, check_account, get_account, put_account, get_account_and_user_id, get_user_uuid, delete_account, add_successful_transactions_to_account } = require('./user_management') const handle_translate = require('./translate') const { verify_receipt, verify_transaction_id } = require('./app_store_receipt_verifier'); const bodyParser = require('body-parser') @@ -110,14 +110,14 @@ function config_router(app) { unauthorized_response(res, 'The account UUID is not valid for this account. Expected: "' + account_uuid + '", got: "' + alleged_account_uuid + '"') return } - - let expiry_date = await verify_receipt(receipt_base64, account_uuid) - if (!expiry_date) { + + let verified_transaction_history = await verify_receipt(receipt_base64, account_uuid) + if (!verified_transaction_history) { unauthorized_response(res, 'Receipt invalid') return } - - const { account: new_account, request_error } = bump_iap_set_expiry(app, req.authorized_pubkey, expiry_date) + + const { account: new_account, request_error } = add_successful_transactions_to_account(app, req.authorized_pubkey, verified_transaction_history) if (request_error) { error_response(res, request_error) return @@ -169,13 +169,13 @@ function config_router(app) { return } - let expiry_date = await verify_transaction_id(transaction_id, account_uuid) - if (!expiry_date) { + let verified_transaction_history = await verify_transaction_id(transaction_id, account_uuid) + if (!verified_transaction_history) { unauthorized_response(res, 'Transaction ID invalid') return } - const { account: new_account, request_error } = bump_iap_set_expiry(app, req.authorized_pubkey, expiry_date) + const { account: new_account, request_error } = add_successful_transactions_to_account(app, req.authorized_pubkey, verified_transaction_history) if (request_error) { error_response(res, request_error) return diff --git a/src/transaction_management.js b/src/transaction_management.js new file mode 100644 index 0000000..ae28a79 --- /dev/null +++ b/src/transaction_management.js @@ -0,0 +1,123 @@ +"use strict"; +// @ts-check + + +/** @typedef {Object} Transaction +* @property {"iap" | "ln" | "legacy"} type - The type of transaction +* @property {string} id - The id of the transaction (Apple IAP transaction ID for iap, Checkout ID for ln, `0` for legacy) +* @property {number | null} start_date - The start date of the transaction if transaction has a fixed start and end (IAP and legacy), null otherwise (Unix timestamp in seconds) +* @property {number | null} end_date - The end date of the transaction if transaction has a fixed start and end (IAP and legacy), null otherwise (Unix timestamp in seconds) +* @property {number | null} purchased_date - The date of the transaction (Applies to LN and IAP only, Unix timestamp in seconds) +* @property {number | null} duration - The duration of the transaction (Applies to LN only, measured in seconds) +*/ + + +/** Calculates the expiry date given a transaction history +* @param {Transaction[]} transaction_history - The transaction history +* @returns {number | null} - The expiry date of the transaction history (Unix timestamp in seconds), null if no expiry date can be calculated +*/ +function calculate_expiry_date_from_history(transaction_history) { + // make a deep copy of the transaction history so that we don't modify the original objects + const transaction_history_copy = deep_copy_unique_transaction_history(transaction_history); + + // sort the transaction history by earliest date + var remaining_transactions = transaction_history_copy.sort((a, b) => get_earliest_date_from_transaction(a) - get_earliest_date_from_transaction(b)); + var time_cursor = null; + var flexible_credits_remaining = 0; + + for (var i = 0; i < remaining_transactions.length; i++) { + var transaction = remaining_transactions[i]; + + // Move time cursor and count flexible credits available. + if (is_transaction_fixed_schedule(transaction)) { + time_cursor = Math.max(time_cursor, transaction.end_date); + } + else if (is_transaction_flexible_schedule(transaction)) { + flexible_credits_remaining += transaction.duration; + time_cursor = Math.max(time_cursor, transaction.purchased_date); + } + + // Check if there is a gap between the time cursor and the next transaction, then consume flexible credits. + if (i < remaining_transactions.length - 1) { + var next_transaction = remaining_transactions[i + 1]; + let earliest_date_from_next_transaction = get_earliest_date_from_transaction(next_transaction); + if (time_cursor < earliest_date_from_next_transaction && flexible_credits_remaining > 0) { + flexible_credits_remaining -= Math.min(earliest_date_from_next_transaction - time_cursor, flexible_credits_remaining); + time_cursor = earliest_date_from_next_transaction; + } + } + else { + // This is the last transaction. Consume all remaining flexible credits. + time_cursor += flexible_credits_remaining; + } + } + + return time_cursor; +} + + +/** Checks if a transaction is fixed schedule + * @param {Transaction} transaction - The transaction to be checked + */ +function is_transaction_fixed_schedule(transaction) { + return transaction.start_date !== null && transaction.start_date !== undefined && transaction.end_date !== null && transaction.end_date !== undefined; +} + + +/** Checks if a transaction is flexible schedule +* @param {Transaction} transaction - The transaction to be checked +*/ +function is_transaction_flexible_schedule(transaction) { + return transaction.purchased_date !== null && transaction.purchased_date !== undefined && transaction.duration !== null && transaction.duration !== undefined && !is_transaction_fixed_schedule(transaction); +} + + +/** Returns the earliest date from a transaction +* @param {Transaction} transaction - The transaction +* @returns {number | null} - The earliest date from the transaction (Unix timestamp in seconds), null if the transaction is null +*/ +function get_earliest_date_from_transaction(transaction) { + if (is_transaction_fixed_schedule(transaction)) { + return transaction.start_date; + } + else if (is_transaction_flexible_schedule(transaction)) { + return transaction.purchased_date; + } + else { + return null; + } +} + + +/** Deep copies a transaction history. More efficient than a JSON parsing roundtrip + * @param {Transaction[]} transaction_history - The transaction history + * @returns {Transaction[]} - The deep copied transaction history + */ +function deep_copy_unique_transaction_history(transaction_history) { + // @type {Transaction[]} + var new_transaction_history = []; + // @type {Set} + var unique_transaction_ids = new Set(); + for (var i = 0; i < transaction_history.length; i++) { + const unique_id = transaction_history[i].type + "_" + transaction_history[i].id; + if (unique_transaction_ids.has(unique_id)) { + continue; + } + unique_transaction_ids.add(unique_id); + new_transaction_history.push({ + type: transaction_history[i].type, + id: transaction_history[i].id, + start_date: transaction_history[i].start_date, + end_date: transaction_history[i].end_date, + purchased_date: transaction_history[i].purchased_date, + duration: transaction_history[i].duration + }); + } + + return new_transaction_history; +} + + +module.exports = { + calculate_expiry_date_from_history, deep_copy_unique_transaction_history +} diff --git a/src/user_management.js b/src/user_management.js index cf78a95..26b16ab 100644 --- a/src/user_management.js +++ b/src/user_management.js @@ -1,5 +1,11 @@ +// @ts-check +const { deep_copy_unique_transaction_history, calculate_expiry_date_from_history } = require('./transaction_management') const { current_time } = require('./utils') const { v4: uuidv4 } = require('uuid') +/** + * @typedef {import('./transaction_management.js').Transaction} Transaction + */ + // Helper function to get a user id from a pubkey function get_user_id_from_pubkey(api, pubkey) { @@ -11,10 +17,38 @@ function get_account_and_user_id(api, pubkey) { const user_id = get_user_id_from_pubkey(api, pubkey) if (!user_id) return { account: null, user_id: null } - const account = api.dbs.accounts.get(user_id) + const account = get_account_by_user_id(api, user_id) return { account: account, user_id: user_id } } +// A lower level function that fetches an account from the database by user id and transforms the data that is more usable for the rest of the code +function get_account_by_user_id(api, user_id) { + const raw_account_data = api.dbs.accounts.get(user_id) + if (!raw_account_data) + return null + + // For backwards compatibility, if the account has an expiry date, we add a legacy transaction to the transaction history + // The expiry date now is calculated on the fly from the transaction history + + // @type {Transaction[]} + var transactions = raw_account_data?.transactions || [] + if (raw_account_data?.expiry) { + transactions = [{ + type: "legacy", + id: "0", + start_date: current_time(), + end_date: raw_account_data?.expiry, + purchased_date: current_time(), + duration: null + }, ...transactions] + } + var account = raw_account_data; + account.transactions = transactions + // TODO: Maybe we should cache these calculations, as they are called for every request + account.expiry = calculate_expiry_date_from_history(transactions) + return account +} + // Helper function to get an account from the database by pubkey function get_account(api, pubkey) { return get_account_and_user_id(api, pubkey).account @@ -31,6 +65,11 @@ function get_last_user_id(api) { // Helper function to put an account into the database by pubkey function put_account(api, pubkey, account) { var user_id = get_user_id_from_pubkey(api, pubkey) + // Make sure we already converted to the transactions model before wiping out the expiry date + if (account.transactions.length > 0) { + account.expiry = null // We don't store the expiry date in the database anymore, it's calculated on the fly from the transaction history + } + if (user_id == null) { const last_user_id = get_last_user_id(api) user_id = last_user_id != null ? parseInt(last_user_id) + 1 : 1 @@ -52,7 +91,7 @@ function check_account(api, pubkey) { return { ok: true, message: null } } -function create_account(api, pubkey, expiry, created_by_user = true) { +function create_account(api, pubkey, transaction_history, created_by_user = true) { const account = get_account(api, pubkey) if (account) @@ -62,62 +101,33 @@ function create_account(api, pubkey, expiry, created_by_user = true) { pubkey: pubkey, // Public key of the user created_at: current_time(), // Time when the account was created created_by_user: created_by_user, // true if the account was created by the user itself, false if it might have been created by someone else. - expiry: expiry, // Date and time when the account expires + expiry: null, // Date and time when the account expires. This is a legacy field, which now is calculated from the transaction history. + transactions: transaction_history, // The transaction history of the account } const { user_id } = put_account(api, pubkey, new_account) return { account: new_account, request_error: null, user_id: user_id } } -function bump_expiry(api, pubkey, expiry_delta) { - const account = get_account(api, pubkey) - if (!account) { - // Create account if it doesn't exist already - return create_account(api, pubkey, current_time() + expiry_delta) - } - if (!account.expiry) { - // Set expiry if it doesn't exist already - account.expiry = current_time() + expiry_delta - } - else if (account.expiry < current_time()) { - // Set new expiry if it has already expired - account.expiry = current_time() + expiry_delta - } - else if (account.expiry >= current_time()) { - // Accumulate expiry if it hasn't expired yet - account.expiry += expiry_delta - } - put_account(api, pubkey, account) - return { account: account, request_error: null } -} -/** - * Sets the expiry date to a fixed date, but also bumps the expiry date if there is any time left. - * It also creates the account if it doesn't exist already. - * - * @param {Object} api - The API object - * @param {string} pubkey - The public key of the user, hex encoded - * @param {number} expiry_date - The new expiry date +/** Adds successful transactions to the account +* @param {Object} api - The API object +* @param {string} pubkey - The public key of the user, hex encoded +* @param {Transaction[]} transactions - The transactions to be added +* @returns {{account?: Object, request_error?: string | null, user_id?: number}} - The account object, or null if the account does not exist, and the request error, or null if there was no error */ -function bump_iap_set_expiry(api, pubkey, expiry_date) { +function add_successful_transactions_to_account(api, pubkey, transactions) { const account = get_account(api, pubkey) if (!account) { // Create account if it doesn't exist already - return create_account(api, pubkey, expiry_date) - } - if (!account.expiry) { - // Set expiry if it doesn't exist already - account.expiry = expiry_date - } - else if (account.expiry < current_time()) { - // Set new expiry if it has already expired - account.expiry = expiry_date + return create_account(api, pubkey, transactions) } - else if (account.expiry >= current_time()) { - // Accumulate expiry if it hasn't expired yet - const remaining_time = account.expiry - current_time() - account.expiry = expiry_date + remaining_time + if (!account.transactions) { + account.transactions = [] } + const merged_transactions = account.transactions.concat(transactions) + const unique_transactions = deep_copy_unique_transaction_history(merged_transactions) + account.transactions = unique_transactions put_account(api, pubkey, account) return { account: account, request_error: null } } @@ -165,4 +175,4 @@ function delete_account(api, pubkey) { return { delete_error: null }; } -module.exports = { check_account, create_account, get_account_info_payload, bump_expiry, get_account, put_account, get_account_and_user_id, get_user_id_from_pubkey, get_user_uuid, bump_iap_set_expiry, delete_account } +module.exports = { check_account, create_account, get_account_info_payload, get_account, put_account, get_account_and_user_id, get_user_id_from_pubkey, get_user_uuid, delete_account, add_successful_transactions_to_account } diff --git a/test/controllers/mock_iap_controller.js b/test/controllers/mock_iap_controller.js index b03c128..d371f0b 100644 --- a/test/controllers/mock_iap_controller.js +++ b/test/controllers/mock_iap_controller.js @@ -99,6 +99,9 @@ const MOCK_ACCOUNT_UUIDS = [ const MOCK_IAP_DATES = { [MOCK_ACCOUNT_UUIDS[0]]: { + // TODO: The dates are actually two ranges of dates, because there are two transactions. Improve this data structure. + // start_date: 1708541400 + // expiry_date: 1708545000 purchase_date: 1708548300, expiry_date: 1708548600, } diff --git a/test/iap_flow.test.js b/test/iap_flow.test.js index 331aca4..33082e2 100644 --- a/test/iap_flow.test.js +++ b/test/iap_flow.test.js @@ -19,16 +19,16 @@ test('IAP Flow — Expected flow (receipts)', async (t) => { // Try to get the account info const response = await purple_api_controller.clients[user_pubkey_1].get_account(); t.same(response.statusCode, 404); - + // Simulate IAP purchase on the iOS side - + purple_api_controller.set_account_uuid(user_pubkey_1, user_uuid); // Associate the pubkey with the user_uuid on the server const receipt_base64 = purple_api_controller.iap.get_iap_receipt_data(user_uuid); // Get the receipt from the iOS side - + // Send the receipt to the server to activate the account const iap_response = await purple_api_controller.clients[user_pubkey_1].send_iap_receipt(user_uuid, receipt_base64); t.same(iap_response.statusCode, 200); - + // Read the account info now const account_info_response = await purple_api_controller.clients[user_pubkey_1].get_account(); t.same(account_info_response.statusCode, 200); @@ -81,7 +81,7 @@ test('IAP Flow — Expected flow (transaction ID)', async (t) => { test('IAP Flow — Invalid receipt should not be authorized or crash the server', async (t) => { // Initialize the PurpleTestController const purple_api_controller = await PurpleTestController.new(t); - + const user_uuid = MOCK_ACCOUNT_UUIDS[0] purple_api_controller.set_current_time(MOCK_IAP_DATES[user_uuid].purchase_date); @@ -91,16 +91,16 @@ test('IAP Flow — Invalid receipt should not be authorized or crash the server' // Try to get the account info const response = await purple_api_controller.clients[user_pubkey_1].get_account(); t.same(response.statusCode, 404); - + // Simulate IAP purchase on the iOS side - + purple_api_controller.set_account_uuid(user_pubkey_1, user_uuid); // Associate the pubkey with the user_uuid on the server const receipt_base64 = "AAAAAAAAAAAA" // Invalid receipt - + // Send the receipt to the server to activate the account const iap_response = await purple_api_controller.clients[user_pubkey_1].send_iap_receipt(user_uuid, receipt_base64); t.same(iap_response.statusCode, 401); - + // Read the account info now const account_info_response = await purple_api_controller.clients[user_pubkey_1].get_account(); t.same(account_info_response.statusCode, 404); @@ -111,7 +111,7 @@ test('IAP Flow — Invalid receipt should not be authorized or crash the server' test('IAP Flow — Invalid receipt should not be authorized or crash the server', async (t) => { // Initialize the PurpleTestController const purple_api_controller = await PurpleTestController.new(t); - + const user_uuid = MOCK_ACCOUNT_UUIDS[1] // Instantiate a new client @@ -120,19 +120,58 @@ test('IAP Flow — Invalid receipt should not be authorized or crash the server' // Try to get the account info const response = await purple_api_controller.clients[user_pubkey_1].get_account(); t.same(response.statusCode, 404); - + // Simulate IAP purchase on the iOS side - + purple_api_controller.set_account_uuid(user_pubkey_1, user_uuid); // Associate the pubkey with the user_uuid on the server const receipt_base64 = MOCK_RECEIPT_DATA[user_uuid]; // Receipt with valid format but invalid transaction ID - + // Send the receipt to the server to activate the account const iap_response = await purple_api_controller.clients[user_pubkey_1].send_iap_receipt(user_uuid, receipt_base64); t.same(iap_response.statusCode, 401); - + // Read the account info now const account_info_response = await purple_api_controller.clients[user_pubkey_1].get_account(); t.same(account_info_response.statusCode, 404); t.end(); }); + +test('IAP Flow — Repeated receipts', async (t) => { + // Initialize the PurpleTestController + const purple_api_controller = await PurpleTestController.new(t); + + const user_uuid = MOCK_ACCOUNT_UUIDS[0] + purple_api_controller.set_current_time(MOCK_IAP_DATES[user_uuid].purchase_date); + + // Instantiate a new client + const user_pubkey_1 = purple_api_controller.new_client(); + + // Try to get the account info + const response = await purple_api_controller.clients[user_pubkey_1].get_account(); + t.same(response.statusCode, 404); + + // Simulate IAP purchase on the iOS side + + purple_api_controller.set_account_uuid(user_pubkey_1, user_uuid); // Associate the pubkey with the user_uuid on the server + const receipt_base64 = purple_api_controller.iap.get_iap_receipt_data(user_uuid); // Get the receipt from the iOS side + + // Send the same receipt to the server 3 times + const iap_response = await purple_api_controller.clients[user_pubkey_1].send_iap_receipt(user_uuid, receipt_base64); + t.same(iap_response.statusCode, 200); + const iap_response_2 = await purple_api_controller.clients[user_pubkey_1].send_iap_receipt(user_uuid, receipt_base64); + t.same(iap_response_2.statusCode, 200); + const iap_response_3 = await purple_api_controller.clients[user_pubkey_1].send_iap_receipt(user_uuid, receipt_base64); + t.same(iap_response_3.statusCode, 200); + + // Read the account info now + const account_info_response = await purple_api_controller.clients[user_pubkey_1].get_account(); + t.same(account_info_response.statusCode, 200); + t.same(account_info_response.body.pubkey, user_pubkey_1) + t.same(account_info_response.body.created_at, purple_api_controller.current_time()); + t.same(account_info_response.body.expiry, MOCK_IAP_DATES[user_uuid].expiry_date); + t.same(account_info_response.body.subscriber_number, 1); + t.same(account_info_response.body.active, true); + + t.end(); +}); diff --git a/test/mixed_iap_ln_flow.test.js b/test/mixed_iap_ln_flow.test.js index 6bd0b7c..f22efac 100644 --- a/test/mixed_iap_ln_flow.test.js +++ b/test/mixed_iap_ln_flow.test.js @@ -38,9 +38,10 @@ test('Mixed IAP/LN Flow — Expiry dates should be nicely handled', async (t) => t.same(account_info_response.statusCode, 200); // This user still had 5 days left on their subscription, so the expiry date should be 5 days after the IAP expiry date // i.e. The user should not lose any credit for the time they had left on their subscription - t.same(account_info_response.body.expiry, MOCK_IAP_DATES[user_uuid].expiry_date + 5 * 24 * 60 * 60); + // TODO: This is hardcoded, but it should be calculated. To better calculate this we need better data structures for the data getters + t.same(account_info_response.body.expiry, 1708987500); t.same(account_info_response.body.active, true); - + // TODO: Test other edge cases? t.end(); diff --git a/test/transaction_management.test.js b/test/transaction_management.test.js new file mode 100644 index 0000000..37e8c6e --- /dev/null +++ b/test/transaction_management.test.js @@ -0,0 +1,437 @@ +"use strict"; +// @ts-check +/** +* @typedef {import('../src/transaction_management').Transaction} Transaction +*/ +const { calculate_expiry_date_from_history } = require('../src/transaction_management'); +const { v4: uuidv4 } = require('uuid'); + +const test = require('tap').test; + +test('Calculate expiry date from transaction history - Simple IAP', async (t) => { + // @type {Transaction[]} + const tx_history_1 = [ + { + type: 'iap', + id: '1', + start_date: generate_unix_timestamp('2024-02-01T00:00:00Z'), + end_date: generate_unix_timestamp('2024-03-01T00:00:00Z'), + purchased_date: generate_unix_timestamp('2024-02-01T00:00:00Z'), + duration: null + }, + { + type: 'iap', + id: '2', + start_date: generate_unix_timestamp('2024-03-01T00:00:00Z'), + end_date: generate_unix_timestamp('2024-04-01T00:00:00Z'), + purchased_date: generate_unix_timestamp('2024-03-01T00:00:00Z'), + duration: null + } + ]; + + const expected_expiry_date_1 = generate_unix_timestamp('2024-04-01T00:00:00Z'); + + t.same(calculate_expiry_date_from_history(tx_history_1), expected_expiry_date_1); + + t.end(); +}); + + +test('Calculate expiry date from transaction history - IAPs with gap', async (t) => { + // @type {Transaction[]} + const tx_history_1 = [ + { + type: 'iap', + id: '1', + start_date: generate_unix_timestamp('2024-02-01T00:00:00Z'), + end_date: generate_unix_timestamp('2024-03-01T00:00:00Z'), + purchased_date: generate_unix_timestamp('2024-02-01T00:00:00Z'), + duration: null + }, + { + type: 'iap', + id: '2', + start_date: generate_unix_timestamp('2024-03-05T00:00:00Z'), + end_date: generate_unix_timestamp('2024-04-05T00:00:00Z'), + purchased_date: generate_unix_timestamp('2024-03-05T00:00:00Z'), + duration: null + } + ]; + + const expected_expiry_date_1 = generate_unix_timestamp('2024-04-05T00:00:00Z'); + + t.same(calculate_expiry_date_from_history(tx_history_1), expected_expiry_date_1); + + t.end(); +}); + + +test('Calculate expiry date from transaction history - Simple LN with no gaps', async (t) => { + // @type {Transaction[]} + const tx_history_1 = [ + { + type: 'ln', + id: uuidv4(), + start_date: null, + end_date: null, + purchased_date: generate_unix_timestamp('2024-02-01T00:00:00Z'), + duration: 60 * 60 * 24 * 30 + }, + { + type: 'ln', + id: uuidv4(), + start_date: null, + end_date: null, + purchased_date: generate_unix_timestamp('2024-02-01T00:00:00Z') + 60 * 60 * 24 * 30, + duration: 60 * 60 * 24 * 30 + } + ]; + + const expected_expiry_date_1 = generate_unix_timestamp('2024-02-01T00:00:00Z') + 60 * 60 * 24 * 30 * 2; + + t.same(calculate_expiry_date_from_history(tx_history_1), expected_expiry_date_1); + + t.end(); +}); + + +test('Calculate expiry date from transaction history - Simple LN with gaps', async (t) => { + // @type {Transaction[]} + const tx_history_1 = [ + { + type: 'ln', + id: uuidv4(), + start_date: null, + end_date: null, + purchased_date: generate_unix_timestamp('2024-02-01T00:00:00Z'), + duration: 60 * 60 * 24 * 30 + }, + { + type: 'ln', + id: uuidv4(), + start_date: null, + end_date: null, + purchased_date: generate_unix_timestamp('2024-02-01T00:00:00Z') + (60 * 60 * 24 * 30) + (60 * 60 * 24), // One day gap + duration: 60 * 60 * 24 * 30 + } + ]; + + const expected_expiry_date_1 = generate_unix_timestamp('2024-02-01T00:00:00Z') + (60 * 60 * 24 * 30 * 2) + (60 * 60 * 24); + + t.same(calculate_expiry_date_from_history(tx_history_1), expected_expiry_date_1); + + t.end(); +}); + + +test('Calculate expiry date from transaction history - Simple LN with overlap', async (t) => { + // @type {Transaction[]} + const tx_history_1 = [ + { + type: 'ln', + id: uuidv4(), + start_date: null, + end_date: null, + purchased_date: generate_unix_timestamp('2024-02-01T00:00:00Z'), + duration: 60 * 60 * 24 * 30 + }, + { + type: 'ln', + id: uuidv4(), + start_date: null, + end_date: null, + purchased_date: generate_unix_timestamp('2024-02-01T00:00:00Z') + (60 * 60 * 24 * 30) - (60 * 60 * 24), // One before expiry + duration: 60 * 60 * 24 * 30 + } + ]; + + const expected_expiry_date_1 = generate_unix_timestamp('2024-02-01T00:00:00Z') + (60 * 60 * 24 * 30 * 2); + + t.same(calculate_expiry_date_from_history(tx_history_1), expected_expiry_date_1); + + t.end(); +}); + + +test('Calculate expiry date from transaction history - Simple LN with overlap and gap', async (t) => { + // @type {Transaction[]} + const tx_history_1 = [ + { + type: 'ln', + id: uuidv4(), + start_date: null, + end_date: null, + purchased_date: generate_unix_timestamp('2024-01-01T00:00:00Z'), + duration: 60 * 60 * 24 * 30 + }, + { + type: 'ln', + id: uuidv4(), + start_date: null, + end_date: null, + purchased_date: generate_unix_timestamp('2024-01-01T00:00:00Z') + (60 * 60 * 24 * 35), + duration: 60 * 60 * 24 * 30 + }, + { + type: 'ln', + id: uuidv4(), + start_date: null, + end_date: null, + purchased_date: generate_unix_timestamp('2024-01-01T00:00:00Z') + (60 * 60 * 24 * 35) + (60 * 60 * 24 * 25), + duration: 60 * 60 * 24 * 30 + } + ]; + + const expected_expiry_date_1 = generate_unix_timestamp('2024-01-01T00:00:00Z') + (60 * 60 * 24 * 95); + t.same(calculate_expiry_date_from_history(tx_history_1), expected_expiry_date_1); + t.end(); +}); + + +test('Calculate expiry date from transaction history - Legacy', async (t) => { + // @type {Transaction[]} + const tx_history_1 = [ + { + type: 'legacy', + id: '0', + start_date: generate_unix_timestamp('2024-02-01T00:00:00Z'), + end_date: generate_unix_timestamp('2024-03-01T00:00:00Z'), + purchased_date: generate_unix_timestamp('2024-02-01T00:00:00Z'), + duration: null + } + ]; + const expected_expiry_date_1 = generate_unix_timestamp('2024-03-01T00:00:00Z'); + t.same(calculate_expiry_date_from_history(tx_history_1), expected_expiry_date_1); + t.end(); +}); + + +test('Calculate expiry date from transaction history - Legacy with overlapping IAP', async (t) => { + // @type {Transaction[]} + const tx_history_1 = [ + { + type: 'legacy', + id: '0', + start_date: generate_unix_timestamp('2024-02-01T00:00:00Z'), + end_date: generate_unix_timestamp('2024-04-01T00:00:00Z'), + purchased_date: generate_unix_timestamp('2024-02-01T00:00:00Z'), + duration: null + }, + { + type: 'iap', + id: '1', + start_date: generate_unix_timestamp('2024-03-01T00:00:00Z'), + end_date: generate_unix_timestamp('2024-04-01T00:00:00Z'), + purchased_date: generate_unix_timestamp('2024-03-01T00:00:00Z'), + duration: null + } + ]; + const expected_expiry_date_1 = generate_unix_timestamp('2024-04-01T00:00:00Z'); + t.same(calculate_expiry_date_from_history(tx_history_1), expected_expiry_date_1); + t.end(); +}); + + +test('Calculate expiry date from transaction history - Legacy with overlapping LN', async (t) => { + // @type {Transaction[]} + const tx_history_1 = [ + { + type: 'legacy', + id: '0', + start_date: generate_unix_timestamp('2024-02-01T00:00:00Z'), + end_date: generate_unix_timestamp('2024-04-01T00:00:00Z'), + purchased_date: generate_unix_timestamp('2024-02-01T00:00:00Z'), + duration: null + }, + { + type: 'ln', + id: uuidv4(), + start_date: null, + end_date: null, + purchased_date: generate_unix_timestamp('2024-03-01T00:00:00Z'), + duration: 60 * 60 * 24 * 30 + } + ]; + const expected_expiry_date_1 = generate_unix_timestamp('2024-04-01T00:00:00Z') + 60 * 60 * 24 * 30; + t.same(calculate_expiry_date_from_history(tx_history_1), expected_expiry_date_1); + t.end(); +}); + + +test('Calculate expiry date from transaction history - Empty history', async (t) => { + // @type {Transaction[]} + const tx_history_1 = []; + t.same(calculate_expiry_date_from_history(tx_history_1), null); + t.end(); +}); + + +test('Calculate expiry date from transaction history - Overlapping IAPs, legacy and LN', async (t) => { + // @type {Transaction[]} + const tx_history_1 = [ + { + type: 'iap', + id: '1', + start_date: generate_unix_timestamp('2024-02-01T00:00:00Z'), + end_date: generate_unix_timestamp('2024-03-01T00:00:00Z'), + purchased_date: generate_unix_timestamp('2024-02-01T00:00:00Z'), + duration: null + }, + { + type: 'iap', + id: '2', + start_date: generate_unix_timestamp('2024-03-01T00:00:00Z'), + end_date: generate_unix_timestamp('2024-04-01T00:00:00Z'), + purchased_date: generate_unix_timestamp('2024-03-01T00:00:00Z'), + duration: null + }, + { + type: 'legacy', + id: '0', + start_date: generate_unix_timestamp('2024-01-01T00:00:00Z'), + end_date: generate_unix_timestamp('2024-03-28T00:00:00Z'), + purchased_date: generate_unix_timestamp('2024-01-01T00:00:00Z'), + duration: null + }, + { + type: 'ln', + id: uuidv4(), + start_date: null, + end_date: null, + purchased_date: generate_unix_timestamp('2024-03-15T00:00:00Z'), + duration: 60 * 60 * 24 * 30 + } + ]; + + const expected_expiry_date_1 = generate_unix_timestamp('2024-04-01T00:00:00Z') + 60 * 60 * 24 * 30; + t.same(calculate_expiry_date_from_history(tx_history_1), expected_expiry_date_1); + t.end(); +}); + + +test('Calculate expiry date from transaction history - Complex overlapping IAPs with gaps, legacy and LN with gaps', async (t) => { + // @type {Transaction[]} + const tx_history_1 = [ + { + type: 'legacy', + id: '0', + start_date: generate_unix_timestamp('2024-01-01T05:55:00Z'), + end_date: generate_unix_timestamp('2024-03-05T02:40:00Z'), + purchased_date: generate_unix_timestamp('2024-01-01T05:55:00Z'), + duration: null + }, + { + type: 'ln', + id: uuidv4(), + start_date: null, + end_date: null, + purchased_date: generate_unix_timestamp('2024-02-05T23:58:00Z'), + duration: 60 * 60 * 24 * 30, + }, + { + type: 'iap', + id: '1', + start_date: generate_unix_timestamp('2024-02-26T04:44:00Z'), + end_date: generate_unix_timestamp('2024-03-26T04:44:00Z'), + purchased_date: generate_unix_timestamp('2024-02-26T04:44:00Z'), + duration: null + }, + { + type: 'iap', + id: '2', + start_date: generate_unix_timestamp('2024-03-26T04:44:00Z'), + end_date: generate_unix_timestamp('2024-04-26T04:44:00Z'), + purchased_date: generate_unix_timestamp('2024-03-26T04:44:00Z'), + duration: null + }, + { + type: 'ln', + id: uuidv4(), + start_date: null, + end_date: null, + purchased_date: generate_unix_timestamp('2024-04-15T04:59:51Z'), + duration: 60 * 60 * 24 * 30 + }, + { + type: 'iap', + id: '3', + start_date: generate_unix_timestamp('2024-05-10T04:59:51Z'), + end_date: generate_unix_timestamp('2024-06-10T04:59:51Z'), + }, + { + type: 'iap', + id: '4', + start_date: generate_unix_timestamp('2024-07-02T04:59:51Z'), + end_date: generate_unix_timestamp('2024-07-20T04:59:51Z'), + }, + { + type: 'ln', + id: uuidv4(), + start_date: null, + end_date: null, + purchased_date: generate_unix_timestamp('2024-07-25T04:20:00Z'), + duration: 60 * 60 * 24 * 30 * 12 + } + ]; + // 1. Gap between end of IAP 2 and start of IAP 3 is 1,210,551 seconds + // 2. LN 1 will be used to fill up gap and at the start of IAP 3 there will be 1,381,449 seconds remaining + // 3. Gap between end of IAP 3 and start of IAP 4 is 1,900,800 seconds + // 4. Remaining time from LN 1 will be used completely to fill up the gap between 3 and 4, but there will still be 519,351 seconds remaining + // 5. LN 2 will be used to fill up the remaining time + // 6. In the beginning of IAP 4, LN 2 will still have 2,072,649 seconds remaining + // 7. At the end of IAP 4, we will use all remaining seconds from LN 2 and then LN 3. LN 2 + LN 3 together have 33,176,649 seconds + // 8. The expiry will then be IAP 4 end date + 33,176,649 seconds = August 8, 2025 at 04:44:00 + const expected_expiry_date_1 = generate_unix_timestamp('2025-08-08T04:44:00Z'); + t.same(calculate_expiry_date_from_history(tx_history_1), expected_expiry_date_1); + + t.end(); +}); + + + +test('Calculate expiry date from transaction history - Broken tx history with repeated transactions', async (t) => { + const ln_id = uuidv4(); + // @type {Transaction[]} + const tx_history_1 = [ + { + type: 'iap', + id: '1', + start_date: generate_unix_timestamp('2024-02-01T00:00:00Z'), + end_date: generate_unix_timestamp('2024-03-01T00:00:00Z'), + purchased_date: generate_unix_timestamp('2024-02-01T00:00:00Z'), + duration: null + }, + { + type: 'iap', + id: '1', + start_date: generate_unix_timestamp('2024-02-01T00:00:00Z'), + end_date: generate_unix_timestamp('2024-03-01T00:00:00Z'), + purchased_date: generate_unix_timestamp('2024-02-01T00:00:00Z'), + duration: null + }, + { + type: 'ln', + id: ln_id, + start_date: null, + end_date: null, + purchased_date: generate_unix_timestamp('2024-02-05T00:00:00Z'), + duration: 60 * 60 * 24 * 30, + }, + { + type: 'ln', + id: ln_id, + start_date: null, + end_date: null, + purchased_date: generate_unix_timestamp('2024-02-05T00:00:00Z'), + duration: 60 * 60 * 24 * 30, + }, + ]; + const expected_expiry_date_1 = generate_unix_timestamp('2024-03-31T00:00:00Z'); + t.same(calculate_expiry_date_from_history(tx_history_1), expected_expiry_date_1); + t.end(); +}); + + +function generate_unix_timestamp(date_string) { + return new Date(date_string) / 1000; +}