Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ jobs:
REDIS_URL: redis://localhost:6379
STELLAR_NETWORK: testnet
STELLAR_HORIZON_URL: https://horizon-testnet.stellar.org
STELLAR_FRIENDBOT_URL: https://friendbot.stellar.org

steps:
- uses: actions/checkout@v7
Expand All @@ -134,9 +135,9 @@ jobs:
working-directory: backend
run: npm run db:migrate

- name: Run tests (unit + integration)
- name: Run tests with coverage (unit + integration + testnet)
working-directory: backend
run: npm test -- --runInBand
run: npm run test:coverage -- --runInBand

test-frontend:
name: Frontend tests
Expand All @@ -159,9 +160,9 @@ jobs:
# Jest's default 5s budget when workers compete, failing on timing
# rather than on any assertion. Running in band makes the gate
# deterministic — and stricter about shared state, not looser.
- name: Run tests
- name: Run tests with coverage
working-directory: frontend
run: npm test -- --runInBand
run: npm run test:coverage -- --runInBand

test-contracts:
name: Governance contract tests
Expand All @@ -176,7 +177,7 @@ jobs:
run: cargo test --workspace

test-governance-e2e:
name: Governance browser flow
name: Governance and browser flows E2E
runs-on: ubuntu-latest
timeout-minutes: 30
if: github.event_name == 'pull_request'
Expand All @@ -203,7 +204,7 @@ jobs:
exec -T -e DATABASE_URL=postgresql://cooplumen:cooplumen@db:5432/cooplumen
backend npm run db:migrate

- name: Run governance browser test
- name: Run E2E browser tests (governance, transactions, mobile)
working-directory: frontend
env:
AUTH_SESSION_SECRET: governance-e2e-secret
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Added

