Skip to content
Merged
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
9 changes: 9 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"decimal.js": "^10.6.0",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-async-errors": "^3.1.1",
"express-rate-limit": "^7.0.0",
"fast-check": "^4.8.0",
"ioredis": "^5.10.1",
Expand Down
131 changes: 127 additions & 4 deletions backend/src/__tests__/walletAliasEndpoints.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import request from 'supertest';
import app from '../index';
import { issueTokenPair } from '../auth';
import { registerApiKey } from '../middleware/apiKeyAuth';
import { disconnectPrismaClient } from '../prismaClient';
import { walletAliasMappingService } from '../walletAliasService';
Expand All @@ -8,7 +9,12 @@ import { VALID_TEST_WALLET, SECOND_TEST_WALLET } from './setup';
describe('Wallet alias API endpoints', () => {
const stellarWallet = VALID_TEST_WALLET;
const providerAlias = 'wallet-connect-test-alias';
const authHeader = { Authorization: `ApiKey ${process.env.ADMIN_API_KEY || 'test-admin-key'}` };
const adminAuthHeader = { Authorization: `ApiKey ${process.env.ADMIN_API_KEY || 'test-admin-key'}` };

async function bearerAuth(walletAddress = stellarWallet): Promise<string> {
const { accessToken } = await issueTokenPair(walletAddress);
return `Bearer ${accessToken}`;
}

beforeEach(async () => {
await walletAliasMappingService.resetForTests();
Expand All @@ -21,6 +27,7 @@ describe('Wallet alias API endpoints', () => {
it('POST /api/v1/wallet-aliases/link links provider aliases to a canonical identity', async () => {
const res = await request(app)
.post('/api/v1/wallet-aliases/link')
.set('Authorization', await bearerAuth(SECOND_TEST_WALLET))
.send({
primaryAlias: SECOND_TEST_WALLET,
primarySource: 'stellar',
Expand All @@ -36,9 +43,77 @@ describe('Wallet alias API endpoints', () => {
expect(res.body.canonicalWallet).toBe(SECOND_TEST_WALLET);
});

it('requires authentication before linking wallet aliases', async () => {
const res = await request(app)
.post('/api/v1/wallet-aliases/link')
.send({
primaryAlias: stellarWallet,
primarySource: 'stellar',
linkedAlias: providerAlias,
linkedSource: 'walletconnect',
});

expect(res.status).toBe(401);
});

it('requires authentication for both wallet alias resolve endpoints', async () => {
const resolved = await request(app).get('/api/v1/wallet-aliases/resolve');
const identity = await request(app).get('/api/v1/wallet-aliases/wallet-alias:test');

expect(resolved.status).toBe(401);
expect(identity.status).toBe(401);
});

it('only allows linking from the authenticated wallet and prevents cross-owner merges', async () => {
const firstLink = await request(app)
.post('/api/v1/wallet-aliases/link')
.set('Authorization', await bearerAuth(stellarWallet))
.send({
primaryAlias: stellarWallet,
primarySource: 'stellar',
linkedAlias: providerAlias,
linkedSource: 'walletconnect',
});
expect(firstLink.status).toBe(200);

const mismatchedPrimary = await request(app)
.post('/api/v1/wallet-aliases/link')
.set('Authorization', await bearerAuth(SECOND_TEST_WALLET))
.send({
primaryAlias: stellarWallet,
primarySource: 'stellar',
linkedAlias: 'another-provider-alias',
linkedSource: 'walletconnect',
});
expect(mismatchedPrimary.status).toBe(403);

const unownedStellarLink = await request(app)
.post('/api/v1/wallet-aliases/link')
.set('Authorization', await bearerAuth(stellarWallet))
.send({
primaryAlias: stellarWallet,
primarySource: 'stellar',
linkedAlias: SECOND_TEST_WALLET,
linkedSource: 'stellar',
});
expect(unownedStellarLink.status).toBe(403);

const crossOwnerLink = await request(app)
.post('/api/v1/wallet-aliases/link')
.set('Authorization', await bearerAuth(SECOND_TEST_WALLET))
.send({
primaryAlias: SECOND_TEST_WALLET,
primarySource: 'stellar',
linkedAlias: providerAlias,
linkedSource: 'walletconnect',
});
expect(crossOwnerLink.status).toBe(409);
});

it('GET /api/v1/wallet-aliases/resolve returns canonical linkage for a provider alias', async () => {
const linked = await request(app)
.post('/api/v1/wallet-aliases/link')
.set('Authorization', await bearerAuth())
.send({
primaryAlias: stellarWallet,
primarySource: 'stellar',
Expand All @@ -48,24 +123,68 @@ describe('Wallet alias API endpoints', () => {

const res = await request(app)
.get('/api/v1/wallet-aliases/resolve')
.set('Authorization', await bearerAuth())
.query({ alias: providerAlias, source: 'walletconnect' });

expect(res.status).toBe(200);
expect(res.body.canonicalId).toBe(linked.body.canonicalId);
expect(res.body.canonicalWallet).toBe(stellarWallet);
});

it('does not reveal an identity group to a different wallet', async () => {
const linked = await request(app)
.post('/api/v1/wallet-aliases/link')
.set('Authorization', await bearerAuth())
.send({
primaryAlias: stellarWallet,
primarySource: 'stellar',
linkedAlias: providerAlias,
linkedSource: 'walletconnect',
});

const res = await request(app)
.get('/api/v1/wallet-aliases/resolve')
.set('Authorization', await bearerAuth(SECOND_TEST_WALLET))
.query({ alias: providerAlias, source: 'walletconnect' });
const identity = await request(app)
.get(`/api/v1/wallet-aliases/${linked.body.canonicalId}`)
.set('Authorization', await bearerAuth(SECOND_TEST_WALLET));

expect(res.status).toBe(404);
expect(identity.status).toBe(404);
});

it('forwards rejected async handlers to the Express error boundary', async () => {
const resolveAlias = jest
.spyOn(walletAliasMappingService, 'resolveAlias')
.mockRejectedValueOnce(new Error('database unavailable'));

try {
const res = await request(app)
.get('/api/v1/wallet-aliases/resolve')
.set('Authorization', await bearerAuth())
.query({ alias: providerAlias, source: 'walletconnect' });

expect(res.status).toBe(500);
} finally {
resolveAlias.mockRestore();
}
});

it('GET /api/v1/wallet-aliases/:canonicalId returns linked aliases', async () => {
const linked = await request(app)
.post('/api/v1/wallet-aliases/link')
.set('Authorization', await bearerAuth())
.send({
primaryAlias: stellarWallet,
primarySource: 'stellar',
linkedAlias: providerAlias,
linkedSource: 'walletconnect',
});

const res = await request(app).get(`/api/v1/wallet-aliases/${linked.body.canonicalId}`);
const res = await request(app)
.get(`/api/v1/wallet-aliases/${linked.body.canonicalId}`)
.set('Authorization', await bearerAuth());

expect(res.status).toBe(200);
expect(res.body.aliases).toEqual(expect.arrayContaining([stellarWallet, providerAlias]));
Expand All @@ -75,14 +194,15 @@ describe('Wallet alias API endpoints', () => {
it('GET /admin/wallet-aliases lists persisted identity groups', async () => {
const linked = await request(app)
.post('/api/v1/wallet-aliases/link')
.set('Authorization', await bearerAuth())
.send({
primaryAlias: stellarWallet,
primarySource: 'stellar',
linkedAlias: providerAlias,
linkedSource: 'walletconnect',
});

const res = await request(app).get('/admin/wallet-aliases').set(authHeader);
const res = await request(app).get('/admin/wallet-aliases').set(adminAuthHeader);

expect(res.status).toBe(200);
expect(res.body.count).toBe(1);
Expand All @@ -93,6 +213,7 @@ describe('Wallet alias API endpoints', () => {
it('DELETE /admin/wallet-aliases/:canonicalId deletes an identity group', async () => {
const linked = await request(app)
.post('/api/v1/wallet-aliases/link')
.set('Authorization', await bearerAuth())
.send({
primaryAlias: stellarWallet,
primarySource: 'stellar',
Expand All @@ -102,13 +223,14 @@ describe('Wallet alias API endpoints', () => {

const deleted = await request(app)
.delete(`/admin/wallet-aliases/${linked.body.canonicalId}`)
.set(authHeader);
.set(adminAuthHeader);

expect(deleted.status).toBe(200);
expect(deleted.body.aliasesDeleted).toBe(2);

const resolved = await request(app)
.get('/api/v1/wallet-aliases/resolve')
.set('Authorization', await bearerAuth())
.query({ alias: providerAlias, source: 'walletconnect' });

expect(resolved.status).toBe(404);
Expand All @@ -118,6 +240,7 @@ describe('Wallet alias API endpoints', () => {
registerApiKey('wallet-alias-viewer-key', { role: 'viewer' });
const linked = await request(app)
.post('/api/v1/wallet-aliases/link')
.set('Authorization', await bearerAuth())
.send({
primaryAlias: stellarWallet,
primarySource: 'stellar',
Expand Down
16 changes: 15 additions & 1 deletion backend/src/__tests__/walletAliasService.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getPrismaClient, disconnectPrismaClient } from '../prismaClient';
import { WalletAliasMappingService } from '../walletAliasService';
import { WalletAliasConflictError, WalletAliasMappingService } from '../walletAliasService';
import { VALID_TEST_WALLET, SECOND_TEST_WALLET } from './setup';

describe('WalletAliasMappingService', () => {
Expand Down Expand Up @@ -70,6 +70,20 @@ describe('WalletAliasMappingService', () => {
).toBe(true);
});

it('does not merge aliases already owned by different canonical identities', async () => {
await service.registerAlias(VALID_TEST_WALLET, 'stellar');
await service.registerAlias('wallet-connect-alias', 'walletconnect');

await expect(
service.linkProviderIdentity(
VALID_TEST_WALLET,
'stellar',
'wallet-connect-alias',
'walletconnect',
),
).rejects.toBeInstanceOf(WalletAliasConflictError);
});

it('hydrates persisted aliases into a fresh service instance', async () => {
const first = await service.linkProviderIdentity(
VALID_TEST_WALLET,
Expand Down
15 changes: 15 additions & 0 deletions backend/src/adminAudit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Request } from 'express';
import { prisma } from './prisma';
import { resetAuditLogs } from './auditLog';
import { redactSensitiveLogAttributes } from './auditRedaction';
import { logger } from './middleware/structuredLogging';
import crypto from 'crypto';

type AuditStorageMode = 'memory' | 'prisma' | 'hybrid';
Expand Down Expand Up @@ -107,6 +108,20 @@ export async function recordAdminAuditLog(
}
}

export function recordAdminAuditLogInBackground(
req: Request,
action: string,
statusCode: number,
metadata: Record<string, unknown> = {},
): void {
void recordAdminAuditLog(req, action, statusCode, metadata).catch((error: unknown) => {
logger.error('Failed to persist background admin audit log', {
action,
error: error instanceof Error ? error.message : String(error),
});
});
}

export async function listAdminAuditLogs(filters: AuditLogFilters): Promise<AdminAuditLogRecord[]> {
const storageMode = normalizeStorageMode(process.env.ADMIN_AUDIT_LOG_STORAGE);
if (storageMode === 'memory') {
Expand Down
Loading