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
33 changes: 33 additions & 0 deletions scripts/db-debug.sh
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
echo " Show complete referral chain for user"
echo " -T, --referral-tree <userDataId>"
echo " Show complete referral tree (all branches)"
echo " -u, --user <email> Resolve an email address to userDataId(s)"
echo ""
echo "Examples:"
echo " ./scripts/db-debug.sh --anomalies 50"
Expand All @@ -76,6 +77,7 @@ if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
echo " ./scripts/db-debug.sh --asset-history MaerkiBaumann/CHF 10"
echo " ./scripts/db-debug.sh --referral-chain 370625"
echo " ./scripts/db-debug.sh --referral-tree 370625"
echo " ./scripts/db-debug.sh --user user@example.com"
echo " ./scripts/db-debug.sh \"SELECT * FROM asset LIMIT 10\""
exit 0
fi
Expand Down Expand Up @@ -109,6 +111,8 @@ ASSET_LIMIT="10"
REFERRAL_CHAIN_MODE=""
REFERRAL_TREE_MODE=""
TARGET_USER_ID=""
USER_LOOKUP_MODE=""
LOOKUP_MAIL=""

case "${1:-}" in
-a|--anomalies)
Expand Down Expand Up @@ -153,6 +157,15 @@ case "${1:-}" in
REFERRAL_TREE_MODE="1"
TARGET_USER_ID="$2"
;;
-u|--user)
if [ -z "${2:-}" ]; then
echo "Error: --user requires an email address"
echo "Usage: ./scripts/db-debug.sh --user <email>"
exit 1
fi
USER_LOOKUP_MODE="1"
LOOKUP_MAIL="$2"
;;
*)
SQL="${1:-SELECT id, name, blockchain FROM asset ORDER BY id DESC LIMIT 5}"
;;
Expand Down Expand Up @@ -198,6 +211,26 @@ ROLE=$(echo "$TOKEN" | cut -d'.' -f2 | base64 -d 2>/dev/null | jq -r '.role' 2>/
echo "Authenticated with role: $ROLE"
echo ""

# --- User lookup mode (email -> userDataId) ---
if [ -n "$USER_LOOKUP_MODE" ]; then
echo "=== Resolving userDataId for: $LOOKUP_MAIL ==="
echo ""

PAYLOAD=$(jq -n --arg mail "$LOOKUP_MAIL" '{"mail": $mail}')
RESULT=$(curl -s -X POST "$API_URL/gs/debug/user" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$PAYLOAD")

if command -v jq &> /dev/null; then
echo "$RESULT" | jq .
else
echo "$RESULT"
fi

exit 0
fi

# --- Referral chain mode ---
if [ -n "$REFERRAL_CHAIN_MODE" ]; then
if ! command -v jq &> /dev/null; then
Expand Down
42 changes: 41 additions & 1 deletion src/subdomains/generic/gs/__tests__/gs.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,23 @@ import { LimitRequestService } from 'src/subdomains/supporting/support-issue/ser
import { SupportIssueService } from 'src/subdomains/supporting/support-issue/services/support-issue.service';
import { SwapService } from 'src/subdomains/core/buy-crypto/routes/swap/swap.service';
import { VirtualIbanService } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.service';
import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity';
import { ComplianceSearchType } from 'src/subdomains/generic/support/dto/user-data-support.dto';

describe('GsService', () => {
let service: GsService;
let dataSource: DataSource;
let appInsightsQueryService: AppInsightsQueryService;
let userDataService: UserDataService;

beforeEach(() => {
dataSource = createMock<DataSource>();
appInsightsQueryService = createMock<AppInsightsQueryService>();
userDataService = createMock<UserDataService>();

service = new GsService(
appInsightsQueryService,
createMock<UserDataService>(),
userDataService,
createMock<UserService>(),
createMock<BuyService>(),
createMock<SellService>(),
Expand Down Expand Up @@ -220,4 +224,40 @@ describe('GsService', () => {
verboseSpy.mockRestore();
});
});

describe('resolveDebugUser', () => {
it('returns deduplicated, sorted userDataIds with the Mail search type and no PII', async () => {
// The resolved records intentionally carry PII (firstname/surname) to prove the DEBUG
// response strips it: only userDataIds may ever be returned, never personal data.
jest
.spyOn(userDataService, 'getUsersByMail')
.mockResolvedValue([
{ id: 102, firstname: 'Jane', surname: 'Doe' } as UserData,
{ id: 101, firstname: 'John', surname: 'Doe' } as UserData,
{ id: 101 } as UserData,
]);

const result = await service.resolveDebugUser('user@example.com', 'test-user');

expect(result).toEqual({ type: ComplianceSearchType.MAIL, userDataIds: [101, 102] });
expect(JSON.stringify(result)).not.toContain('firstname');
expect(JSON.stringify(result)).not.toContain('Doe');
});

it('resolves the mail with onlyValidUser=false (same resolution as the compliance search)', async () => {
const spy = jest.spyOn(userDataService, 'getUsersByMail').mockResolvedValue([{ id: 1 } as UserData]);

await service.resolveDebugUser('merged@example.com', 'test-user');

expect(spy).toHaveBeenCalledWith('merged@example.com', false, {});
});

it('returns an empty id list when no account matches', async () => {
jest.spyOn(userDataService, 'getUsersByMail').mockResolvedValue([]);

const result = await service.resolveDebugUser('missing@example.com', 'test-user');

expect(result).toEqual({ type: ComplianceSearchType.MAIL, userDataIds: [] });
});
});
});
15 changes: 15 additions & 0 deletions src/subdomains/generic/gs/dto/debug-user-query.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { ComplianceSearchType } from 'src/subdomains/generic/support/dto/user-data-support.dto';

export class DebugUserQueryDto {
@IsNotEmpty()
@IsString()
@MaxLength(256)
mail: string;
}

// Mirrors the compliance search result shape, but exposes only non-PII userDataIds.
export interface DebugUserResult {
type: ComplianceSearchType;
userDataIds: number[];
}
9 changes: 9 additions & 0 deletions src/subdomains/generic/gs/gs.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { UserRole } from 'src/shared/auth/user-role.enum';
import { DfxLogger } from 'src/shared/services/dfx-logger';
import { DbQueryBaseDto, DbQueryDto, DbReturnData } from './dto/db-query.dto';
import { DebugQueryDto } from './dto/debug-query.dto';
import { DebugUserQueryDto, DebugUserResult } from './dto/debug-user-query.dto';
import { LogQueryDto, LogQueryResult } from './dto/log-query.dto';
import { SupportDataQuery, SupportReturnData } from './dto/support-data.dto';
import { GsService } from './gs.service';
Expand Down Expand Up @@ -63,4 +64,12 @@ export class GsController {
async executeLogQuery(@GetJwt() jwt: JwtPayload, @Body() dto: LogQueryDto): Promise<LogQueryResult> {
return this.gsService.executeLogQuery(dto, jwt.address ?? `account:${jwt.account}`);
}

@Post('debug/user')
@ApiBearerAuth()
@ApiExcludeEndpoint()
@UseGuards(AuthGuard(), RoleGuard(UserRole.DEBUG), UserActiveGuard())
async resolveDebugUser(@GetJwt() jwt: JwtPayload, @Body() dto: DebugUserQueryDto): Promise<DebugUserResult> {
return this.gsService.resolveDebugUser(dto.mail, jwt.address ?? `account:${jwt.account}`);
}
}
18 changes: 18 additions & 0 deletions src/subdomains/generic/gs/gs.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { SwapService } from 'src/subdomains/core/buy-crypto/routes/swap/swap.ser
import { RefRewardService } from 'src/subdomains/core/referral/reward/services/ref-reward.service';
import { BuyFiatService } from 'src/subdomains/core/sell-crypto/process/services/buy-fiat.service';
import { SellService } from 'src/subdomains/core/sell-crypto/route/sell.service';
import { ComplianceSearchType } from 'src/subdomains/generic/support/dto/user-data-support.dto';
import { BankTxRepeatService } from 'src/subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.service';
import { BankTxType } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity';
import { BankTxService } from 'src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service';
Expand All @@ -29,6 +30,7 @@ import { UserData } from '../user/models/user-data/user-data.entity';
import { UserDataService } from '../user/models/user-data/user-data.service';
import { UserService } from '../user/models/user/user.service';
import { DbQueryBaseDto, DbQueryDto, DbReturnData } from './dto/db-query.dto';
import { DebugUserResult } from './dto/debug-user-query.dto';
import {
DebugBlockedCols,
DebugBlockedSchemas,
Expand Down Expand Up @@ -192,6 +194,22 @@ export class GsService {
};
}

// Sanctioned reverse lookup for the DEBUG tooling: the /gs/debug SQL endpoint blocks the
// user_data.mail column (PII), so an email cannot be resolved to a userDataId via SQL. This
// returns only non-PII identifiers, never the mail or any other personal data.
async resolveDebugUser(mail: string, userIdentifier: string): Promise<DebugUserResult> {
this.logger.verbose(`Debug user lookup by ${userIdentifier}`);

// Same resolution the compliance search uses for a mail; returns only userDataIds, no PII.
// No relations needed — only the id is read.
const userDataList = await this.userDataService.getUsersByMail(mail, false, {});
const userDataIds = Util.toUniqueList(userDataList, 'id')
.map((userData) => userData.id)
.sort((a, b) => a - b);

return { type: ComplianceSearchType.MAIL, userDataIds };
}

async executeDebugQuery(sql: string, userIdentifier: string): Promise<Record<string, unknown>[]> {
// 1. Parse SQL to AST for robust validation
let ast;
Expand Down