diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 6b28f7acb..b3163c073 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -812,6 +812,243 @@ jobs: chia stop all -d || true echo "Chia services stopped" + test-readonly-live-smoke: + name: READ_ONLY live smoke + runs-on: ubuntu-latest + container: + image: node:24 + + steps: + - uses: Chia-Network/actions/clean-workspace@main + + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Ignore Husky + run: npm pkg delete scripts.prepare + + - name: Install Mocha + run: npm install --save-dev mocha + + - name: npm install + run: npm install + + - name: install global packages + run: npm i -g @babel/cli sequelize-cli cross-env pm2@latest + + - name: Show home directory + run: ls -lah ~ + + - name: Install yq and jq + run: | + apt-get update + apt-get install -y yq jq bc iproute2 + + - name: Install Chia and chia-tools + shell: bash + run: | + curl -sL https://repo.chia.net/FD39E6D3.pubkey.asc | gpg --dearmor -o /usr/share/keyrings/chia.gpg + + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/chia.gpg] https://repo.chia.net/debian/ stable main" | tee /etc/apt/sources.list.d/chia.list > /dev/null + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/chia.gpg] https://repo.chia.net/chia-tools/debian/ stable main" | tee /etc/apt/sources.list.d/chia-tools.list > /dev/null + apt-get update + + apt-get install -y chia-blockchain-cli chia-tools + + - name: Configure Chia + shell: bash + run: | + chia init + chia keys generate -l "readonly_live_smoke" + chia-tools network switch testneta + chia-tools config add-trusted-peer -y testneta-node-msp.chia.net 58444 + chia-tools config edit --set wallet.connect_to_unknown_peers=false + chia configure -log-level INFO + + - name: Configure CADT + shell: bash + run: | + # Run cadt momentarily to create config file + pm2 start npm --no-autorestart --name "cadt" -- start + sleep 10 + pm2 logs cadt --nostream + pm2 stop cadt + + # Remove cadt databases to reset system + echo "Removing cadt databases to reset system..." + rm -f ~/.chia/mainnet/cadt/v1/data.sqlite3* + rm -f ~/.chia/mainnet/cadt/v2/data.sqlite3* + + # Configure CADT for READ_ONLY smoke mode + echo "Configuring CADT config.yaml..." + yq -yi '.APP.LOG_LEVEL = "debug"' ~/.chia/mainnet/cadt/config.yaml + yq -yi '.APP.CHIA_NETWORK = "testnet"' ~/.chia/mainnet/cadt/config.yaml + yq -yi '.APP.BIND_ADDRESS = "127.0.0.1"' ~/.chia/mainnet/cadt/config.yaml + yq -yi '.APP.DATALAYER_HOST = "https://127.0.0.1:8562"' ~/.chia/mainnet/cadt/config.yaml + yq -yi '.APP.WALLET_HOST = "https://127.0.0.1:9256"' ~/.chia/mainnet/cadt/config.yaml + yq -yi '.APP.DATALAYER_FILE_SERVER_URL = "https://127.0.0.1:8575"' ~/.chia/mainnet/cadt/config.yaml + yq -yi '.APP.AUTO_MIRROR_EXTERNAL_STORES = false' ~/.chia/mainnet/cadt/config.yaml + + # Enable both APIs and force READ_ONLY mode for this smoke + yq -yi '.V1.ENABLE = true' ~/.chia/mainnet/cadt/config.yaml + yq -yi '.V2.ENABLE = true' ~/.chia/mainnet/cadt/config.yaml + yq -yi '.V1.READ_ONLY = true' ~/.chia/mainnet/cadt/config.yaml + yq -yi '.V2.READ_ONLY = true' ~/.chia/mainnet/cadt/config.yaml + + # Avoid governance bootstrap dependencies for this suite + yq -yi '.V1.GOVERNANCE.GOVERNANCE_BODY_ID = ""' ~/.chia/mainnet/cadt/config.yaml + yq -yi '.V2.GOVERNANCE.GOVERNANCE_BODY_ID = ""' ~/.chia/mainnet/cadt/config.yaml + + # Show the config file + echo "Showing the config file after configuration..." + cat ~/.chia/mainnet/cadt/config.yaml + + # Clean up any existing PM2 processes with the same name + pm2 delete cadt 2>/dev/null || true + + - name: Start Chia + shell: bash + run: | + chia start wallet data data_layer_http + sleep 10 + tail ~/.chia/mainnet/log/debug.log + chia wallet show + + - name: Start CADT + shell: bash + run: | + pm2 start npm --no-autorestart --name "cadt" -- start + sleep 30 + + - name: Wait for wallet sync and datalayer readiness + shell: bash + run: | + echo "Waiting for wallet to finish syncing..." + MAX_WAIT_MINUTES=10 + WAIT_SECONDS=$((MAX_WAIT_MINUTES * 60)) + START_TIME=$(date +%s) + + while true; do + CURRENT_TIME=$(date +%s) + ELAPSED=$((CURRENT_TIME - START_TIME)) + REMAINING=$((WAIT_SECONDS - ELAPSED)) + + if [ "$REMAINING" -le 0 ]; then + echo "Reached maximum wait time of $MAX_WAIT_MINUTES minutes" + break + fi + + WALLET_STATUS=$(chia rpc wallet get_sync_status 2>/dev/null || echo '{"synced": false}') + WALLET_SYNCED=$(echo "$WALLET_STATUS" | jq -r '.synced // false') + WALLET_SYNCING=$(echo "$WALLET_STATUS" | jq -r '.syncing // true') + + echo "Wallet status: synced=$WALLET_SYNCED, syncing=$WALLET_SYNCING (elapsed: ${ELAPSED}s)" + + if [ "$WALLET_SYNCED" = "true" ]; then + echo "Wallet is synced!" + echo "Waiting additional 60 seconds for datalayer to sync..." + sleep 60 + break + fi + + echo "Wallet still syncing, waiting 30 seconds..." + sleep 30 + done + + - name: Show CADT logs before tests + shell: bash + run: | + echo "Last 100 lines of CADT logs:" + pm2 logs cadt --lines 100 --nostream + + - name: READ_ONLY live smoke tests + env: + TEST_API_HOST: 127.0.0.1 + run: | + echo "########################################################" + echo "Running READ_ONLY live smoke tests" + echo "########################################################" + npm run test:live:readonly:smoke + + - name: Show CADT logs after tests + if: always() + shell: bash + run: | + echo "==========================================" + echo "CADT stdout logs (full):" + echo "==========================================" + cat ~/.pm2/logs/cadt-out.log || echo "No stdout log found" + echo "" + echo "==========================================" + echo "CADT stderr logs (full):" + echo "==========================================" + cat ~/.pm2/logs/cadt-error.log || echo "No stderr log found" + + - name: Show Chia debug log after tests (filtered) + if: always() + shell: bash + run: | + echo "==========================================" + echo "Chia debug log (filtered, last 5000 lines):" + echo "==========================================" + echo "Filtering out noisy messages..." + echo "" + tail -n 5000 ~/.chia/mainnet/log/debug.log 2>/dev/null | grep -v \ + -e "Connecting: wss://127.0.0.1" \ + -e "respond_block_headers from" \ + -e "Time for request request_block_headers" \ + -e "get_timestamp_for_height_from_peer add to cache" \ + -e "request_puzzle_solution to peer" \ + -e "acquired on.*keyring.yaml.lock" \ + -e "released on.*keyring.yaml.lock" \ + -e "request_header_blocks" \ + -e "coin_state_update" \ + -e "new_signage_point_or_end_of_sub_slot" \ + -e "request_removal_proof" \ + -e "RespondToCoinUpdates" \ + -e "register_interest_in_coin" \ + -e "request_children to peer" \ + -e "respond_to_coin_update" \ + -e "_coin_subscriptions" \ + -e "Getting generator for" \ + -e "SubscriptionConfig" \ + -e "sub epoch.*start weight is" \ + -e "Puzzle at index" \ + -e "Attempting to acquire lock" \ + -e "watchdog.observers.inotify_buffer" \ + -e "filelock" \ + -e "Adding derivation record" \ + -e "Received message: WSMessage" \ + -e "DataLayer subscription update pool" \ + -e "register_service" \ + -e "Cannot connect to host 127.0.0.1" \ + -e "Failed to connect to PeerInfo" \ + || echo "No Chia debug log found or all lines filtered" + + - name: Stop CADT (readonly-live-smoke) + if: always() + shell: bash + run: | + echo "Stopping CADT pm2 process..." + pm2 stop cadt || true + pm2 delete cadt || true + echo "CADT stopped" + + - name: Show wallet balance (readonly-live-smoke) + if: always() + shell: bash + run: | + echo "Wallet balance at end of job:" + chia wallet show || echo "Failed to show wallet" + + - name: Stop Chia services (readonly-live-smoke) + if: always() + shell: bash + run: | + echo "Stopping all Chia services..." + chia stop all -d || true + echo "Chia services stopped" + test-v1-to-v2-upgrade: name: v1 to v2 upgrade test runs-on: ubuntu-latest diff --git a/package.json b/package.json index ced717d4a..b2894feb9 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test": "bash tests/run-tests.sh npx cross-env NODE_ENV=test USE_SIMULATOR=true mocha --loader node_modules/extensionless/src/register.js tests/**/*.spec.js --reporter spec --exit --timeout 300000", "test:v1": "bash tests/run-tests.sh npx cross-env NODE_ENV=test USE_SIMULATOR=true CW_PORT=31310 mocha --loader node_modules/extensionless/src/register.js 'tests/integration/**/*.spec.js' 'tests/resources/**/*.spec.js' --reporter spec --exit --timeout 300000", "test:v2": "bash tests/run-tests.sh npx cross-env NODE_ENV=test USE_SIMULATOR=true CW_PORT=31311 mocha --loader node_modules/extensionless/src/register.js 'tests/v2/integration/**/*.spec.js' --reporter spec --exit --timeout 300000", + "test:v2:readonly": "bash tests/run-tests.sh npx cross-env NODE_ENV=test USE_SIMULATOR=true CW_PORT=31311 mocha --loader node_modules/extensionless/src/register.js 'tests/v2/integration/read-only-enforcement.spec.js' --reporter spec --exit --timeout 300000", "preflight-check": "node --import=extensionless/register scripts/preflight-check.js", "preflight-check:v1": "node --import=extensionless/register scripts/preflight-check.js --require-v1", "preflight-check:v2": "node --import=extensionless/register scripts/preflight-check.js --require-v2", @@ -22,6 +23,7 @@ "test:v1:live:organization:upgrade": "npx cross-env NODE_ENV=production mocha --loader node_modules/extensionless/src/register.js 'tests/v2/live-api/organization/organization-upgrade-v1.live.spec.js' --reporter spec --exit --timeout 3600000", "test:v2:live:data:extended": "node --import=extensionless/register tests/v2/live-api/data-extended.js", "test:v2:live:data:short": "node --import=extensionless/register tests/v2/live-api/data-short.js", + "test:live:readonly:smoke": "npx cross-env NODE_ENV=production mocha --loader node_modules/extensionless/src/register.js 'tests/v2/live-api/read-only-smoke.live.spec.js' --reporter spec --exit --timeout 600000", "test:v2:live:organization:delete": "npx cross-env NODE_ENV=production mocha --loader node_modules/extensionless/src/register.js 'tests/v2/live-api/organization/organization-delete.live.spec.js' --reporter spec --exit --timeout 600000", "test:locks": "node --import=extensionless/register tests/helpers/show-test-locks.js", "test:v1:live:data:short": "node --import=extensionless/register tests/v1/live-api/data-short.js", diff --git a/src/controllers/fileStore.controller.js b/src/controllers/fileStore.controller.js index 09947385a..b580e7a70 100644 --- a/src/controllers/fileStore.controller.js +++ b/src/controllers/fileStore.controller.js @@ -1,17 +1,26 @@ import crypto from 'crypto'; import { FileStore } from '../models'; import { logger } from '../config/logger'; +import { assertIfReadOnlyMode } from '../utils/data-assertions'; +import { + isReadOnlyError, + sendReadOnlyError, +} from '../utils/read-only-response.js'; -export const subscribeToFileStore = (req, res) => { +export const subscribeToFileStore = async (req, res) => { try { + await assertIfReadOnlyMode(); const { orgUid } = req.body; - FileStore.subscribeToFileStore(orgUid); + await FileStore.subscribeToFileStore(orgUid); res.status(200).json({ message: `${orgUid} subscribed to file store.`, }); } catch (error) { + if (isReadOnlyError(error)) { + return sendReadOnlyError(res); + } res.status(400).json({ message: `Can not subscribe to file store.`, error: error.message, @@ -20,16 +29,20 @@ export const subscribeToFileStore = (req, res) => { } }; -export const unsubscribeFromFileStore = (req, res) => { +export const unsubscribeFromFileStore = async (req, res) => { try { + await assertIfReadOnlyMode(); const { orgUid } = req.body; - FileStore.unsubscribeFromFileStore(orgUid); + await FileStore.unsubscribeFromFileStore(orgUid); res.status(200).json({ - message: `Can not unsubscribe the fileStore from ${orgUid}`, + message: `${orgUid} unsubscribed from file store.`, }); } catch (error) { + if (isReadOnlyError(error)) { + return sendReadOnlyError(res); + } res.status(400).json({ message: 'Can not retrieve file list from filestore', error: error.message, @@ -53,12 +66,16 @@ export const getFileList = async (req, res) => { export const deleteFile = async (req, res) => { try { + await assertIfReadOnlyMode(); await FileStore.deleteFileStoreItem(req.body.fileId); res.status(200).json({ message: 'File will be deleted from the filestore, but it will take a few mins to confirm.', }); } catch (error) { + if (isReadOnlyError(error)) { + return sendReadOnlyError(res); + } res.status(400).json({ message: 'Can not delete file from filestore', error: error.message, @@ -91,6 +108,7 @@ export const getFile = async (req, res) => { export const addFileToFileStore = async (req, res) => { try { + await assertIfReadOnlyMode(); if (!req.file) { throw new Error('Missing file data, can not upload file.'); } @@ -115,6 +133,9 @@ export const addFileToFileStore = async (req, res) => { fileId: SHA256, }); } catch (error) { + if (isReadOnlyError(error)) { + return sendReadOnlyError(res); + } logger.error('[v1]: Error adding file to file store:', error); res.status(400).json({ message: 'Can not add file to file store', diff --git a/src/controllers/governance.controller.js b/src/controllers/governance.controller.js index 459bc9db3..dfdda74db 100644 --- a/src/controllers/governance.controller.js +++ b/src/controllers/governance.controller.js @@ -9,6 +9,10 @@ import { assertWalletIsSynced, assertCanBeGovernanceBody, } from '../utils/data-assertions'; +import { + isReadOnlyError, + sendReadOnlyError, +} from '../utils/read-only-response.js'; import { getConfig } from '../utils/config-loader'; import glossary from '../models/governance/glossary.stub.js'; @@ -138,6 +142,9 @@ export const createGoveranceBody = async (req, res) => { success: true, }); } catch (error) { + if (isReadOnlyError(error)) { + return sendReadOnlyError(res); + } res.status(400).json({ message: 'Cant create Governance Body', error: error.message, @@ -164,6 +171,9 @@ export const setDefaultOrgList = async (req, res) => { success: true, }); } catch (error) { + if (isReadOnlyError(error)) { + return sendReadOnlyError(res); + } logger.error('[v1]: Error updating default orgs:', error); res.status(400).json({ message: 'Cant update default orgs', @@ -191,6 +201,9 @@ export const setPickList = async (req, res) => { success: true, }); } catch (error) { + if (isReadOnlyError(error)) { + return sendReadOnlyError(res); + } res.status(400).json({ message: 'Cant update picklist', error: error.message, @@ -216,6 +229,9 @@ export const setGlossary = async (req, res) => { success: true, }); } catch (error) { + if (isReadOnlyError(error)) { + return sendReadOnlyError(res); + } res.status(400).json({ message: 'Cant update glossary', error: error.message, @@ -226,12 +242,16 @@ export const setGlossary = async (req, res) => { export const sync = async (req, res) => { try { + await assertIfReadOnlyMode(); Governance.sync(); return res.json({ message: 'Syncing Governance Body', success: true, }); } catch (error) { + if (isReadOnlyError(error)) { + return sendReadOnlyError(res); + } res.status(400).json({ message: 'Cant Sync Governance Body', error: error.message, diff --git a/src/controllers/offer.controller.js b/src/controllers/offer.controller.js index 424c14b95..427b80ce3 100644 --- a/src/controllers/offer.controller.js +++ b/src/controllers/offer.controller.js @@ -13,6 +13,10 @@ import { assertNoPendingCommitsExcludingTransfers, assertNoPendingCommits, } from '../utils/data-assertions'; +import { + isReadOnlyError, + sendReadOnlyError, +} from '../utils/read-only-response.js'; import { deserializeMaker, deserializeTaker } from '../utils/datalayer-utils'; @@ -32,6 +36,9 @@ export const generateOfferFile = async (req, res) => { const offerFile = await Staging.generateOfferFile(); res.json(offerFile); } catch (error) { + if (isReadOnlyError(error)) { + return sendReadOnlyError(res); + } console.trace(error); res.status(400).json({ message: 'Error generating offer file.', @@ -153,6 +160,7 @@ export const commitImportedOfferFile = async (req, res) => { export const cancelImportedOfferFile = async (req, res) => { try { + await assertIfReadOnlyMode(); await assertActiveOfferFile(); await Meta.destroy({ @@ -166,6 +174,9 @@ export const cancelImportedOfferFile = async (req, res) => { success: true, }); } catch (error) { + if (isReadOnlyError(error)) { + return sendReadOnlyError(res); + } res.status(400).json({ message: 'Can not cancel offer.', error: error.message, diff --git a/src/controllers/organization.controller.js b/src/controllers/organization.controller.js index 986f31d79..e9d34d717 100644 --- a/src/controllers/organization.controller.js +++ b/src/controllers/organization.controller.js @@ -23,6 +23,10 @@ import { getOrgLockStatus, } from '../utils/org-operation-lock.js'; import { hasInProgressCreation } from '../utils/organization-creation-state.js'; +import { + isReadOnlyError, + sendReadOnlyError, +} from '../utils/read-only-response.js'; const { USE_SIMULATOR } = getConfig().APP; @@ -329,6 +333,7 @@ export const deleteOrganization = async (req, res) => { const { orgUid } = req.params; try { + await assertIfReadOnlyMode(); const organization = await Organization.findOne({ where: { orgUid }, raw: true, @@ -366,6 +371,9 @@ export const deleteOrganization = async (req, res) => { success: true, }); } catch (error) { + if (isReadOnlyError(error)) { + return sendReadOnlyError(res); + } res.status(400).json({ message: 'Error deleting organization', error: error.message, @@ -809,12 +817,16 @@ export const reclaimHome = async (req, res) => { export const sync = async (req, res) => { try { + await assertIfReadOnlyMode(); Organization.syncOrganizationMeta(); return res.json({ message: 'Syncing All Organizations Metadata', success: true, }); } catch (error) { + if (isReadOnlyError(error)) { + return sendReadOnlyError(res); + } res.status(400).json({ message: 'Cant All Organizations Metadata', error: error.message, diff --git a/src/controllers/v2/filestore-v2.controller.js b/src/controllers/v2/filestore-v2.controller.js index 5232c99aa..280ce0d2c 100644 --- a/src/controllers/v2/filestore-v2.controller.js +++ b/src/controllers/v2/filestore-v2.controller.js @@ -142,7 +142,7 @@ export const getFile = async (req, res) => { try { await assertV2HomeOrgExists(); - const { fileId } = req.body; + const { fileId } = req.query; if (!fileId) { return res.status(400).json({ @@ -166,7 +166,7 @@ export const getFile = async (req, res) => { // Check if it's a "not found" error if (error.message && error.message.includes('not found')) { return res.status(404).json({ - message: `FileId ${req.body.fileId || 'unknown'} not found in the filestore.`, + message: `FileId ${req.query.fileId || 'unknown'} not found in the filestore.`, success: false, }); } diff --git a/src/controllers/v2/governance-v2.controller.js b/src/controllers/v2/governance-v2.controller.js index 0e64c5475..f2eab81cd 100644 --- a/src/controllers/v2/governance-v2.controller.js +++ b/src/controllers/v2/governance-v2.controller.js @@ -12,6 +12,10 @@ import { assertIsActiveGovernanceBodyV2, assertV2IfReadOnlyMode, } from '../../utils/v2-data-assertions.js'; +import { + isReadOnlyError, + sendReadOnlyError, +} from '../../utils/read-only-response.js'; /** * Get all GovernanceV2 records @@ -184,6 +188,7 @@ export const findPickList = async (req, res) => { */ export const createGoveranceBody = async (req, res) => { try { + await assertV2IfReadOnlyMode(); await assertCanBeGovernanceBodyV2(); // Validate synchronously before starting background work @@ -211,6 +216,9 @@ export const createGoveranceBody = async (req, res) => { success: true, }); } catch (error) { + if (isReadOnlyError(error)) { + return sendReadOnlyError(res); + } res.status(400).json({ message: 'Cant create V2 Governance Body', error: error.message, @@ -243,6 +251,9 @@ export const setDefaultOrgList = async (req, res) => { success: true, }); } catch (error) { + if (isReadOnlyError(error)) { + return sendReadOnlyError(res); + } loggerV2.error('[v2]: Error updating default orgs:', error); res.status(400).json({ message: 'Cannot update default orgs', @@ -276,6 +287,9 @@ export const setPickList = async (req, res) => { success: true, }); } catch (error) { + if (isReadOnlyError(error)) { + return sendReadOnlyError(res); + } loggerV2.error('[v2]: Error updating picklist:', error); res.status(400).json({ message: 'Cannot update picklist', @@ -308,6 +322,9 @@ export const setGlossary = async (req, res) => { success: true, }); } catch (error) { + if (isReadOnlyError(error)) { + return sendReadOnlyError(res); + } loggerV2.error('[v2]: Error updating glossary:', error); res.status(400).json({ message: 'Cannot update glossary', @@ -327,12 +344,16 @@ export const setGlossary = async (req, res) => { */ export const sync = async (req, res) => { try { + await assertV2IfReadOnlyMode(); GovernanceV2.sync(); return res.json({ message: 'Syncing V2 Governance Body', success: true, }); } catch (error) { + if (isReadOnlyError(error)) { + return sendReadOnlyError(res); + } res.status(400).json({ message: 'Cannot sync V2 Governance Body', error: error.message, @@ -369,6 +390,9 @@ export const subscribeToGovernanceBody = async (req, res) => { success: true, }); } catch (error) { + if (isReadOnlyError(error)) { + return sendReadOnlyError(res); + } loggerV2.error(`[v2]: Error subscribing to governance body: ${error.message}`); res.status(400).json({ message: 'Error subscribing to governance body', diff --git a/src/controllers/v2/offer-v2.controller.js b/src/controllers/v2/offer-v2.controller.js index 29566daca..83eabe811 100644 --- a/src/controllers/v2/offer-v2.controller.js +++ b/src/controllers/v2/offer-v2.controller.js @@ -12,6 +12,10 @@ import { assertStagingTableNotEmpty, assertStagingTableIsEmpty, } from '../../utils/v2-data-assertions.js'; +import { + isReadOnlyError, + sendReadOnlyError, +} from '../../utils/read-only-response.js'; import { deserializeMaker, deserializeTaker } from '../../utils/datalayer-utils.js'; import { loggerV2 } from '../../config/logger.js'; import { getConfig } from '../../utils/config-loader.js'; @@ -32,6 +36,9 @@ export const generateOfferFile = async (req, res) => { const offerFile = await OfferV2.generateOfferFile(); res.json(offerFile); } catch (error) { + if (isReadOnlyError(error)) { + return sendReadOnlyError(res); + } loggerV2.error('[v2]: Error generating offer file:', error); res.status(400).json({ message: 'Error generating offer file.', @@ -134,6 +141,7 @@ export const commitImportedOffer = async (req, res) => { */ export const cancelImportedOffer = async (req, res) => { try { + await assertV2IfReadOnlyMode(); await OfferV2.cancelImportedOffer(); res.json({ @@ -141,6 +149,9 @@ export const cancelImportedOffer = async (req, res) => { success: true, }); } catch (error) { + if (isReadOnlyError(error)) { + return sendReadOnlyError(res); + } loggerV2.error('[v2]: Error canceling imported offer:', error); res.status(400).json({ message: 'Cannot cancel offer', diff --git a/src/controllers/v2/organizations-v2.controller.js b/src/controllers/v2/organizations-v2.controller.js index f5cdf5b87..0623552f5 100644 --- a/src/controllers/v2/organizations-v2.controller.js +++ b/src/controllers/v2/organizations-v2.controller.js @@ -33,6 +33,10 @@ import { assertV2IfReadOnlyMode, assertV2HomeOrgExists, assertV2OrgDoesNotExist import { assertWalletIsSynced, assertStoreIsOwned } from '../../utils/data-assertions.js'; import { sequelizeV2 } from '../../database/v2/index.js'; import { loggerV2 } from '../../config/logger.js'; +import { + isReadOnlyError, + sendReadOnlyError, +} from '../../utils/read-only-response.js'; import datalayer from '../../datalayer'; import { getStoreData as getRawStoreData } from '../../datalayer/persistance.js'; import * as simulator from '../../datalayer/simulator.js'; @@ -985,8 +989,7 @@ export const deleteOrganization = async (req, res) => { */ export const sync = async (req, res) => { try { - // Optional: check read-only mode (sync is typically read-only operation) - // await assertV2IfReadOnlyMode(); + await assertV2IfReadOnlyMode(); await OrganizationsV2.syncOrganizationMeta(); @@ -995,6 +998,9 @@ export const sync = async (req, res) => { success: true, }); } catch (error) { + if (isReadOnlyError(error)) { + return sendReadOnlyError(res); + } loggerV2.error(`[v2]: Error syncing organization metadata: ${error.message}`); res.status(400).json({ message: 'Error syncing organization metadata', diff --git a/src/middleware.js b/src/middleware.js index 3d9994d31..5717a5043 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -19,6 +19,7 @@ import datalayer from './datalayer'; import { Organization } from './models'; import { OrganizationsV2 } from './models/v2/index.js'; import { logger } from './config/logger.js'; +import { sendReadOnlyError } from './utils/read-only-response.js'; const { USE_SIMULATOR } = getConfig().APP; @@ -42,6 +43,7 @@ const HEALTH_ENDPOINTS = new Set([ ]); const isHealthEndpoint = (path) => HEALTH_ENDPOINTS.has(path); +const isReadOnlyMethodBlocked = (method) => !['GET', 'HEAD', 'OPTIONS'].includes(method); const app = express(); @@ -152,6 +154,22 @@ app.use(async function (req, res, next) { } try { + // Enforce READ_ONLY before wallet availability assertions so writes are + // consistently rejected with the canonical 403 response. + const isV2Route = req.path.startsWith('/v2/'); + const isV1Route = req.path.startsWith('/v1/'); + let READ_ONLY = false; + if (isV2Route) { + READ_ONLY = getConfigV2().READ_ONLY || false; + } else if (isV1Route) { + READ_ONLY = getConfig().READ_ONLY || false; + } else { + READ_ONLY = getConfigV2().READ_ONLY || getConfig().READ_ONLY || false; + } + if (READ_ONLY && isReadOnlyMethodBlocked(req.method)) { + return sendReadOnlyError(res); + } + await assertChiaNetworkMatchInConfiguration(); await assertDataLayerAvailable(); if (req.method !== 'GET') { diff --git a/src/models/organizations/organizations.model.js b/src/models/organizations/organizations.model.js index e6eb912df..8cbd69a72 100644 --- a/src/models/organizations/organizations.model.js +++ b/src/models/organizations/organizations.model.js @@ -59,6 +59,7 @@ const { isTransientWalletError } = wallet; class Organization extends Model { static async getHomeOrg(includeAddress = true) { + const { READ_ONLY } = getConfig(); const myOrganization = await Organization.findOne({ where: { isHome: true }, raw: true, @@ -79,7 +80,9 @@ class Organization extends Model { } if (myOrganization && includeAddress) { - myOrganization.xchAddress = await datalayer.getPublicAddress(); + if (!READ_ONLY) { + myOrganization.xchAddress = await datalayer.getPublicAddress(); + } myOrganization.fileStoreSubscribed = true; return myOrganization; } @@ -97,6 +100,7 @@ class Organization extends Model { } static async getOrgsMap() { + const { READ_ONLY } = getConfig(); logger.silly( '[MIRROR_DEBUG] Starting getOrgsMap() - querying organizations from database', ); @@ -126,10 +130,12 @@ class Organization extends Model { for (let i = 0; i < organizations.length; i++) { if (organizations[i].dataValues.isHome) { - organizations[i].dataValues.xchAddress = - await datalayer.getPublicAddress(); - organizations[i].dataValues.balance = - await datalayer.getWalletBalance(); + if (!READ_ONLY) { + organizations[i].dataValues.xchAddress = + await datalayer.getPublicAddress(); + organizations[i].dataValues.balance = + await datalayer.getWalletBalance(); + } const pendingCommitsCount = await Staging.count({ where: { commited: true }, diff --git a/src/models/v2/organizations-v2.model.js b/src/models/v2/organizations-v2.model.js index c24f8f5a0..334c9d851 100644 --- a/src/models/v2/organizations-v2.model.js +++ b/src/models/v2/organizations-v2.model.js @@ -9,7 +9,7 @@ import datalayer from '../../datalayer'; import { getStoreData as getRawStoreData } from '../../datalayer/persistance.js'; import * as simulator from '../../datalayer/simulator.js'; import { loggerV2 } from '../../config/logger.js'; -import { getConfig } from '../../utils/config-loader'; +import { getConfig, getConfigV2 } from '../../utils/config-loader'; import { decodeHex, decodeDataLayerResponse } from '../../utils/datalayer-utils.js'; const { USE_SIMULATOR, AUTO_SUBSCRIBE_FILESTORE } = getConfig().APP; @@ -1146,6 +1146,7 @@ class OrganizationsV2 extends Model { * @returns {Promise} Home organization record or null if not found */ static async getHomeOrg(includeAddress = true) { + const { READ_ONLY } = getConfigV2(); const myOrganization = await OrganizationsV2.findOne({ where: { is_home: true }, raw: true, @@ -1175,7 +1176,9 @@ class OrganizationsV2 extends Model { } if (includeAddress) { - myOrganization.xchAddress = await datalayer.getPublicAddress(); + if (!READ_ONLY) { + myOrganization.xchAddress = await datalayer.getPublicAddress(); + } myOrganization.fileStoreSubscribed = myOrganization.file_store_subscribed || false; return myOrganization; } @@ -1196,6 +1199,7 @@ class OrganizationsV2 extends Model { * @returns {Promise} Map of orgUid -> org data */ static async getOrgsMap() { + const { READ_ONLY } = getConfigV2(); loggerV2.silly( '[v2]: [MIRROR_DEBUG] Starting getOrgsMap() - querying V2 organizations from database', ); @@ -1225,10 +1229,12 @@ class OrganizationsV2 extends Model { // Add XCH address and balance for home org for (let i = 0; i < organizations.length; i++) { if (organizations[i].dataValues.is_home) { - organizations[i].dataValues.xchAddress = - await datalayer.getPublicAddress(); - organizations[i].dataValues.balance = - await datalayer.getWalletBalance(); + if (!READ_ONLY) { + organizations[i].dataValues.xchAddress = + await datalayer.getPublicAddress(); + organizations[i].dataValues.balance = + await datalayer.getWalletBalance(); + } const pendingCommitsCount = await StagingV2.count({ where: { committed: true }, diff --git a/src/routes/v1/resources/governance.js b/src/routes/v1/resources/governance.js index 802eee124..4078432f4 100644 --- a/src/routes/v1/resources/governance.js +++ b/src/routes/v1/resources/governance.js @@ -5,7 +5,6 @@ import joiExpress from 'express-joi-validation'; import { GovernanceController } from '../../../controllers'; import { - governanceSubscribeSchema, setOrgListSchema, governancePickListSchema, } from '../../../validations'; @@ -61,12 +60,4 @@ GovernanceRouter.post('/meta/glossary', (req, res) => { return GovernanceController.setGlossary(req, res); }); -GovernanceRouter.post( - '/subscribe', - validator.body(governanceSubscribeSchema), - (req, res) => { - return GovernanceController.subscribeToGovernanceBody(req, res); - }, -); - export { GovernanceRouter }; diff --git a/src/routes/v2/resources/filestore-v2.js b/src/routes/v2/resources/filestore-v2.js index 33b6ff369..095eee7f5 100644 --- a/src/routes/v2/resources/filestore-v2.js +++ b/src/routes/v2/resources/filestore-v2.js @@ -18,13 +18,8 @@ import { subscribedSchema, } from '../../../validations/v2/filestore-v2.validations.js'; -// GET /v2/filestore/get_file - Get file by ID -// Note: V1 uses GET with body (unusual but matches V1 pattern) -// For testing, we also support POST to work with supertest -FilestoreV2Router.get('/get_file', validator.body(getFileSchema), (req, res) => { - return FilestoreV2Controller.getFile(req, res); -}); -FilestoreV2Router.post('/get_file', validator.body(getFileSchema), (req, res) => { +// GET /v2/filestore/get_file?fileId=... - Get file by ID +FilestoreV2Router.get('/get_file', validator.query(getFileSchema), (req, res) => { return FilestoreV2Controller.getFile(req, res); }); diff --git a/src/utils/data-assertions.js b/src/utils/data-assertions.js index 95fcb7fec..3107a4b9c 100644 --- a/src/utils/data-assertions.js +++ b/src/utils/data-assertions.js @@ -8,10 +8,11 @@ import { formatModelAssociationName } from './model-utils.js'; import { getConfig } from './config-loader'; import { getOwnedStores } from '../datalayer/persistance.js'; import { isOwnedStoreLocalDataMissing } from './datalayer-utils.js'; +import { createReadOnlyError } from './read-only-response.js'; const config = getConfig(); const { USE_SIMULATOR, CHIA_NETWORK } = config.APP; -const { IS_GOVERNANCE_BODY, READ_ONLY } = config; +const { IS_GOVERNANCE_BODY } = config; export const assertChiaNetworkMatchInConfiguration = async () => { if (!USE_SIMULATOR) { @@ -55,8 +56,9 @@ export const assertDataLayerAvailable = async () => { }; export const assertIfReadOnlyMode = async () => { + const { READ_ONLY } = getConfig(); if (READ_ONLY) { - throw new Error('You can not use this API in read-only mode'); + throw createReadOnlyError(); } }; diff --git a/src/utils/read-only-response.js b/src/utils/read-only-response.js new file mode 100644 index 000000000..762f8b860 --- /dev/null +++ b/src/utils/read-only-response.js @@ -0,0 +1,20 @@ +export const READ_ONLY_ERROR = Object.freeze({ + message: 'This CADT instance is configured as read-only', + error: 'Write operations are not permitted in read-only mode', + success: false, +}); + +export const sendReadOnlyError = (res) => { + return res.status(403).json(READ_ONLY_ERROR); +}; + +export const createReadOnlyError = () => { + const error = new Error(READ_ONLY_ERROR.error); + error.code = 'READ_ONLY'; + error.status = 403; + return error; +}; + +export const isReadOnlyError = (error) => { + return error?.code === 'READ_ONLY'; +}; diff --git a/src/utils/v2-data-assertions.js b/src/utils/v2-data-assertions.js index 7fb51da4a..845d4c81a 100644 --- a/src/utils/v2-data-assertions.js +++ b/src/utils/v2-data-assertions.js @@ -5,6 +5,7 @@ import _ from 'lodash'; import { StagingV2, OrganizationsV2, MetaV2 } from '../models/v2/index.js'; import { getConfig, getConfigV2 } from './config-loader.js'; import { loggerV2 } from '../config/logger.js'; +import { createReadOnlyError } from './read-only-response.js'; /** * V2-specific assertion that the system is not in read-only mode @@ -17,7 +18,7 @@ export const assertV2IfReadOnlyMode = async () => { const config = getConfigV2(); const READ_ONLY = config.READ_ONLY; if (READ_ONLY) { - throw new Error('Cannot use this API in read-only mode. The system is currently configured as read-only.'); + throw createReadOnlyError(); } }; diff --git a/src/validations/governance.validations.js b/src/validations/governance.validations.js index 67044a138..262f9db54 100644 --- a/src/validations/governance.validations.js +++ b/src/validations/governance.validations.js @@ -1,9 +1,5 @@ import Joi from 'joi'; -export const governanceSubscribeSchema = Joi.object().keys({ - orgUid: Joi.string().required(), -}); - export const setOrgListSchema = Joi.array().items( Joi.object({ orgUid: Joi.string().required(), diff --git a/tests/v2/integration/filestore-v2.spec.js b/tests/v2/integration/filestore-v2.spec.js index 95908d883..b476730a5 100644 --- a/tests/v2/integration/filestore-v2.spec.js +++ b/tests/v2/integration/filestore-v2.spec.js @@ -11,7 +11,7 @@ import crypto from 'crypto'; * Phase 19.4: FilestoreV2 Comprehensive Integration Tests * * Comprehensive integration tests for all filestore endpoints covering: - * - GET /v2/filestore/get_file - Get file by ID + * - GET /v2/filestore/get_file?fileId=... - Get file by ID * - GET /v2/filestore/get_file_list - List files * - POST /v2/filestore/add_file - Add file to filestore * - POST /v2/filestore/subscribe - Subscribe to filestore @@ -25,6 +25,15 @@ describe('Phase 19.4: FilestoreV2 Comprehensive Integration Tests', function () let testOrgUid; let testOrgUid2; + const clearV1FileStoreIfAvailable = async () => { + try { + await FileStore.destroy({ where: {} }); + } catch (error) { + if (!String(error.message).includes('no such table')) { + throw error; + } + } + }; before(async function () { console.log('Setting up V2 test environment...'); @@ -33,7 +42,7 @@ describe('Phase 19.4: FilestoreV2 Comprehensive Integration Tests', function () // Clean up any existing data await FilestoreV2.destroy({ where: {} }); await OrganizationsV2.destroy({ where: {} }); - await FileStore.destroy({ where: {} }); + await clearV1FileStoreIfAvailable(); // Create test home organization const homeOrg = await createV2TestHomeOrg(); @@ -58,7 +67,7 @@ describe('Phase 19.4: FilestoreV2 Comprehensive Integration Tests', function () beforeEach(async function () { // Clean up filestore records before each test await FilestoreV2.destroy({ where: {} }); - await FileStore.destroy({ where: {} }); + await clearV1FileStoreIfAvailable(); }); describe('GET /v2/filestore/get_file_list', function () { @@ -158,7 +167,7 @@ describe('Phase 19.4: FilestoreV2 Comprehensive Integration Tests', function () }); }); - describe('GET /v2/filestore/get_file', function () { + describe('GET /v2/filestore/get_file?fileId=...', function () { it('should return file content when file exists', async function () { // Create a test file const testContent = 'test file content for get'; @@ -175,33 +184,38 @@ describe('Phase 19.4: FilestoreV2 Comprehensive Integration Tests', function () org_uid: testOrgUid, }); - // Note: Using POST for testing since supertest doesn't support body on GET const response = await supertest(app) - .post('/v2/filestore/get_file') - .send({ fileId: SHA256 }) + .get('/v2/filestore/get_file') + .query({ fileId: SHA256 }) .expect(200); // Response should be the file content as binary expect(response.text).to.equal(testContent); }); - it('should return error if fileId is missing', async function () { + it('should return validation error for missing fileId query param', async function () { const response = await supertest(app) - .post('/v2/filestore/get_file') - .send({}); + .get('/v2/filestore/get_file'); expect([400, 422]).to.include(response.status); }); it('should return error if file does not exist', async function () { const response = await supertest(app) - .post('/v2/filestore/get_file') - .send({ fileId: 'nonexistent-sha256' }) + .get('/v2/filestore/get_file') + .query({ fileId: 'nonexistent-sha256' }) .expect(404); expect(response.body.success).to.be.false; expect(response.body.message).to.include('not found'); }); + + it('should not support legacy POST /v2/filestore/get_file', async function () { + await supertest(app) + .post('/v2/filestore/get_file') + .send({ fileId: 'legacy-route' }) + .expect(404); + }); }); describe('DELETE /v2/filestore/delete_file', function () { @@ -311,8 +325,8 @@ describe('Phase 19.4: FilestoreV2 Comprehensive Integration Tests', function () describe('Error Handling', function () { it('should handle invalid file ID gracefully', async function () { const response = await supertest(app) - .post('/v2/filestore/get_file') - .send({ fileId: 'invalid-sha256-format' }) + .get('/v2/filestore/get_file') + .query({ fileId: 'invalid-sha256-format' }) .expect(404); expect(response.body.success).to.be.false; @@ -471,8 +485,8 @@ describe('Phase 19.4: FilestoreV2 Comprehensive Integration Tests', function () // Step 3: Get file const getResponse = await supertest(app) - .post('/v2/filestore/get_file') - .send({ fileId }) + .get('/v2/filestore/get_file') + .query({ fileId }) .expect(200); expect(getResponse.text).to.equal(testContent); diff --git a/tests/v2/integration/read-only-enforcement.spec.js b/tests/v2/integration/read-only-enforcement.spec.js new file mode 100644 index 000000000..d018953bd --- /dev/null +++ b/tests/v2/integration/read-only-enforcement.spec.js @@ -0,0 +1,77 @@ +import { expect } from 'chai'; +import supertest from 'supertest'; +import app from '../../../src/server.js'; +import { prepareDb } from '../../../src/database/index.js'; +import { prepareV2Db } from '../../../src/database/v2/index.js'; +import { createV2TestHomeOrg, withConfigOverride } from '../utils/v2-test-helpers.js'; +import { READ_ONLY_ERROR } from '../../../src/utils/read-only-response.js'; +import { createTestHomeOrg } from '../../test-fixtures/index.js'; + +describe('READ_ONLY enforcement', function () { + this.timeout(30000); + + before(async function () { + await prepareDb(); + await prepareV2Db(); + await createTestHomeOrg(); + await createV2TestHomeOrg(); + }); + + const assertReadOnlyResponse = (response) => { + expect(response.status).to.equal(403); + expect(response.body).to.deep.equal(READ_ONLY_ERROR); + }; + + it('blocks representative write methods via middleware when READ_ONLY=true', async function () { + await withConfigOverride(async () => { + const postResponse = await supertest(app) + .post('/v2/governance') + .send({}); + + const putResponse = await supertest(app) + .put('/v2/organizations/subscribe') + .send({ orgUid: 'test-org' }); + + const deleteResponse = await supertest(app) + .delete('/v1/organizations/nonexistent-org'); + + assertReadOnlyResponse(postResponse); + assertReadOnlyResponse(putResponse); + assertReadOnlyResponse(deleteResponse); + }, { APP: { READ_ONLY: true } }); + }); + + it('blocks GET sync endpoints that perform writes via controller guards', async function () { + await withConfigOverride(async () => { + const v1Response = await supertest(app).get('/v1/governance/sync'); + const v2Response = await supertest(app).get('/v2/governance/sync'); + + assertReadOnlyResponse(v1Response); + assertReadOnlyResponse(v2Response); + }, { APP: { READ_ONLY: true } }); + }); + + it('allows safe GET endpoints when READ_ONLY=true', async function () { + await withConfigOverride(async () => { + await supertest(app).get('/v1/health').expect(200); + await supertest(app).get('/v2/health').expect(200); + }, { APP: { READ_ONLY: true } }); + }); + + it('suppresses wallet fields on organization read endpoints in read-only mode', async function () { + await withConfigOverride(async () => { + const v1Response = await supertest(app).get('/v1/organizations').expect(200); + const v2Response = await supertest(app).get('/v2/organizations').expect(200); + + const v1HomeOrg = Object.values(v1Response.body).find((org) => org.isHome); + const v2HomeOrg = Object.values(v2Response.body).find((org) => org.is_home); + + expect(v1HomeOrg).to.exist; + expect(v2HomeOrg).to.exist; + expect(v1HomeOrg).to.not.have.property('xchAddress'); + expect(v1HomeOrg).to.not.have.property('balance'); + expect(v2HomeOrg).to.not.have.property('xchAddress'); + expect(v2HomeOrg).to.not.have.property('balance'); + }, { APP: { READ_ONLY: true } }); + }); +}); diff --git a/tests/v2/live-api/read-only-smoke.live.spec.js b/tests/v2/live-api/read-only-smoke.live.spec.js new file mode 100644 index 000000000..cba08ef13 --- /dev/null +++ b/tests/v2/live-api/read-only-smoke.live.spec.js @@ -0,0 +1,66 @@ +import { expect } from 'chai'; +import { getLiveApiRequest } from './helpers/live-api-helpers.js'; +import { READ_ONLY_ERROR } from '../../../src/utils/read-only-response.js'; + +describe('READ_ONLY Live API Smoke', function () { + this.timeout(600000); + + let request; + const canaryWriteEndpoints = [ + { method: 'post', path: '/v1/governance', label: 'v1 governance create' }, + { method: 'post', path: '/v1/organization/sync', label: 'v1 organization sync' }, + { method: 'delete', path: '/v1/staging/clean', label: 'v1 staging clean' }, + { method: 'post', path: '/v1/filestore/add_file', label: 'v1 filestore add file' }, + { method: 'post', path: '/v2/governance', label: 'v2 governance create' }, + { method: 'post', path: '/v2/organizations/sync', label: 'v2 organizations sync' }, + { method: 'delete', path: '/v2/staging/clean', label: 'v2 staging clean' }, + { method: 'post', path: '/v2/filestore/add_file', label: 'v2 filestore add file' }, + ]; + const canaryWriteLikeGetEndpoints = [ + { path: '/v1/governance/sync', label: 'v1 governance sync (GET side effects)' }, + { path: '/v2/governance/sync', label: 'v2 governance sync (GET side effects)' }, + { path: '/v1/offer', label: 'v1 offer generate (GET side effects)' }, + { path: '/v2/offer', label: 'v2 offer generate (GET side effects)' }, + ]; + + before(async function () { + request = await getLiveApiRequest({ apiVersion: 'any' }); + }); + + const expectReadOnlyBlock = (response, endpointLabel) => { + expect(response.status, endpointLabel).to.equal(403); + expect(response.body, endpointLabel).to.deep.equal(READ_ONLY_ERROR); + }; + + it('confirms node is configured in read-only mode', async function () { + const v1Health = await request.get('/v1/health/wallet'); + const v2Health = await request.get('/v2/health/wallet'); + + expect(v1Health.status).to.equal(200); + expect(v2Health.status).to.equal(200); + expect(v1Health.body.readOnly).to.equal(true); + expect(v2Health.body.readOnly).to.equal(true); + }); + + it('blocks canary write endpoints with canonical 403 payload', async function () { + for (const endpoint of canaryWriteEndpoints) { + const response = await request[endpoint.method](endpoint.path).send({}); + expectReadOnlyBlock(response, endpoint.label); + } + }); + + it('blocks canary GET endpoints with write-side effects', async function () { + for (const endpoint of canaryWriteLikeGetEndpoints) { + const response = await request.get(endpoint.path); + expectReadOnlyBlock(response, endpoint.label); + } + }); + + it('allows safe GET health endpoints', async function () { + const v1Health = await request.get('/v1/health'); + const v2Health = await request.get('/v2/health'); + + expect(v1Health.status).to.equal(200); + expect(v2Health.status).to.equal(200); + }); +});