mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-07-26 21:15:40 +00:00
80d273ff11
Closes the "backend has zero CI + manual SSH deploy" audit finding for the indexer. Part B — CI-runnable tests (the integration-tests/*.live.test.js need a live chain and are excluded, not stubbed): extract injectable cores (indexer.js: DB+HTTP+decode, no @pezkuwi import; council.js: createApp factory for the council/KYC routes). New node:test suite (19/19, fully offline) — in-memory sqlite, dependency-injected chain stub, an in-memory fake-supabase, and REAL @pezkuwi keyring signatures proving 401 bad-sig / 400 msg-mismatch / 200 valid / 409 dup / 403 non-member / threshold auto-execute. Backend job now runs npm ci + npm test and is in ci-gate (failing tests block merge). Fixed a latent bug: block indexing now awaits each insert (was fire-and-forget forEach). Added runtime deps server.js imported but were missing from package.json (@supabase/supabase-js, pino, pino-http) — server.js could not have `npm ci`-run before. Part C — Dockerfile (non-root, HEALTHCHECK /health, sqlite state on a /data VOLUME kept out of the image) + backend deploy pipeline mirroring the web one: build+push to GHCR, cosign keyless sign + verify, ssh deploy with the DB volume preserved across deploys, /health poll, auto-rollback to previous SHA, same Telegram CEO approval gate, main/tags only, never fork PRs. New secrets documented in backend/DEPLOY.md (BACKEND_VPS_HOST/USER/ SSH_KEY). DB_PATH env (default ./transactions.db) lets prod point at the volume. Note for owner: if server.js (council/KYC bootstrap) is legacy/unused, its newly-added deps can be dropped instead.
153 lines
5.1 KiB
JavaScript
153 lines
5.1 KiB
JavaScript
// Offline indexer tests — in-memory sqlite, no live chain.
|
|
// Run: node --test
|
|
|
|
import { test } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import request from 'supertest'
|
|
import { initDb, saveTransfer, parseExtrinsic, indexBlock, createApp } from '../src/indexer.js'
|
|
|
|
// --- fixtures ---------------------------------------------------------------
|
|
|
|
// Build a decoded-extrinsic look-alike matching the shape parseExtrinsic reads.
|
|
function mkEx ({ section, method, args, hash, signer }) {
|
|
return {
|
|
method: { section, method, args },
|
|
signer: { toString: () => signer },
|
|
hash: { toHex: () => hash }
|
|
}
|
|
}
|
|
|
|
const scalar = (s) => ({ toString: () => s })
|
|
const numeric = (n) => ({ toNumber: () => n })
|
|
|
|
// --- parseExtrinsic ---------------------------------------------------------
|
|
|
|
test('parseExtrinsic maps native balances.transfer to HEZ', () => {
|
|
const ex = mkEx({
|
|
section: 'balances',
|
|
method: 'transfer',
|
|
args: [scalar('5Receiver'), scalar('1000')],
|
|
hash: '0xhez1',
|
|
signer: '5Sender'
|
|
})
|
|
const t = parseExtrinsic(ex, 42)
|
|
assert.deepEqual(t, {
|
|
hash: '0xhez1',
|
|
sender: '5Sender',
|
|
receiver: '5Receiver',
|
|
amount: '1000',
|
|
asset_id: null,
|
|
symbol: 'HEZ',
|
|
block_number: 42
|
|
})
|
|
})
|
|
|
|
test('parseExtrinsic maps balances.transferKeepAlive to HEZ', () => {
|
|
const ex = mkEx({
|
|
section: 'balances',
|
|
method: 'transferKeepAlive',
|
|
args: [scalar('5R'), scalar('7')],
|
|
hash: '0xhez2',
|
|
signer: '5S'
|
|
})
|
|
assert.equal(parseExtrinsic(ex, 1).symbol, 'HEZ')
|
|
})
|
|
|
|
test('parseExtrinsic maps asset ids to PEZ/USDT/ASSET-n', () => {
|
|
const mk = (id) => mkEx({
|
|
section: 'assets',
|
|
method: 'transfer',
|
|
args: [numeric(id), scalar('5R'), scalar('9')],
|
|
hash: `0xa${id}`,
|
|
signer: '5S'
|
|
})
|
|
assert.equal(parseExtrinsic(mk(1), 1).symbol, 'PEZ')
|
|
assert.equal(parseExtrinsic(mk(1000), 1).symbol, 'USDT')
|
|
assert.equal(parseExtrinsic(mk(5), 1).symbol, 'ASSET-5')
|
|
assert.equal(parseExtrinsic(mk(5), 1).asset_id, 5)
|
|
})
|
|
|
|
test('parseExtrinsic ignores non-transfer extrinsics', () => {
|
|
const ex = mkEx({
|
|
section: 'system',
|
|
method: 'remark',
|
|
args: [scalar('hi')],
|
|
hash: '0xnope',
|
|
signer: '5S'
|
|
})
|
|
assert.equal(parseExtrinsic(ex, 1), null)
|
|
})
|
|
|
|
// --- dedup + persistence ----------------------------------------------------
|
|
|
|
test('saveTransfer dedups on hash (INSERT OR IGNORE)', async () => {
|
|
const db = await initDb(':memory:')
|
|
const tx = {
|
|
hash: '0xdup',
|
|
sender: '5S',
|
|
receiver: '5R',
|
|
amount: '100',
|
|
asset_id: null,
|
|
symbol: 'HEZ',
|
|
block_number: 10
|
|
}
|
|
await saveTransfer(db, tx)
|
|
await saveTransfer(db, tx) // same hash again — must be a no-op
|
|
const { total } = await db.get('SELECT COUNT(*) as total FROM transfers')
|
|
assert.equal(total, 1)
|
|
await db.close()
|
|
})
|
|
|
|
test('indexBlock persists only transfer extrinsics from a block', async () => {
|
|
const db = await initDb(':memory:')
|
|
const signedBlock = {
|
|
block: {
|
|
extrinsics: [
|
|
mkEx({ section: 'balances', method: 'transfer', args: [scalar('5R'), scalar('1')], hash: '0x1', signer: '5A' }),
|
|
mkEx({ section: 'system', method: 'remark', args: [scalar('x')], hash: '0x2', signer: '5A' }),
|
|
mkEx({ section: 'assets', method: 'transfer', args: [numeric(1000), scalar('5R'), scalar('2')], hash: '0x3', signer: '5B' })
|
|
]
|
|
}
|
|
}
|
|
await indexBlock(db, signedBlock, 99)
|
|
const rows = await db.all('SELECT hash, symbol FROM transfers ORDER BY hash')
|
|
assert.equal(rows.length, 2)
|
|
assert.deepEqual(rows.map(r => r.symbol).sort(), ['HEZ', 'USDT'])
|
|
await db.close()
|
|
})
|
|
|
|
// --- HTTP surface -----------------------------------------------------------
|
|
|
|
test('GET /health returns 200 ok', async () => {
|
|
const db = await initDb(':memory:')
|
|
const app = createApp(db)
|
|
const res = await request(app).get('/health')
|
|
assert.equal(res.statusCode, 200)
|
|
assert.equal(res.body.status, 'ok')
|
|
await db.close()
|
|
})
|
|
|
|
test('GET /api/stats reflects indexed count', async () => {
|
|
const db = await initDb(':memory:')
|
|
await saveTransfer(db, { hash: '0xs1', sender: 'a', receiver: 'b', amount: '1', asset_id: null, symbol: 'HEZ', block_number: 1 })
|
|
const app = createApp(db)
|
|
const res = await request(app).get('/api/stats')
|
|
assert.equal(res.statusCode, 200)
|
|
assert.equal(res.body.total, 1)
|
|
await db.close()
|
|
})
|
|
|
|
test('GET /api/history/:address filters by sender or receiver', async () => {
|
|
const db = await initDb(':memory:')
|
|
await saveTransfer(db, { hash: '0xh1', sender: 'alice', receiver: 'bob', amount: '1', asset_id: null, symbol: 'HEZ', block_number: 2 })
|
|
await saveTransfer(db, { hash: '0xh2', sender: 'carol', receiver: 'dave', amount: '1', asset_id: null, symbol: 'HEZ', block_number: 3 })
|
|
await saveTransfer(db, { hash: '0xh3', sender: 'eve', receiver: 'alice', amount: '1', asset_id: null, symbol: 'HEZ', block_number: 4 })
|
|
const app = createApp(db)
|
|
const res = await request(app).get('/api/history/alice')
|
|
assert.equal(res.statusCode, 200)
|
|
assert.equal(res.body.length, 2) // sent one, received one
|
|
const hashes = res.body.map(r => r.hash).sort()
|
|
assert.deepEqual(hashes, ['0xh1', '0xh3'])
|
|
await db.close()
|
|
})
|