mirror of
https://github.com/selfxyz/self.git
synced 2026-04-27 03:01:15 -04:00
Fix missing DSC parsing for passport/id_card (#1498)
* Handle missing parsed DSC before fetching * address cr feedback * formatting * fix bugs
This commit is contained in:
@@ -1045,17 +1045,30 @@ export const useProvingStore = create<ProvingState>((set, get) => {
|
||||
|
||||
set({ passportData, secret, env });
|
||||
set({ circuitType });
|
||||
// Skip parsing for disclosure if passport is already parsed
|
||||
// Only skip parsing when the document has already been parsed for non-DSC circuits.
|
||||
// Re-parsing would overwrite the alternative CSCA used during registration and is unnecessary
|
||||
// skip also the register circuit as the passport already got parsed in during the dsc step
|
||||
console.log('circuitType', circuitType);
|
||||
if (circuitType !== 'dsc') {
|
||||
console.log('skipping id document parsing');
|
||||
actor.send({ type: 'FETCH_DATA' });
|
||||
selfClient.trackEvent(ProofEvents.FETCH_DATA_STARTED);
|
||||
} else {
|
||||
// for already parsed passports or ID cards.
|
||||
// Aadhaar and KYC documents do not require DSC parsing at all.
|
||||
const needsDscParsing =
|
||||
passportData.documentCategory === 'passport' || passportData.documentCategory === 'id_card';
|
||||
const hasParsedDsc = needsDscParsing && Boolean(passportData.dsc_parsed?.authorityKeyIdentifier);
|
||||
|
||||
if (circuitType === 'dsc' && !needsDscParsing) {
|
||||
console.error(`DSC circuit is not supported for ${passportData.documentCategory} documents`);
|
||||
selfClient.trackEvent(ProofEvents.PROOF_FAILED, {
|
||||
message: `DSC circuit not supported for ${passportData.documentCategory}`,
|
||||
});
|
||||
actor.send({ type: 'ERROR' });
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldParseDocument = circuitType === 'dsc' || (needsDscParsing && !hasParsedDsc);
|
||||
|
||||
if (shouldParseDocument) {
|
||||
actor.send({ type: 'PARSE_ID_DOCUMENT' });
|
||||
selfClient.trackEvent(ProofEvents.PARSE_ID_DOCUMENT_STARTED);
|
||||
} else {
|
||||
actor.send({ type: 'FETCH_DATA' });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1158,14 +1171,15 @@ export const useProvingStore = create<ProvingState>((set, get) => {
|
||||
switch (passportData.documentCategory) {
|
||||
case 'passport':
|
||||
case 'id_card':
|
||||
if (!passportData?.dsc_parsed) {
|
||||
selfClient.logProofEvent('error', 'Missing parsed DSC', context, {
|
||||
if (!passportData?.dsc_parsed?.authorityKeyIdentifier) {
|
||||
const docType = passportData.documentCategory;
|
||||
selfClient.logProofEvent('error', `Missing parsed DSC in ${docType} data`, context, {
|
||||
failure: 'PROOF_FAILED_DATA_FETCH',
|
||||
duration_ms: Date.now() - startTime,
|
||||
});
|
||||
console.error('Missing parsed DSC in passport data');
|
||||
console.error(`Missing parsed DSC in ${docType} data`);
|
||||
selfClient.trackEvent(ProofEvents.FETCH_DATA_FAILED, {
|
||||
message: 'Missing parsed DSC in passport data',
|
||||
message: `Missing parsed DSC in ${docType} data`,
|
||||
});
|
||||
actor!.send({ type: 'FETCH_ERROR' });
|
||||
return;
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { SelfClient } from '../../src';
|
||||
import { ProofEvents } from '../../src/constants/analytics';
|
||||
import * as documentUtils from '../../src/documents/utils';
|
||||
import { useProvingStore } from '../../src/proving/provingMachine';
|
||||
import { fetchAllTreesAndCircuits } from '../../src/stores';
|
||||
import { useProtocolStore } from '../../src/stores/protocolStore';
|
||||
import { useSelfAppStore } from '../../src/stores/selfAppStore';
|
||||
import { actorMock } from './actorMock';
|
||||
@@ -36,46 +37,359 @@ vitest.mock('../../src/stores', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
describe('startFetchingData', () => {
|
||||
let mockSelfClient: SelfClient;
|
||||
beforeEach(async () => {
|
||||
const createMockSelfClient = () =>
|
||||
({
|
||||
getPrivateKey: vitest.fn().mockResolvedValue('secret'),
|
||||
trackEvent: vitest.fn(),
|
||||
logProofEvent: vitest.fn(),
|
||||
getSelfAppState: () => useSelfAppStore.getState(),
|
||||
getProvingState: () => useProvingStore.getState(),
|
||||
getProtocolState: () => useProtocolStore.getState(),
|
||||
}) as unknown as SelfClient;
|
||||
|
||||
function mockDocument(data: Record<string, unknown>) {
|
||||
vitest.spyOn(documentUtils, 'loadSelectedDocument').mockResolvedValue({ data } as any);
|
||||
}
|
||||
|
||||
describe('init parsing decision', () => {
|
||||
beforeEach(() => {
|
||||
vitest.clearAllMocks();
|
||||
|
||||
const loadSelectedDocumentSpy = vitest.spyOn(documentUtils, 'loadSelectedDocument');
|
||||
|
||||
loadSelectedDocumentSpy.mockResolvedValue({
|
||||
data: {
|
||||
documentCategory: 'passport',
|
||||
mock: false,
|
||||
dsc_parsed: { authorityKeyIdentifier: 'key' } as any,
|
||||
},
|
||||
} as any);
|
||||
|
||||
// Create mock selfClient
|
||||
mockSelfClient = {
|
||||
getPrivateKey: vitest.fn().mockResolvedValue('secret'), // or mock-secret?
|
||||
trackEvent: vitest.fn(),
|
||||
logProofEvent: vitest.fn(),
|
||||
getSelfAppState: () => useSelfAppStore.getState(),
|
||||
getProvingState: () => useProvingStore.getState(),
|
||||
getProtocolState: () => useProtocolStore.getState(),
|
||||
} as unknown as SelfClient;
|
||||
|
||||
await useProvingStore.getState().init(mockSelfClient, 'register');
|
||||
actorMock.send.mockClear();
|
||||
|
||||
useProvingStore.setState({
|
||||
passportData: { documentCategory: 'passport', mock: false },
|
||||
env: 'prod',
|
||||
} as any);
|
||||
useProvingStore.setState({ circuitType: null, passportData: null, env: null });
|
||||
});
|
||||
|
||||
it('emits FETCH_ERROR when dsc_parsed is missing', async () => {
|
||||
describe('passport', () => {
|
||||
it('skips parsing for disclose when dsc_parsed has authorityKeyIdentifier', async () => {
|
||||
const client = createMockSelfClient();
|
||||
mockDocument({
|
||||
documentCategory: 'passport',
|
||||
mock: false,
|
||||
dsc_parsed: { authorityKeyIdentifier: 'key' },
|
||||
});
|
||||
|
||||
await useProvingStore.getState().init(client, 'disclose');
|
||||
|
||||
expect(actorMock.send).toHaveBeenCalledWith({ type: 'FETCH_DATA' });
|
||||
expect(actorMock.send).not.toHaveBeenCalledWith({ type: 'PARSE_ID_DOCUMENT' });
|
||||
expect(client.trackEvent).not.toHaveBeenCalledWith(ProofEvents.PARSE_ID_DOCUMENT_STARTED);
|
||||
});
|
||||
|
||||
it('parses for disclose when dsc_parsed is missing', async () => {
|
||||
const client = createMockSelfClient();
|
||||
mockDocument({ documentCategory: 'passport', mock: false });
|
||||
|
||||
await useProvingStore.getState().init(client, 'disclose');
|
||||
|
||||
expect(actorMock.send).toHaveBeenCalledWith({ type: 'PARSE_ID_DOCUMENT' });
|
||||
expect(actorMock.send).not.toHaveBeenCalledWith({ type: 'FETCH_DATA' });
|
||||
expect(client.trackEvent).toHaveBeenCalledWith(ProofEvents.PARSE_ID_DOCUMENT_STARTED);
|
||||
});
|
||||
|
||||
it('parses when dsc_parsed exists but authorityKeyIdentifier is missing', async () => {
|
||||
const client = createMockSelfClient();
|
||||
mockDocument({
|
||||
documentCategory: 'passport',
|
||||
mock: false,
|
||||
dsc_parsed: { issuer: 'some-issuer' },
|
||||
});
|
||||
|
||||
await useProvingStore.getState().init(client, 'disclose');
|
||||
|
||||
expect(actorMock.send).toHaveBeenCalledWith({ type: 'PARSE_ID_DOCUMENT' });
|
||||
expect(actorMock.send).not.toHaveBeenCalledWith({ type: 'FETCH_DATA' });
|
||||
});
|
||||
|
||||
it('skips parsing for register when dsc_parsed has authorityKeyIdentifier', async () => {
|
||||
const client = createMockSelfClient();
|
||||
mockDocument({
|
||||
documentCategory: 'passport',
|
||||
mock: false,
|
||||
dsc_parsed: { authorityKeyIdentifier: 'key' },
|
||||
});
|
||||
|
||||
await useProvingStore.getState().init(client, 'register');
|
||||
|
||||
expect(actorMock.send).toHaveBeenCalledWith({ type: 'FETCH_DATA' });
|
||||
expect(actorMock.send).not.toHaveBeenCalledWith({ type: 'PARSE_ID_DOCUMENT' });
|
||||
});
|
||||
|
||||
it('parses for register when dsc_parsed is missing', async () => {
|
||||
const client = createMockSelfClient();
|
||||
mockDocument({ documentCategory: 'passport', mock: false });
|
||||
|
||||
await useProvingStore.getState().init(client, 'register');
|
||||
|
||||
expect(actorMock.send).toHaveBeenCalledWith({ type: 'PARSE_ID_DOCUMENT' });
|
||||
expect(actorMock.send).not.toHaveBeenCalledWith({ type: 'FETCH_DATA' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('id_card', () => {
|
||||
it('skips parsing for disclose when dsc_parsed has authorityKeyIdentifier', async () => {
|
||||
const client = createMockSelfClient();
|
||||
mockDocument({
|
||||
documentCategory: 'id_card',
|
||||
mock: false,
|
||||
dsc_parsed: { authorityKeyIdentifier: 'key' },
|
||||
});
|
||||
|
||||
await useProvingStore.getState().init(client, 'disclose');
|
||||
|
||||
expect(actorMock.send).toHaveBeenCalledWith({ type: 'FETCH_DATA' });
|
||||
expect(actorMock.send).not.toHaveBeenCalledWith({ type: 'PARSE_ID_DOCUMENT' });
|
||||
});
|
||||
|
||||
it('parses when dsc_parsed exists but authorityKeyIdentifier is missing', async () => {
|
||||
const client = createMockSelfClient();
|
||||
mockDocument({
|
||||
documentCategory: 'id_card',
|
||||
mock: false,
|
||||
dsc_parsed: { issuer: 'some-issuer' },
|
||||
});
|
||||
|
||||
await useProvingStore.getState().init(client, 'disclose');
|
||||
|
||||
expect(actorMock.send).toHaveBeenCalledWith({ type: 'PARSE_ID_DOCUMENT' });
|
||||
expect(actorMock.send).not.toHaveBeenCalledWith({ type: 'FETCH_DATA' });
|
||||
});
|
||||
|
||||
it('parses for register when dsc_parsed is missing', async () => {
|
||||
const client = createMockSelfClient();
|
||||
mockDocument({ documentCategory: 'id_card', mock: false });
|
||||
|
||||
await useProvingStore.getState().init(client, 'register');
|
||||
|
||||
expect(actorMock.send).toHaveBeenCalledWith({ type: 'PARSE_ID_DOCUMENT' });
|
||||
expect(actorMock.send).not.toHaveBeenCalledWith({ type: 'FETCH_DATA' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('dsc circuit', () => {
|
||||
it('always parses even when dsc_parsed already exists', async () => {
|
||||
const client = createMockSelfClient();
|
||||
mockDocument({
|
||||
documentCategory: 'passport',
|
||||
mock: false,
|
||||
dsc_parsed: { authorityKeyIdentifier: 'key' },
|
||||
});
|
||||
|
||||
await useProvingStore.getState().init(client, 'dsc');
|
||||
|
||||
expect(actorMock.send).toHaveBeenCalledWith({ type: 'PARSE_ID_DOCUMENT' });
|
||||
expect(actorMock.send).not.toHaveBeenCalledWith({ type: 'FETCH_DATA' });
|
||||
expect(client.trackEvent).toHaveBeenCalledWith(ProofEvents.PARSE_ID_DOCUMENT_STARTED);
|
||||
});
|
||||
|
||||
it('parses when dsc_parsed is missing', async () => {
|
||||
const client = createMockSelfClient();
|
||||
mockDocument({ documentCategory: 'passport', mock: false });
|
||||
|
||||
await useProvingStore.getState().init(client, 'dsc');
|
||||
|
||||
expect(actorMock.send).toHaveBeenCalledWith({ type: 'PARSE_ID_DOCUMENT' });
|
||||
expect(actorMock.send).not.toHaveBeenCalledWith({ type: 'FETCH_DATA' });
|
||||
});
|
||||
|
||||
it('rejects dsc circuit for aadhaar documents', async () => {
|
||||
const client = createMockSelfClient();
|
||||
mockDocument({ documentCategory: 'aadhaar', mock: false });
|
||||
|
||||
await useProvingStore.getState().init(client, 'dsc');
|
||||
|
||||
expect(actorMock.send).toHaveBeenCalledWith({ type: 'ERROR' });
|
||||
expect(actorMock.send).not.toHaveBeenCalledWith({ type: 'PARSE_ID_DOCUMENT' });
|
||||
expect(actorMock.send).not.toHaveBeenCalledWith({ type: 'FETCH_DATA' });
|
||||
expect(client.trackEvent).toHaveBeenCalledWith(ProofEvents.PROOF_FAILED, {
|
||||
message: 'DSC circuit not supported for aadhaar',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects dsc circuit for kyc documents', async () => {
|
||||
const client = createMockSelfClient();
|
||||
mockDocument({ documentCategory: 'kyc', mock: false });
|
||||
|
||||
await useProvingStore.getState().init(client, 'dsc');
|
||||
|
||||
expect(actorMock.send).toHaveBeenCalledWith({ type: 'ERROR' });
|
||||
expect(actorMock.send).not.toHaveBeenCalledWith({ type: 'PARSE_ID_DOCUMENT' });
|
||||
expect(client.trackEvent).toHaveBeenCalledWith(ProofEvents.PROOF_FAILED, {
|
||||
message: 'DSC circuit not supported for kyc',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('aadhaar', () => {
|
||||
it('skips parsing for disclose (does not require DSC)', async () => {
|
||||
const client = createMockSelfClient();
|
||||
mockDocument({ documentCategory: 'aadhaar', mock: false });
|
||||
|
||||
await useProvingStore.getState().init(client, 'disclose');
|
||||
|
||||
expect(actorMock.send).toHaveBeenCalledWith({ type: 'FETCH_DATA' });
|
||||
expect(actorMock.send).not.toHaveBeenCalledWith({ type: 'PARSE_ID_DOCUMENT' });
|
||||
expect(client.trackEvent).not.toHaveBeenCalledWith(ProofEvents.PARSE_ID_DOCUMENT_STARTED);
|
||||
});
|
||||
|
||||
it('skips parsing for register (does not require DSC)', async () => {
|
||||
const client = createMockSelfClient();
|
||||
mockDocument({ documentCategory: 'aadhaar', mock: false });
|
||||
|
||||
await useProvingStore.getState().init(client, 'register');
|
||||
|
||||
expect(actorMock.send).toHaveBeenCalledWith({ type: 'FETCH_DATA' });
|
||||
expect(actorMock.send).not.toHaveBeenCalledWith({ type: 'PARSE_ID_DOCUMENT' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('kyc', () => {
|
||||
it('skips parsing for disclose (does not require DSC)', async () => {
|
||||
const client = createMockSelfClient();
|
||||
mockDocument({ documentCategory: 'kyc', mock: false });
|
||||
|
||||
await useProvingStore.getState().init(client, 'disclose');
|
||||
|
||||
expect(actorMock.send).toHaveBeenCalledWith({ type: 'FETCH_DATA' });
|
||||
expect(actorMock.send).not.toHaveBeenCalledWith({ type: 'PARSE_ID_DOCUMENT' });
|
||||
expect(client.trackEvent).not.toHaveBeenCalledWith(ProofEvents.PARSE_ID_DOCUMENT_STARTED);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('startFetchingData', () => {
|
||||
let mockSelfClient: SelfClient;
|
||||
|
||||
/** Run init() with a valid passport so the actor is ready, then clear mocks
|
||||
* and set passportData to whatever the individual test needs. */
|
||||
async function setupForFetch(passportData: Record<string, unknown>) {
|
||||
vitest.clearAllMocks();
|
||||
mockDocument({
|
||||
documentCategory: 'passport',
|
||||
mock: false,
|
||||
dsc_parsed: { authorityKeyIdentifier: 'key' },
|
||||
});
|
||||
mockSelfClient = createMockSelfClient();
|
||||
await useProvingStore.getState().init(mockSelfClient, 'register');
|
||||
actorMock.send.mockClear();
|
||||
(mockSelfClient.trackEvent as ReturnType<typeof vitest.fn>).mockClear();
|
||||
(mockSelfClient.logProofEvent as ReturnType<typeof vitest.fn>).mockClear();
|
||||
useProvingStore.setState({ passportData, env: 'prod' } as any);
|
||||
}
|
||||
|
||||
it('emits FETCH_ERROR when dsc_parsed is missing for passport', async () => {
|
||||
await setupForFetch({ documentCategory: 'passport', mock: false });
|
||||
|
||||
await useProvingStore.getState().startFetchingData(mockSelfClient);
|
||||
|
||||
expect(mockSelfClient.trackEvent).toHaveBeenCalledWith(ProofEvents.FETCH_DATA_STARTED);
|
||||
expect(actorMock.send).toHaveBeenCalledWith({ type: 'FETCH_ERROR' });
|
||||
expect(mockSelfClient.trackEvent).toHaveBeenCalledWith(ProofEvents.FETCH_DATA_FAILED, {
|
||||
message: 'Missing parsed DSC in passport data',
|
||||
});
|
||||
expect(actorMock.send).not.toHaveBeenCalledWith({ type: 'FETCH_SUCCESS' });
|
||||
});
|
||||
|
||||
it('emits FETCH_ERROR when dsc_parsed exists but authorityKeyIdentifier is missing', async () => {
|
||||
await setupForFetch({
|
||||
documentCategory: 'passport',
|
||||
mock: false,
|
||||
dsc_parsed: { issuer: 'some-issuer' },
|
||||
});
|
||||
|
||||
await useProvingStore.getState().startFetchingData(mockSelfClient);
|
||||
|
||||
expect(actorMock.send).toHaveBeenCalledWith({ type: 'FETCH_ERROR' });
|
||||
expect(mockSelfClient.trackEvent).toHaveBeenCalledWith(ProofEvents.FETCH_DATA_FAILED, {
|
||||
message: 'Missing parsed DSC in passport data',
|
||||
});
|
||||
});
|
||||
|
||||
it('emits FETCH_ERROR when dsc_parsed is missing for id_card', async () => {
|
||||
await setupForFetch({ documentCategory: 'id_card', mock: false });
|
||||
|
||||
await useProvingStore.getState().startFetchingData(mockSelfClient);
|
||||
|
||||
expect(actorMock.send).toHaveBeenCalledWith({ type: 'FETCH_ERROR' });
|
||||
expect(mockSelfClient.trackEvent).toHaveBeenCalledWith(ProofEvents.FETCH_DATA_FAILED, {
|
||||
message: 'Missing parsed DSC in id_card data',
|
||||
});
|
||||
});
|
||||
|
||||
it('calls fetchAllTreesAndCircuits for passport with valid dsc_parsed', async () => {
|
||||
await setupForFetch({
|
||||
documentCategory: 'passport',
|
||||
mock: false,
|
||||
dsc_parsed: { authorityKeyIdentifier: 'test-aki-123' },
|
||||
});
|
||||
|
||||
await useProvingStore.getState().startFetchingData(mockSelfClient);
|
||||
|
||||
expect(fetchAllTreesAndCircuits).toHaveBeenCalledWith(mockSelfClient, 'passport', 'prod', 'test-aki-123');
|
||||
expect(actorMock.send).toHaveBeenCalledWith({ type: 'FETCH_SUCCESS' });
|
||||
expect(mockSelfClient.trackEvent).toHaveBeenCalledWith(ProofEvents.FETCH_DATA_SUCCESS);
|
||||
});
|
||||
|
||||
it('calls fetchAllTreesAndCircuits for id_card with valid dsc_parsed', async () => {
|
||||
await setupForFetch({
|
||||
documentCategory: 'id_card',
|
||||
mock: false,
|
||||
dsc_parsed: { authorityKeyIdentifier: 'card-aki-456' },
|
||||
});
|
||||
|
||||
await useProvingStore.getState().startFetchingData(mockSelfClient);
|
||||
|
||||
expect(fetchAllTreesAndCircuits).toHaveBeenCalledWith(mockSelfClient, 'id_card', 'prod', 'card-aki-456');
|
||||
expect(actorMock.send).toHaveBeenCalledWith({ type: 'FETCH_SUCCESS' });
|
||||
});
|
||||
|
||||
it('calls aadhaar.fetch_all for aadhaar documents', async () => {
|
||||
const mockFetchAll = vitest.fn().mockResolvedValue(undefined);
|
||||
const client = createMockSelfClient();
|
||||
(client as any).getProtocolState = () => ({
|
||||
aadhaar: { fetch_all: mockFetchAll },
|
||||
});
|
||||
|
||||
// Bootstrap actor via init
|
||||
vitest.clearAllMocks();
|
||||
mockDocument({ documentCategory: 'aadhaar', mock: false });
|
||||
(client as any).getPrivateKey = vitest.fn().mockResolvedValue('secret');
|
||||
await useProvingStore.getState().init(client, 'disclose');
|
||||
actorMock.send.mockClear();
|
||||
(client.trackEvent as ReturnType<typeof vitest.fn>).mockClear();
|
||||
|
||||
useProvingStore.setState({
|
||||
passportData: { documentCategory: 'aadhaar', mock: false },
|
||||
env: 'prod',
|
||||
} as any);
|
||||
|
||||
await useProvingStore.getState().startFetchingData(client);
|
||||
|
||||
expect(mockFetchAll).toHaveBeenCalledWith('prod');
|
||||
expect(actorMock.send).toHaveBeenCalledWith({ type: 'FETCH_SUCCESS' });
|
||||
expect(fetchAllTreesAndCircuits).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls kyc.fetch_all for kyc documents', async () => {
|
||||
const mockFetchAll = vitest.fn().mockResolvedValue(undefined);
|
||||
const client = createMockSelfClient();
|
||||
(client as any).getProtocolState = () => ({
|
||||
kyc: { fetch_all: mockFetchAll },
|
||||
});
|
||||
|
||||
vitest.clearAllMocks();
|
||||
mockDocument({ documentCategory: 'kyc', mock: false });
|
||||
(client as any).getPrivateKey = vitest.fn().mockResolvedValue('secret');
|
||||
await useProvingStore.getState().init(client, 'disclose');
|
||||
actorMock.send.mockClear();
|
||||
(client.trackEvent as ReturnType<typeof vitest.fn>).mockClear();
|
||||
|
||||
useProvingStore.setState({
|
||||
passportData: { documentCategory: 'kyc', mock: false },
|
||||
env: 'prod',
|
||||
} as any);
|
||||
|
||||
await useProvingStore.getState().startFetchingData(client);
|
||||
|
||||
expect(mockFetchAll).toHaveBeenCalledWith('prod');
|
||||
expect(actorMock.send).toHaveBeenCalledWith({ type: 'FETCH_SUCCESS' });
|
||||
expect(fetchAllTreesAndCircuits).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user