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
37 changes: 32 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ jobs:

# --- Backend Jobs ---
backend-test:
name: Backend Tests
name: Backend Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -101,10 +101,38 @@ jobs:
working-directory: app/backend
run: npm ci

- name: Run tests
- name: Run unit tests
working-directory: app/backend
run: npm test

backend-e2e:
name: Backend E2E Tests (Module Organization)
runs-on: ubuntu-latest
needs: [backend-test]
steps:
- uses: actions/checkout@v4

- name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: app/backend/package-lock.json

- name: Install dependencies
working-directory: app/backend
run: npm ci

- name: Run e2e tests
working-directory: app/backend
run: npm run test:e2e

- name: Upload e2e test results
uses: actions/upload-artifact@v4
if: always()
with:
name: e2e-test-results
path: app/backend/test-results/
- name: Check OpenAPI schema drift
working-directory: app/backend
run: npm run openapi:check
Expand Down Expand Up @@ -228,7 +256,7 @@ jobs:
# --- Deployment & Verification (#348, #351) ---
deploy-and-verify:
name: Deploy & Verify Contracts
needs: [contract-lint, contract-test, backend-test, security-audit]
needs: [contract-lint, contract-test, backend-test, backend-e2e, security-audit]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
Expand All @@ -254,5 +282,4 @@ jobs:
run: bash scripts/verify_contracts.sh
continue-on-error: true
env:
SOROBAN_NETWORK: testnet

SOROBAN_NETWORK: testnet
106 changes: 87 additions & 19 deletions app/backend/test/app.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
import { configureAppSecurity } from './../src/security/app-security';

describe('AppController (e2e)', () => {
describe('Module Organization - NestJS E2E', () => {
let app: INestApplication<App>;

beforeEach(async () => {
Expand All @@ -19,29 +19,97 @@ describe('AppController (e2e)', () => {
});

afterEach(async () => {
await app.close();
if (app) {
await app.close();
}
});

it('/ (GET)', () => {
return request(app.getHttpServer()).get('/').expect(200).expect('Hello World!');
/**
* Helper: extract all registered GET routes from the Express router.
*/
function discoverGetRoutes(): string[] {
const adapter = app.getHttpAdapter();
const instance = adapter.getInstance();
// Express lazily creates `._router` on first use; `.router` (getter)
// returns the same internal Router instance.
const router = instance.router as { stack: any[] };
const getRoutes: string[] = [];

for (const layer of router.stack) {
if (layer.route && layer.route.methods?.get) {
getRoutes.push(layer.route.path);
}
}

return getRoutes;
}

// ------------------------------------------------------------------
// Root route
// ------------------------------------------------------------------
describe('AppController root route', () => {
it('/ (GET) should return 200 with Hello World!', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});

it('rejects cross-origin state-changing cookie requests without matching CSRF header', async () => {
await request(app.getHttpServer())
.post('/')
.set('Origin', 'http://localhost:3001')
.set('Cookie', ['access_token=session-cookie; csrf_token=trusted-token'])
.send({ state: 'change' })
.expect(403);
// ------------------------------------------------------------------
// Module-organization: verify every registered controller route resolves
// ------------------------------------------------------------------
describe('All registered controller routes resolve', () => {
it('should discover at least one GET route from registered controllers', () => {
const routes = discoverGetRoutes();
expect(routes.length).toBeGreaterThan(0);
});

it('the root route should be among registered GET routes', () => {
const routes = discoverGetRoutes();
expect(routes).toContain('/');
});

it('every registered GET route resolves to a controller (no 500)', async () => {
const getRoutes = discoverGetRoutes();

for (const routePath of getRoutes) {
const response = await request(app.getHttpServer()).get(routePath);
// A properly resolved route returns:
// - 200/201: successful response (no auth required)
// - 302: redirect
// - 401/403: authentication/authorization required
// - 404: valid route but resource not found
// A 500 would indicate a crash — fail the test.
expect(response.status).not.toBe(500);
expect([200, 201, 302, 401, 403, 404]).toContain(response.status);
}
});
});

it('allows state-changing cookie requests when the CSRF cookie and header match', async () => {
await request(app.getHttpServer())
.post('/')
.set('Origin', 'http://localhost:3000')
.set('Cookie', ['access_token=session-cookie; csrf_token=trusted-token'])
.set('X-CSRF-Token', 'trusted-token')
.send({ state: 'change' })
.expect(404);
// ------------------------------------------------------------------
// CSRF security middleware (preserved from original tests)
// ------------------------------------------------------------------
describe('CSRF security middleware', () => {
it('rejects cross-origin state-changing cookie requests without matching CSRF header', async () => {
await request(app.getHttpServer())
.post('/')
.set('Origin', 'http://localhost:3001')
.set('Cookie', ['access_token=session-cookie; csrf_token=trusted-token'])
.send({ state: 'change' })
.expect(403);
});

it('allows state-changing cookie requests when the CSRF cookie and header match', async () => {
await request(app.getHttpServer())
.post('/')
.set('Origin', 'http://localhost:3000')
.set('Cookie', [
'access_token=session-cookie; csrf_token=trusted-token',
])
.set('X-CSRF-Token', 'trusted-token')
.send({ state: 'change' })
.expect(404);
});
});
});
Loading