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
50 changes: 50 additions & 0 deletions .github/workflows/ai-agent-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: AI Agent CI

on:
push:
paths:
- 'apps/ai_agent/**'
- '.github/workflows/ai-agent-ci.yml'
pull_request:
paths:
- 'apps/ai_agent/**'
- '.github/workflows/ai-agent-ci.yml'

jobs:
test:
name: Test · Coverage
runs-on: ubuntu-latest

defaults:
run:
working-directory: apps/ai_agent

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: 'apps/ai_agent/uv.lock'

- name: Install dependencies
run: uv sync --group dev

- name: Run tests with coverage
run: uv run pytest --cov=main --cov-report=xml --cov-report=term-missing

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
continue-on-error: true
with:
files: apps/ai_agent/coverage.xml
flags: ai-agent

- name: Post coverage summary
if: always()
run: |
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
uv run coverage report --format=markdown >> $GITHUB_STEP_SUMMARY 2>/dev/null || \
echo "Coverage data not available." >> $GITHUB_STEP_SUMMARY
1 change: 1 addition & 0 deletions apps/ai_agent/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ dependencies = [
[dependency-groups]
dev = [
"pytest>=8.0.0",
"pytest-cov>=5.0.0",
"httpx>=0.27.0",
]

Expand Down
535 changes: 535 additions & 0 deletions apps/ai_agent/uv.lock

Large diffs are not rendered by default.

167 changes: 129 additions & 38 deletions apps/backend/src/__tests__/auth.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ vi.mock('../lib/nonce.js', () => ({
consumeNonce: mockConsumeNonce,
}));

const mockFindFirst = vi.fn();
const mockWalletFindFirst = vi.fn();
const mockDeviceFindFirst = vi.fn();
const mockInsert = vi.fn();

vi.mock('../db/index.js', () => ({
db: {
query: {
wallets: { findFirst: mockFindFirst },
wallets: { findFirst: mockWalletFindFirst },
devices: { findFirst: mockDeviceFindFirst },
},
insert: mockInsert,
execute: vi.fn().mockResolvedValue([]),
Expand Down Expand Up @@ -46,12 +48,27 @@ function resetRateLimiters() {
const WALLET = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ012345678901234567890123456789AB';
const SIGNATURE = 'aabbccdd';
const NONCE = 'test-nonce-abc123';
const IDENTITY_KEY = 'dGVzdC1pZGVudGl0eS1wdWJsaWMta2V5'; // base64 placeholder

function setupInsert(userId = 'new-user-id', deviceId = 'new-device-id') {
// mockInsert is called twice when creating a new user: once for users, once for devices.
// For existing users it's called once for devices.
const userReturning = vi.fn().mockResolvedValue([{ id: userId }]);
const deviceReturning = vi.fn().mockResolvedValue([{ id: deviceId }]);
const userValues = vi.fn().mockReturnValue({ returning: userReturning });
const deviceValues = vi.fn().mockReturnValue({ returning: deviceReturning });
mockInsert
.mockReturnValueOnce({ values: userValues })
.mockReturnValueOnce({ values: deviceValues });
return { userReturning, deviceReturning };
}

function setupInsert(userId = 'new-user-id') {
const returningFn = vi.fn().mockResolvedValue([{ id: userId }]);
const valuesFn = vi.fn().mockReturnValue({ returning: returningFn });
mockInsert.mockReturnValue({ values: valuesFn });
return { returningFn, valuesFn };
function setupExistingUserInsert(deviceId = 'device-id') {
// Only the device insert is called for an existing wallet.
const deviceReturning = vi.fn().mockResolvedValue([{ id: deviceId }]);
const deviceValues = vi.fn().mockReturnValue({ returning: deviceReturning });
mockInsert.mockReturnValue({ values: deviceValues });
return { deviceReturning };
}

// ── Tests ─────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -100,27 +117,53 @@ describe('POST /auth/verify', () => {
it('returns 200 with JWT token for valid new-user flow', async () => {
mockConsumeNonce.mockReturnValue(true);
mockVerify.mockReturnValue(true);
mockFindFirst.mockResolvedValue(undefined); // no existing wallet → create user
mockWalletFindFirst.mockResolvedValue(undefined); // no existing wallet → create user
mockDeviceFindFirst.mockResolvedValue(undefined); // no existing device → create device
setupInsert();

const res = await request(app)
.post('/auth/verify')
.send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE });
const res = await request(app).post('/auth/verify').send({
walletAddress: WALLET,
signature: SIGNATURE,
nonce: NONCE,
identityPublicKey: IDENTITY_KEY,
});

expect(res.status).toBe(200);
expect(res.body).toHaveProperty('token');
const parts = (res.body.token as string).split('.');
expect(parts).toHaveLength(3); // valid JWT structure
});

it('returns 200 with JWT for existing wallet (returning user)', async () => {
it('returns 200 with JWT for existing wallet and existing device (returning user)', async () => {
mockConsumeNonce.mockReturnValue(true);
mockVerify.mockReturnValue(true);
mockFindFirst.mockResolvedValue({ userId: 'existing-user-id', address: WALLET });
mockWalletFindFirst.mockResolvedValue({ userId: 'existing-user-id', address: WALLET });
mockDeviceFindFirst.mockResolvedValue({ id: 'device-id', isRevoked: false });

const res = await request(app).post('/auth/verify').send({
walletAddress: WALLET,
signature: SIGNATURE,
nonce: NONCE,
identityPublicKey: IDENTITY_KEY,
});

const res = await request(app)
.post('/auth/verify')
.send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('token');
});

it('returns 200 with JWT for existing wallet and new device', async () => {
mockConsumeNonce.mockReturnValue(true);
mockVerify.mockReturnValue(true);
mockWalletFindFirst.mockResolvedValue({ userId: 'existing-user-id', address: WALLET });
mockDeviceFindFirst.mockResolvedValue(undefined); // new device for existing user
setupExistingUserInsert();

const res = await request(app).post('/auth/verify').send({
walletAddress: WALLET,
signature: SIGNATURE,
nonce: NONCE,
identityPublicKey: IDENTITY_KEY,
});

expect(res.status).toBe(200);
expect(res.body).toHaveProperty('token');
Expand All @@ -129,9 +172,12 @@ describe('POST /auth/verify', () => {
it('returns 401 when nonce is expired or invalid', async () => {
mockConsumeNonce.mockReturnValue(false);

const res = await request(app)
.post('/auth/verify')
.send({ walletAddress: WALLET, signature: SIGNATURE, nonce: 'expired-nonce' });
const res = await request(app).post('/auth/verify').send({
walletAddress: WALLET,
signature: SIGNATURE,
nonce: 'expired-nonce',
identityPublicKey: IDENTITY_KEY,
});

expect(res.status).toBe(401);
expect(res.body).toHaveProperty('error');
Expand All @@ -141,14 +187,34 @@ describe('POST /auth/verify', () => {
mockConsumeNonce.mockReturnValue(true);
mockVerify.mockReturnValue(false);

const res = await request(app)
.post('/auth/verify')
.send({ walletAddress: WALLET, signature: 'badsig', nonce: NONCE });
const res = await request(app).post('/auth/verify').send({
walletAddress: WALLET,
signature: 'badsig',
nonce: NONCE,
identityPublicKey: IDENTITY_KEY,
});

expect(res.status).toBe(401);
expect(res.body.error).toMatch(/signature/i);
});

it('returns 401 when device is revoked', async () => {
mockConsumeNonce.mockReturnValue(true);
mockVerify.mockReturnValue(true);
mockWalletFindFirst.mockResolvedValue({ userId: 'existing-user-id', address: WALLET });
mockDeviceFindFirst.mockResolvedValue({ id: 'device-id', isRevoked: true });

const res = await request(app).post('/auth/verify').send({
walletAddress: WALLET,
signature: SIGNATURE,
nonce: NONCE,
identityPublicKey: IDENTITY_KEY,
});

expect(res.status).toBe(401);
expect(res.body.error).toMatch(/revoked/i);
});

it('returns 400 when required fields are missing', async () => {
const res = await request(app).post('/auth/verify').send({ walletAddress: WALLET });

Expand All @@ -163,15 +229,27 @@ describe('POST /auth/verify', () => {
expect(res.body).toHaveProperty('error');
});

it('returns 400 when identityPublicKey is missing', async () => {
const res = await request(app)
.post('/auth/verify')
.send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE });

expect(res.status).toBe(400);
expect(res.body).toHaveProperty('error');
});

it('returns 401 when Stellar Keypair throws (malformed wallet address)', async () => {
mockConsumeNonce.mockReturnValue(true);
mockVerify.mockImplementation(() => {
throw new Error('invalid key');
});

const res = await request(app)
.post('/auth/verify')
.send({ walletAddress: 'INVALID', signature: SIGNATURE, nonce: NONCE });
const res = await request(app).post('/auth/verify').send({
walletAddress: 'INVALID',
signature: SIGNATURE,
nonce: NONCE,
identityPublicKey: IDENTITY_KEY,
});

expect(res.status).toBe(401);
expect(res.body).toHaveProperty('error');
Expand All @@ -184,7 +262,8 @@ describe('Auth rate limiting', () => {
resetRateLimiters();
mockConsumeNonce.mockReturnValue(true);
mockVerify.mockReturnValue(true);
mockFindFirst.mockResolvedValue({ userId: 'existing-user-id', address: WALLET });
mockWalletFindFirst.mockResolvedValue({ userId: 'existing-user-id', address: WALLET });
mockDeviceFindFirst.mockResolvedValue({ id: 'device-id', isRevoked: false });
});

it('allows up to 10 /auth/challenge requests per minute, blocks the 11th with 429', async () => {
Expand All @@ -200,29 +279,41 @@ describe('Auth rate limiting', () => {

it('allows up to 5 /auth/verify requests per minute, blocks the 6th with 429', async () => {
for (let i = 0; i < 5; i++) {
const res = await request(app)
.post('/auth/verify')
.send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE });
const res = await request(app).post('/auth/verify').send({
walletAddress: WALLET,
signature: SIGNATURE,
nonce: NONCE,
identityPublicKey: IDENTITY_KEY,
});
expect(res.status).toBe(200);
}

const blocked = await request(app)
.post('/auth/verify')
.send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE });
const blocked = await request(app).post('/auth/verify').send({
walletAddress: WALLET,
signature: SIGNATURE,
nonce: NONCE,
identityPublicKey: IDENTITY_KEY,
});
expect(blocked.status).toBe(429);
expect(blocked.headers['retry-after']).toBeDefined();
});

it('challenge and verify limiters are independent', async () => {
// Exhaust verify limit
for (let i = 0; i < 5; i++) {
await request(app)
.post('/auth/verify')
.send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE });
await request(app).post('/auth/verify').send({
walletAddress: WALLET,
signature: SIGNATURE,
nonce: NONCE,
identityPublicKey: IDENTITY_KEY,
});
}
const verifyBlocked = await request(app)
.post('/auth/verify')
.send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE });
const verifyBlocked = await request(app).post('/auth/verify').send({
walletAddress: WALLET,
signature: SIGNATURE,
nonce: NONCE,
identityPublicKey: IDENTITY_KEY,
});
expect(verifyBlocked.status).toBe(429);

// Challenge limit should still allow requests
Expand Down
Loading
Loading