Files
pwap/backend/test/council.test.js
T
pezkuwichain 80d273ff11 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.
2026-07-25 11:01:47 -07:00

229 lines
8.3 KiB
JavaScript

// 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)
})