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
75 changes: 75 additions & 0 deletions api/src/__tests__/graphql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,81 @@ describe('GraphQL API (Issue #879)', () => {
expect(remittance).toHaveProperty('sender');
});

it('should enforce row ownership for remittances list on non-admin callers', async () => {
const userApp = express();
userApp.use(express.json());

const userStore = {
queryWithCursor: async (_cursor: string | null, _limit: number, agent?: string) => {
expect(agent).toBe('user-42');
return {
items: [
{
id: 7,
sender: 'user-42',
agent: 'agent-42',
amount: 1500,
fee: 30,
status: 'Completed',
token: 'USDC',
memo: 'owned',
created_at: '2025-06-01T00:00:00Z',
updated_at: '2025-06-01T01:00:00Z',
},
],
nextCursor: null,
hasMore: false,
};
},
};

userApp.use('/api/graphql', createGraphQLRouter({ pool: undefined, remittanceStore: userStore as any }));

const response = await request(userApp)
.post('/api/graphql')
.set('Authorization', bearer('user-42', { role: 'user' }))
.send({ query: 'query { remittances(agent: "OTHER_AGENT") { id sender agent } }' });

expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.remittances[0].sender).toBe('user-42');
});

it('should deny access to a remittance row owned by another user', async () => {
const userApp = express();
userApp.use(express.json());

const pool = {
query: async (sql: string) => {
if (sql.includes('SELECT * FROM remittances WHERE id = $1')) {
return {
rows: [{
id: 99,
sender_id: 'other-user',
agent_id: 'other-agent',
amount: 2000,
fee: 40,
status: 'Completed',
created_at: '2025-06-01T00:00:00Z',
updated_at: '2025-06-01T01:00:00Z',
}],
};
}
return { rows: [] };
},
} as unknown as Pool;

userApp.use('/api/graphql', createGraphQLRouter({ pool, remittanceStore: undefined }));

const response = await request(userApp)
.post('/api/graphql')
.set('Authorization', bearer('user-42', { role: 'user' }))
.send({ query: 'query { remittance(id: 99) { id sender } }' });

expect(response.status).toBe(200);
expect(response.body.data.remittance).toBeNull();
});

it('should handle invalid queries', async () => {
const query = `query { invalidField { foo } }`;

Expand Down
7 changes: 5 additions & 2 deletions api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { createAccountsRouter } from './routes/accounts';
import { getApiMetrics } from './metrics';
import { ErrorResponse } from './types';
import { AnchorStore, PostgresAnchorStore, createAnchorPool } from './db/anchorStore';
import { getDefaultRemittanceStore } from './db/remittanceStore';
import { Server as SocketIOServer } from 'socket.io';
import { createWsHealthRouter } from './websocket/health';
import { createRateLimitMiddleware, addRateLimitHeaders } from './middleware/rateLimitHeaders';
Expand Down Expand Up @@ -122,6 +123,8 @@ async function checkContractReachability() {
export function createApp(options: AppOptions = {}): Application {
const app = express();

const remittanceStore = options.remittanceStore ?? (process.env.DATABASE_URL ? getDefaultRemittanceStore() : undefined);

// Initialize instrumented pool if DATABASE_URL is configured
const pool = options.pool ?? (process.env.DATABASE_URL ? initPool() : null);

Expand Down Expand Up @@ -261,7 +264,7 @@ export function createApp(options: AppOptions = {}): Application {
// Remittances — cursor-based pagination (Issues #472, #531); SR-160: pool
// passed so failed outbound webhooks are persisted to webhook_dead_letters.
apiRouter.use('/remittances', createRemittancesRouter({
remittanceStore: options.remittanceStore,
remittanceStore,
pool: pool ?? undefined,
}));

Expand Down Expand Up @@ -297,7 +300,7 @@ export function createApp(options: AppOptions = {}): Application {
// existed but was never mounted, so the endpoint was unreachable.
apiRouter.use('/graphql', createGraphQLRouter({
pool: analyticsPool ?? undefined,
remittanceStore: options.remittanceStore,
remittanceStore,
}));

// SR-056: Mount versioned (/v1/api/...) and unversioned-alias (/api/...)
Expand Down
23 changes: 21 additions & 2 deletions api/src/graphql/resolvers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Pool } from 'pg';
import type { GraphQLContext } from '../routes/graphql';

export interface RemittanceStore {
queryWithCursor(
Expand Down Expand Up @@ -34,15 +35,20 @@ export function createResolvers(pool: Pool, remittanceStore?: RemittanceStore) {
remittances: async (
_: unknown,
args: { agent?: string; status?: string; cursor?: string; limit?: number },
context?: GraphQLContext,
) => {
if (!remittanceStore) {
throw new Error('Remittance store not configured');
}

const allowedAgent = context?.role === 'admin'
? args.agent?.trim()
: context?.userId ?? args.agent?.trim();

const result = await remittanceStore.queryWithCursor(
args.cursor || null,
args.limit || 20,
args.agent,
allowedAgent || undefined,
args.status,
);

Expand All @@ -52,6 +58,7 @@ export function createResolvers(pool: Pool, remittanceStore?: RemittanceStore) {
remittance: async (
_: unknown,
args: { id: number },
context?: GraphQLContext,
) => {
if (!pool) {
throw new Error('Database not configured');
Expand All @@ -62,7 +69,19 @@ export function createResolvers(pool: Pool, remittanceStore?: RemittanceStore) {
[args.id],
);

return result.rows[0] || null;
const row = result.rows[0] || null;
if (!row) {
return null;
}

if (context && context.role !== 'admin') {
const isOwner = row.sender_id === context.userId || row.agent_id === context.userId;
if (!isOwner) {
return null;
}
}

return row;
},

corridors: async (
Expand Down
Loading