mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-08-03 15:05:46 +00:00
ci/cd(backend): offline test suite + Dockerize + audit-grade deploy (b + c)
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.
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
// Offline council / KYC tests — real signature crypto, mocked chain + Supabase.
|
||||
// Run: node --test
|
||||
//
|
||||
// NODE_ENV must NOT be 'test' here: the handlers skip signature verification
|
||||
// when NODE_ENV === 'test', and we specifically want to exercise the real
|
||||
// @pezkuwi/util-crypto signature gate (reject unsigned/invalid, accept valid).
|
||||
|
||||
process.env.NODE_ENV = 'ci-offline'
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import request from 'supertest'
|
||||
import { Keyring } from '@pezkuwi/keyring'
|
||||
import { cryptoWaitReady } from '@pezkuwi/util-crypto'
|
||||
import { u8aToHex } from '@pezkuwi/util'
|
||||
import { createApp } from '../src/council.js'
|
||||
import { makeFakeSupabase } from './helpers/fake-supabase.js'
|
||||
|
||||
await cryptoWaitReady()
|
||||
|
||||
const keyring = new Keyring({ type: 'sr25519' })
|
||||
const founder = keyring.addFromUri('//Founder')
|
||||
const proposer = keyring.addFromUri('//Proposer')
|
||||
const stranger = keyring.addFromUri('//Stranger')
|
||||
const user = keyring.addFromUri('//KycUser')
|
||||
|
||||
const sign = (pair, message) => u8aToHex(pair.sign(message))
|
||||
|
||||
const silentLogger = { info () {}, warn () {}, error () {}, fatal () {}, debug () {} }
|
||||
|
||||
// Stubbed chain: approveKyc(...).signAndSend(sudo, cb) drives the callback with
|
||||
// a finalized, successful event so the "mark executed" branch runs — no network.
|
||||
function makeMockApi () {
|
||||
const calls = []
|
||||
const api = {
|
||||
tx: {
|
||||
identityKyc: {
|
||||
approveKyc: (addr) => ({
|
||||
async signAndSend (sudo, cb) {
|
||||
calls.push(addr)
|
||||
await cb({
|
||||
status: { isFinalized: true },
|
||||
dispatchError: undefined,
|
||||
events: [{ event: { __kycApproved: true } }]
|
||||
})
|
||||
return () => {}
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
events: {
|
||||
identityKyc: {
|
||||
KycApproved: { is: (ev) => !!(ev && ev.__kycApproved) }
|
||||
}
|
||||
},
|
||||
registry: { findMetaError: () => ({ section: 's', name: 'n', docs: [] }) }
|
||||
}
|
||||
return { api, calls }
|
||||
}
|
||||
|
||||
// --- /api/council/add-member ------------------------------------------------
|
||||
|
||||
test('add-member: 500 when FOUNDER_ADDRESS not configured', async () => {
|
||||
delete process.env.FOUNDER_ADDRESS
|
||||
const app = createApp({ supabase: makeFakeSupabase(), logger: silentLogger })
|
||||
const res = await request(app).post('/api/council/add-member').send({
|
||||
newMemberAddress: stranger.address,
|
||||
signature: '0x00',
|
||||
message: `addCouncilMember:${stranger.address}`
|
||||
})
|
||||
assert.equal(res.statusCode, 500)
|
||||
assert.equal(res.body.error.key, 'errors.server.founder_not_configured')
|
||||
})
|
||||
|
||||
test('add-member: 401 on invalid signature', async () => {
|
||||
process.env.FOUNDER_ADDRESS = founder.address
|
||||
const app = createApp({ supabase: makeFakeSupabase(), logger: silentLogger })
|
||||
const message = `addCouncilMember:${stranger.address}`
|
||||
const res = await request(app).post('/api/council/add-member').send({
|
||||
newMemberAddress: stranger.address,
|
||||
signature: sign(stranger, message), // well-formed, but NOT the founder's key
|
||||
message
|
||||
})
|
||||
assert.equal(res.statusCode, 401)
|
||||
assert.equal(res.body.error.key, 'errors.auth.invalid_signature')
|
||||
})
|
||||
|
||||
test('add-member: 400 when signed message does not match the action', async () => {
|
||||
process.env.FOUNDER_ADDRESS = founder.address
|
||||
const message = 'addCouncilMember:5SomeoneElse'
|
||||
const app = createApp({ supabase: makeFakeSupabase(), logger: silentLogger })
|
||||
const res = await request(app).post('/api/council/add-member').send({
|
||||
newMemberAddress: stranger.address,
|
||||
signature: sign(founder, message),
|
||||
message
|
||||
})
|
||||
assert.equal(res.statusCode, 400)
|
||||
assert.equal(res.body.error.key, 'errors.request.message_mismatch')
|
||||
})
|
||||
|
||||
test('add-member: 200 on valid founder signature', async () => {
|
||||
process.env.FOUNDER_ADDRESS = founder.address
|
||||
const message = `addCouncilMember:${stranger.address}`
|
||||
const supabase = makeFakeSupabase()
|
||||
const app = createApp({ supabase, logger: silentLogger })
|
||||
const res = await request(app).post('/api/council/add-member').send({
|
||||
newMemberAddress: stranger.address,
|
||||
signature: sign(founder, message),
|
||||
message
|
||||
})
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.equal(res.body.success, true)
|
||||
assert.equal(supabase._tables.council_members.length, 1)
|
||||
})
|
||||
|
||||
test('add-member: 409 when member already exists', async () => {
|
||||
process.env.FOUNDER_ADDRESS = founder.address
|
||||
const message = `addCouncilMember:${stranger.address}`
|
||||
const supabase = makeFakeSupabase({ council_members: [{ address: stranger.address }] })
|
||||
const app = createApp({ supabase, logger: silentLogger })
|
||||
const res = await request(app).post('/api/council/add-member').send({
|
||||
newMemberAddress: stranger.address,
|
||||
signature: sign(founder, message),
|
||||
message
|
||||
})
|
||||
assert.equal(res.statusCode, 409)
|
||||
assert.equal(res.body.error.key, 'errors.council.member_exists')
|
||||
})
|
||||
|
||||
// --- /api/kyc/propose -------------------------------------------------------
|
||||
|
||||
test('propose: 401 on invalid proposer signature', async () => {
|
||||
const app = createApp({ supabase: makeFakeSupabase(), logger: silentLogger })
|
||||
const message = `proposeKYC:${user.address}`
|
||||
const res = await request(app).post('/api/kyc/propose').send({
|
||||
userAddress: user.address,
|
||||
proposerAddress: proposer.address,
|
||||
signature: sign(stranger, message), // signed by a different key than proposerAddress
|
||||
message
|
||||
})
|
||||
assert.equal(res.statusCode, 401)
|
||||
})
|
||||
|
||||
test('propose: 403 when proposer is not a council member', async () => {
|
||||
const message = `proposeKYC:${user.address}`
|
||||
const app = createApp({ supabase: makeFakeSupabase(), logger: silentLogger })
|
||||
const res = await request(app).post('/api/kyc/propose').send({
|
||||
userAddress: user.address,
|
||||
proposerAddress: proposer.address, // not seeded into council_members
|
||||
signature: sign(proposer, message),
|
||||
message
|
||||
})
|
||||
assert.equal(res.statusCode, 403)
|
||||
assert.equal(res.body.error.key, 'errors.auth.proposer_not_member')
|
||||
})
|
||||
|
||||
test('propose: 201 and auto-executes when threshold reached (chain mocked)', async () => {
|
||||
const message = `proposeKYC:${user.address}`
|
||||
const supabase = makeFakeSupabase({ council_members: [{ address: proposer.address }] })
|
||||
const { api, calls } = makeMockApi()
|
||||
const app = createApp({
|
||||
supabase,
|
||||
getApi: () => api,
|
||||
getSudo: () => ({ address: founder.address }), // stub signer
|
||||
logger: silentLogger
|
||||
})
|
||||
|
||||
const res = await request(app).post('/api/kyc/propose').send({
|
||||
userAddress: user.address,
|
||||
proposerAddress: proposer.address,
|
||||
signature: sign(proposer, message),
|
||||
message
|
||||
})
|
||||
|
||||
assert.equal(res.statusCode, 201)
|
||||
assert.equal(res.body.success, true)
|
||||
// Single-member council → 60% threshold (ceil(1*0.6)=1) met by the auto-aye,
|
||||
// so approveKyc was signed+sent for the user and the proposal marked executed.
|
||||
assert.deepEqual(calls, [user.address])
|
||||
const proposal = supabase._tables.kyc_proposals[0]
|
||||
assert.equal(proposal.executed, true)
|
||||
})
|
||||
|
||||
test('propose: does NOT execute below threshold (multi-member council)', async () => {
|
||||
const message = `proposeKYC:${user.address}`
|
||||
const supabase = makeFakeSupabase({
|
||||
council_members: [
|
||||
{ address: proposer.address },
|
||||
{ address: founder.address },
|
||||
{ address: stranger.address }
|
||||
]
|
||||
})
|
||||
const { api, calls } = makeMockApi()
|
||||
const app = createApp({
|
||||
supabase,
|
||||
getApi: () => api,
|
||||
getSudo: () => ({ address: founder.address }),
|
||||
logger: silentLogger
|
||||
})
|
||||
|
||||
const res = await request(app).post('/api/kyc/propose').send({
|
||||
userAddress: user.address,
|
||||
proposerAddress: proposer.address,
|
||||
signature: sign(proposer, message),
|
||||
message
|
||||
})
|
||||
|
||||
assert.equal(res.statusCode, 201)
|
||||
// 3 members → required = ceil(3*0.6)=2, only 1 aye → no on-chain execution.
|
||||
assert.deepEqual(calls, [])
|
||||
assert.equal(supabase._tables.kyc_proposals[0].executed, false)
|
||||
})
|
||||
|
||||
// --- /api/kyc/pending -------------------------------------------------------
|
||||
|
||||
test('GET /api/kyc/pending lists non-executed proposals', async () => {
|
||||
const supabase = makeFakeSupabase({
|
||||
kyc_proposals: [
|
||||
{ user_address: user.address, proposer_address: proposer.address, executed: false },
|
||||
{ user_address: stranger.address, proposer_address: proposer.address, executed: true }
|
||||
]
|
||||
})
|
||||
const app = createApp({ supabase, logger: silentLogger })
|
||||
const res = await request(app).get('/api/kyc/pending')
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.equal(res.body.pending.length, 1)
|
||||
assert.equal(res.body.pending[0].user_address, user.address)
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
// Minimal in-memory Supabase stand-in.
|
||||
//
|
||||
// Implements exactly the query-builder surface the council/KYC handlers use:
|
||||
// .from(t).insert(v)
|
||||
// .from(t).select(cols).eq(c,v).single()
|
||||
// .from(t).select('*', { count:'exact', head:true }).eq(...).eq(...)
|
||||
// .from(t).update(v).eq(c,v)
|
||||
// .from(t).select(cols).eq(c,v) (list)
|
||||
// Unique-violation (23505) is simulated on council_members.address and
|
||||
// kyc_proposals.user_address so the 409 paths are covered. Everything lives in
|
||||
// plain arrays — no network, no real DB.
|
||||
|
||||
export function makeFakeSupabase (initial = {}) {
|
||||
const tables = {
|
||||
council_members: [],
|
||||
kyc_proposals: [],
|
||||
votes: [],
|
||||
...structuredCloneSafe(initial)
|
||||
}
|
||||
|
||||
const UNIQUE = {
|
||||
council_members: 'address',
|
||||
kyc_proposals: 'user_address'
|
||||
}
|
||||
|
||||
function from (name) {
|
||||
const rows = tables[name] || (tables[name] = [])
|
||||
const builder = {
|
||||
_filters: [],
|
||||
_count: null,
|
||||
_head: false,
|
||||
_update: null,
|
||||
|
||||
insert (vals) {
|
||||
const arr = Array.isArray(vals) ? vals : [vals]
|
||||
for (const v of arr) {
|
||||
const uniqueCol = UNIQUE[name]
|
||||
if (uniqueCol && rows.some(r => r[uniqueCol] === v[uniqueCol])) {
|
||||
return Promise.resolve({ error: { code: '23505' }, data: null })
|
||||
}
|
||||
rows.push({ id: rows.length + 1, executed: false, created_at: new Date().toISOString(), ...v })
|
||||
}
|
||||
return Promise.resolve({ error: null, data: null })
|
||||
},
|
||||
|
||||
select (cols, opts) {
|
||||
if (opts) {
|
||||
this._count = opts.count || null
|
||||
this._head = !!opts.head
|
||||
}
|
||||
return this
|
||||
},
|
||||
|
||||
eq (col, val) {
|
||||
this._filters.push([col, val])
|
||||
return this
|
||||
},
|
||||
|
||||
neq () { return this },
|
||||
|
||||
update (vals) {
|
||||
this._update = vals
|
||||
return this
|
||||
},
|
||||
|
||||
_apply () {
|
||||
return rows.filter(r => this._filters.every(([c, v]) => r[c] === v))
|
||||
},
|
||||
|
||||
single () {
|
||||
const found = this._apply()
|
||||
if (found.length === 0) {
|
||||
return Promise.resolve({ data: null, error: { code: 'PGRST116', message: 'no rows' } })
|
||||
}
|
||||
return Promise.resolve({ data: found[0], error: null })
|
||||
},
|
||||
|
||||
// Thenable terminal for chains awaited without .single()
|
||||
then (resolve, reject) {
|
||||
try {
|
||||
const found = this._apply()
|
||||
if (this._update) {
|
||||
found.forEach(r => Object.assign(r, this._update))
|
||||
return resolve({ error: null, data: found })
|
||||
}
|
||||
if (this._count) {
|
||||
return resolve({ count: found.length, error: null, data: this._head ? null : found })
|
||||
}
|
||||
return resolve({ data: found, error: null })
|
||||
} catch (err) {
|
||||
return reject(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
return { from, _tables: tables }
|
||||
}
|
||||
|
||||
function structuredCloneSafe (obj) {
|
||||
const out = {}
|
||||
for (const [k, v] of Object.entries(obj)) out[k] = v.map(r => ({ ...r }))
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// 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()
|
||||
})
|
||||
Reference in New Issue
Block a user