- Stellar testnet live Friendbot integration test covering account funding, loading, custom asset trustline creation, issuance, and payment error handling (`backend/src/contracts/__tests__/stellar.testnet.test.ts`) (#403, #325).
- Playwright E2E browser test for viewing community transaction history with query filtering and empty states against the running stack (`frontend/e2e/transactions.spec.ts`) (#397, #319).
- Playwright E2E browser test for responsive mobile layout on a 375px viewport checking navigation drawer toggle and layout bounds (`frontend/e2e/responsive-mobile.spec.ts`) (#398, #320).
- CI test coverage threshold enforcement failing builds below 70% backend and 60% frontend coverage in Jest and GitHub Actions workflow (#409, #331).

- Double-submit cookie CSRF protection (`csrfProtection` middleware) guarding state-changing routes under `/api/v1`, with `GET /api/v1/auth/csrf` endpoint, `cooplumen_csrf` cookie management, and OpenAPI documentation (#359, #281).
- Community write endpoints (`POST /api/v1/communities`, `PUT /:id`, `DELETE /:id`, `POST /:id/members`, `PUT /:id/members/:address`, `DELETE /:id/members/:address`, `POST /:id/avatar`) guarded with `authenticateJWT` (#356, #278).
- Community member removal (`DELETE /api/v1/communities/:id/members/:address`) guarded with `requireRole(['admin'])` (#358, #280).
Expand Down
8 changes: 8 additions & 0 deletions backend/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ module.exports = {
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts', '!src/index.ts', '!src/db/migrate.ts'],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
coverageThreshold: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70,
},
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
Expand Down
2 changes: 0 additions & 2 deletions backend/src/api/routes/__tests__/proposals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,6 @@ describe('governance proposal routes', () => {

it('rejects a caller who is not an active community member', async () => {
mockDb.query.mockResolvedValueOnce([{ exists: false }]);
query.mockResolvedValueOnce([{ exists: false }]);

const response = await request(app)
.post('/api/v1/proposals')
Expand All @@ -160,7 +159,6 @@ describe('governance proposal routes', () => {
});

it('creates an active proposal for an authenticated member', async () => {
mockDb.query
query
.mockResolvedValueOnce([{ exists: true }])
.mockResolvedValueOnce([{ id: proposalId }])
Expand Down
1 change: 0 additions & 1 deletion backend/src/api/routes/kyc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { db } from '../../db';
import { requireAuth } from '../middleware/auth';
import { validateBody, validateParams } from '../middleware/validate';
import { kycStatusParamsSchema, initiateKycSchema, InitiateKycInput } from '../schemas/kyc';
import { initiateKycSchema, InitiateKycInput, kycStatusParamsSchema } from '../schemas/kyc';

export const kycRouter = Router();

Expand Down
16 changes: 2 additions & 14 deletions backend/src/api/routes/proposals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,11 @@ import { validateBody, validateParams, validateQuery } from '../middleware/valid
import {
castVoteSchema,
createProposalSchema,
CreateProposalInput,
proposalIdParamsSchema,
listProposalsQuerySchema,
delegateProposalSchema,
DelegateProposalInput,
castVoteSchema,
createProposalSchema,
CreateProposalInput,
listProposalsQuerySchema,
proposalIdParamsSchema,
} from '../schemas/proposal';

export const proposalRouter: Router = Router();
Expand Down Expand Up @@ -208,14 +204,6 @@ proposalRouter.post(
}
);

const proposalSelect = `
SELECT p.*,
COALESCE(SUM(v.weight) FILTER (WHERE v.choice = 'for'), 0)::text AS votes_for,
COALESCE(SUM(v.weight) FILTER (WHERE v.choice = 'against'), 0)::text AS votes_against,
COALESCE(SUM(v.weight) FILTER (WHERE v.choice = 'abstain'), 0)::text AS votes_abstain
FROM proposals p
LEFT JOIN votes v ON v.proposal_id = p.id`;

proposalRouter.get(
'/',
validateQuery(listProposalsQuerySchema),
Expand Down Expand Up @@ -277,7 +265,7 @@ proposalRouter.post(
if (!(await isActiveMember(communityId, req.auth!.address))) {
res.status(403).json({
data: null,
error: 'Only active community members can create proposals',
error: 'Active community membership required',
});
return;
}
Expand Down
27 changes: 0 additions & 27 deletions backend/src/api/schemas/proposal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,6 @@ export const createProposalSchema = z.object({

export type CreateProposalInput = z.infer<typeof createProposalSchema>;

export const proposalIdParamsSchema = z.object({
id: z.string().uuid({ message: 'Proposal ID must be a valid UUID' }),
});

export const castVoteSchema = z.object({
choice: z.enum(['for', 'against', 'abstain']),
reason: z.string().trim().max(2_000).optional(),
Expand All @@ -59,26 +55,3 @@ export const listProposalsQuerySchema = z.object({
communityId: z.string().uuid({ message: 'communityId must be a valid UUID' }),
status: proposalStatus.optional(),
});

export const createProposalSchema = z.object({
communityId: z.string().uuid({ message: 'communityId must be a valid UUID' }),
title: z.string().trim().min(1, { message: 'title is required' }).max(200),
description: z.string().trim().max(10_000).optional(),
type: proposalType.default('general'),
quorumPercent: z.coerce.number().min(0).max(100).default(50),
metadata: z.record(z.string(), z.unknown()).optional(),
votingDurationHours: z.coerce
.number()
.int()
.min(1)
.max(24 * 30)
.optional(),
votingEndsAt: z.coerce
.date({ error: 'votingEndsAt must be a valid date' })
.refine((value) => value.getTime() > Date.now(), {
message: 'votingEndsAt must be in the future',
})
.optional(),
});

export type CreateProposalInput = z.infer<typeof createProposalSchema>;
37 changes: 33 additions & 4 deletions backend/src/contracts/__tests__/stellar.testnet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,18 @@
* Exercises end-to-end flows for keypair funding, account creation, trustlines,
* asset issuance, payments, balance verification, and error handling.
*
* Skipped unless STELLAR_TESTNET_E2E=1 or SKIP_TESTNET_TESTS is false/unset
* and testnet is reachable.
* Runs when STELLAR_TESTNET_E2E=1 or in CI (unless SKIP_TESTNET_TESTS=true).
*/

import { Keypair } from '@stellar/stellar-sdk';
import { StellarService } from '../stellar';
import { StellarService, UnfundedAccountError } from '../stellar';
import { getAssetBalance, issueAsset } from '../assets';
import { establishTrustline } from '../trustlines';
import { submitPayment } from '../transactions';

const RUN = process.env.STELLAR_TESTNET_E2E === '1';
const RUN =
process.env.STELLAR_TESTNET_E2E === '1' ||
(process.env.CI !== undefined && process.env.SKIP_TESTNET_TESTS !== 'true');
const describeIf = RUN ? describe : describe.skip;

const FRIENDBOT_URL = process.env.STELLAR_FRIENDBOT_URL ?? 'https://friendbot.stellar.org';
Expand Down Expand Up @@ -46,6 +47,21 @@ describeIf('Stellar testnet integration with Friendbot-funded accounts', () => {

expect(issuerAccount.account_id).toBe(issuer.publicKey());
expect(holderAccount.account_id).toBe(holder.publicKey());
expect(issuerAccount.balances.length).toBeGreaterThanOrEqual(1);
expect(holderAccount.balances.length).toBeGreaterThanOrEqual(1);
});

it('correctly reports unfunded account error for freshly generated keypair', async () => {
const unfunded = Keypair.random();
await expect(StellarService.loadAccountSafe(unfunded.publicKey())).rejects.toThrow(
UnfundedAccountError
);
});

it('rejects an invalid public key string', async () => {
await expect(
StellarService.loadAccountSafe('not-a-valid-stellar-public-key')
).rejects.toThrow();
});

it('establishes trustline for holder to accept custom asset', async () => {
Expand Down Expand Up @@ -90,4 +106,17 @@ describeIf('Stellar testnet integration with Friendbot-funded accounts', () => {

expect(txHash).toMatch(/^[a-f0-9]{64}$/);
});

it('fails payment when sender attempts to transfer an excessive amount', async () => {
await expect(
submitPayment({
senderSecret: issuer.secret(),
destinationPublicKey: holder.publicKey(),
assetCode: 'XLM',
assetIssuer: '',
amount: '999999999.0000000',
memo: { type: 'text', value: 'excessive payment' },
})
).rejects.toThrow();
});
});
15 changes: 5 additions & 10 deletions contracts/governance/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,7 @@ impl GovernanceContract {
// Failures are surfaced as individual TOPIC_EXECUTION_FAILED events;
// they do not revert the Executed status (preventing indefinite retry
// loops on a permanently-failing action).
#[allow(deprecated)]
for action in proposal.actions.iter() {
// Validate the action string is non-empty before recording it.
if action.is_empty() {
Expand Down Expand Up @@ -683,16 +684,10 @@ impl GovernanceContract {
.ok_or(GovernanceError::Overflow)?;
}

let voter_turnout_bps = if total_proposals == 0 {
0
} else {
(turnout_bps_total / total_proposals) as u32
};
let average_quorum_bps = if finalized_proposals == 0 {
0
} else {
(finalized_quorum_bps_total / finalized_proposals) as u32
};
let voter_turnout_bps = turnout_bps_total.checked_div(total_proposals).unwrap_or(0) as u32;
let average_quorum_bps = finalized_quorum_bps_total
.checked_div(finalized_proposals)
.unwrap_or(0) as u32;
let proposal_pass_rate_bps = if finalized_proposals == 0 {
0
} else {
Expand Down
45 changes: 0 additions & 45 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -198,50 +198,6 @@ paths:
'400': { $ref: '#/components/responses/ValidationError' }
'404': { $ref: '#/components/responses/NotFound' }

/api/v1/proposals/{id}/delegate:
post:
tags: [Governance]
summary: Delegate voting power for a proposal
description: Delegates the caller's voting power on an active proposal to another community member.
security:
- SessionToken: []
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ProposalDelegationRequest'
responses:
'200':
description: Delegation stored
content:
application/json:
schema:
$ref: '#/components/schemas/ProposalDelegationResponse'
'400': { $ref: '#/components/responses/ValidationError' }
'401':
description: Authentication required
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'403':
description: Delegator or delegatee is not an active community member
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'404':
$ref: '#/components/responses/NotFound'
'409':
description: Proposal state, voting history, or delegation topology conflicts with the request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }

/api/v1/kyc/initiate:
post:
tags: [KYC]
Expand All @@ -255,7 +211,6 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/InitiateKycRequest'
schema: { $ref: '#/components/schemas/InitiateKycRequest' }
responses:
'200':
description: An existing pending flow was returned
Expand Down
Loading
Loading