taler-harness: deployment tooling for tipping
This commit is contained in:
parent
7879efcff7
commit
7985b0a33f
@ -1436,12 +1436,20 @@ export interface MerchantServiceInterface {
|
|||||||
readonly name: string;
|
readonly name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeleteTippingReserveArgs {
|
||||||
|
reservePub: string;
|
||||||
|
purge?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class MerchantApiClient {
|
export class MerchantApiClient {
|
||||||
constructor(
|
constructor(
|
||||||
private baseUrl: string,
|
private baseUrl: string,
|
||||||
public readonly auth: MerchantAuthConfiguration,
|
public readonly auth: MerchantAuthConfiguration,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
// FIXME: Migrate everything to this in favor of axios
|
||||||
|
http = createPlatformHttpLib();
|
||||||
|
|
||||||
async changeAuth(auth: MerchantAuthConfiguration): Promise<void> {
|
async changeAuth(auth: MerchantAuthConfiguration): Promise<void> {
|
||||||
const url = new URL("private/auth", this.baseUrl);
|
const url = new URL("private/auth", this.baseUrl);
|
||||||
await axios.post(url.href, auth, {
|
await axios.post(url.href, auth, {
|
||||||
@ -1449,6 +1457,51 @@ export class MerchantApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteTippingReserve(req: DeleteTippingReserveArgs): Promise<void> {
|
||||||
|
const url = new URL(`private/reserves/${req.reservePub}`, this.baseUrl);
|
||||||
|
if (req.purge) {
|
||||||
|
url.searchParams.set("purge", "YES");
|
||||||
|
}
|
||||||
|
const resp = await axios.delete(url.href, {
|
||||||
|
headers: this.makeAuthHeader(),
|
||||||
|
});
|
||||||
|
logger.info(`delete status: ${resp.status}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTippingReserve(
|
||||||
|
req: CreateMerchantTippingReserveRequest,
|
||||||
|
): Promise<CreateMerchantTippingReserveConfirmation> {
|
||||||
|
const url = new URL("private/reserves", this.baseUrl);
|
||||||
|
const resp = await axios.post(url.href, req, {
|
||||||
|
headers: this.makeAuthHeader(),
|
||||||
|
});
|
||||||
|
// FIXME: validate
|
||||||
|
return resp.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPrivateInstanceInfo(): Promise<any> {
|
||||||
|
console.log(this.makeAuthHeader());
|
||||||
|
const url = new URL("private", this.baseUrl);
|
||||||
|
logger.info(`request url ${url.href}`);
|
||||||
|
const resp = await this.http.fetch(url.href, {
|
||||||
|
method: "GET",
|
||||||
|
headers: this.makeAuthHeader(),
|
||||||
|
});
|
||||||
|
return await resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPrivateTipReserves(): Promise<TippingReserveStatus> {
|
||||||
|
console.log(this.makeAuthHeader());
|
||||||
|
const url = new URL("private/reserves", this.baseUrl);
|
||||||
|
const resp = await this.http.fetch(url.href, {
|
||||||
|
method: "GET",
|
||||||
|
headers: this.makeAuthHeader(),
|
||||||
|
});
|
||||||
|
// FIXME: Validate!
|
||||||
|
return await resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
async deleteInstance(instanceId: string) {
|
async deleteInstance(instanceId: string) {
|
||||||
const url = new URL(`management/instances/${instanceId}`, this.baseUrl);
|
const url = new URL(`management/instances/${instanceId}`, this.baseUrl);
|
||||||
await axios.delete(url.href, {
|
await axios.delete(url.href, {
|
||||||
@ -1578,6 +1631,7 @@ export namespace MerchantPrivateApi {
|
|||||||
`private/reserves`,
|
`private/reserves`,
|
||||||
merchantService.makeInstanceBaseUrl(instance),
|
merchantService.makeInstanceBaseUrl(instance),
|
||||||
);
|
);
|
||||||
|
// FIXME: Don't use axios!
|
||||||
const resp = await axios.post(reqUrl.href, req);
|
const resp = await axios.post(reqUrl.href, req);
|
||||||
// FIXME: validate
|
// FIXME: validate
|
||||||
return resp.data;
|
return resp.data;
|
||||||
|
@ -7,7 +7,8 @@
|
|||||||
|
|
||||||
import axiosImp from "axios";
|
import axiosImp from "axios";
|
||||||
const axios = axiosImp.default;
|
const axios = axiosImp.default;
|
||||||
import { Logger, URL } from "@gnu-taler/taler-util";
|
import { AmountString, Logger, URL } from "@gnu-taler/taler-util";
|
||||||
|
import { createPlatformHttpLib } from "@gnu-taler/taler-util/http";
|
||||||
|
|
||||||
export interface LibeufinSandboxServiceInterface {
|
export interface LibeufinSandboxServiceInterface {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
@ -163,10 +164,6 @@ export interface LibeufinSandboxAddIncomingRequest {
|
|||||||
direction: string;
|
direction: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRandomString(): string {
|
|
||||||
return Math.random().toString(36).substring(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* APIs spread across Legacy and Access, it is therefore
|
* APIs spread across Legacy and Access, it is therefore
|
||||||
* the "base URL" relative to which API every call addresses.
|
* the "base URL" relative to which API every call addresses.
|
||||||
|
@ -22,10 +22,13 @@ import fs from "fs";
|
|||||||
import os from "os";
|
import os from "os";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import {
|
import {
|
||||||
|
addPaytoQueryParams,
|
||||||
Amounts,
|
Amounts,
|
||||||
Configuration,
|
Configuration,
|
||||||
decodeCrock,
|
decodeCrock,
|
||||||
|
j2s,
|
||||||
Logger,
|
Logger,
|
||||||
|
parsePaytoUri,
|
||||||
rsaBlind,
|
rsaBlind,
|
||||||
setGlobalLogLevelFromString,
|
setGlobalLogLevelFromString,
|
||||||
} from "@gnu-taler/taler-util";
|
} from "@gnu-taler/taler-util";
|
||||||
@ -33,11 +36,18 @@ import { runBench1 } from "./bench1.js";
|
|||||||
import { runBench2 } from "./bench2.js";
|
import { runBench2 } from "./bench2.js";
|
||||||
import { runBench3 } from "./bench3.js";
|
import { runBench3 } from "./bench3.js";
|
||||||
import { runEnv1 } from "./env1.js";
|
import { runEnv1 } from "./env1.js";
|
||||||
import { GlobalTestState, runTestWithState } from "./harness/harness.js";
|
import {
|
||||||
|
GlobalTestState,
|
||||||
|
MerchantApiClient,
|
||||||
|
MerchantPrivateApi,
|
||||||
|
runTestWithState,
|
||||||
|
} from "./harness/harness.js";
|
||||||
import { getTestInfo, runTests } from "./integrationtests/testrunner.js";
|
import { getTestInfo, runTests } from "./integrationtests/testrunner.js";
|
||||||
import { lintExchangeDeployment } from "./lint.js";
|
import { lintExchangeDeployment } from "./lint.js";
|
||||||
import { runEnvFull } from "./env-full.js";
|
import { runEnvFull } from "./env-full.js";
|
||||||
import { clk } from "@gnu-taler/taler-util/clk";
|
import { clk } from "@gnu-taler/taler-util/clk";
|
||||||
|
import { createPlatformHttpLib } from "@gnu-taler/taler-util/http";
|
||||||
|
import { BankAccessApiClient } from "@gnu-taler/taler-wallet-core";
|
||||||
|
|
||||||
const logger = new Logger("taler-harness:index.ts");
|
const logger = new Logger("taler-harness:index.ts");
|
||||||
|
|
||||||
@ -152,10 +162,123 @@ advancedCli
|
|||||||
await runTestWithState(testState, runEnv1, "env1", true);
|
await runTestWithState(testState, runEnv1, "env1", true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sandcastleCli = testingCli.subcommand("sandcastleArgs", "sandcastle", {
|
||||||
|
help: "Subcommands for handling GNU Taler sandcastle deployments.",
|
||||||
|
});
|
||||||
|
|
||||||
const deploymentCli = testingCli.subcommand("deploymentArgs", "deployment", {
|
const deploymentCli = testingCli.subcommand("deploymentArgs", "deployment", {
|
||||||
help: "Subcommands for handling GNU Taler deployments.",
|
help: "Subcommands for handling GNU Taler deployments.",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
deploymentCli
|
||||||
|
.subcommand("tipTopup", "tip-topup")
|
||||||
|
.requiredOption("merchantBaseUrl", ["--merchant-url"], clk.STRING)
|
||||||
|
.requiredOption("exchangeBaseUrl", ["--exchange-url"], clk.STRING)
|
||||||
|
.requiredOption("merchantApikey", ["--merchant-apikey"], clk.STRING)
|
||||||
|
.requiredOption("bankAccessUrl", ["--bank-access-url"], clk.STRING)
|
||||||
|
.requiredOption("bankAccount", ["--bank-account"], clk.STRING)
|
||||||
|
.requiredOption("bankPassword", ["--bank-password"], clk.STRING)
|
||||||
|
.requiredOption("wireMethod", ["--wire-method"], clk.STRING)
|
||||||
|
.requiredOption("amount", ["--amount"], clk.STRING)
|
||||||
|
.action(async (args) => {
|
||||||
|
const amount = args.tipTopup.amount;
|
||||||
|
|
||||||
|
const merchantClient = new MerchantApiClient(
|
||||||
|
args.tipTopup.merchantBaseUrl,
|
||||||
|
{
|
||||||
|
method: "token",
|
||||||
|
token: args.tipTopup.merchantApikey,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await merchantClient.getPrivateInstanceInfo();
|
||||||
|
console.log(res);
|
||||||
|
|
||||||
|
const tipReserveResp = await merchantClient.createTippingReserve({
|
||||||
|
exchange_url: args.tipTopup.exchangeBaseUrl,
|
||||||
|
initial_balance: amount,
|
||||||
|
wire_method: args.tipTopup.wireMethod,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(tipReserveResp);
|
||||||
|
|
||||||
|
const bankAccessApiClient = new BankAccessApiClient({
|
||||||
|
baseUrl: args.tipTopup.bankAccessUrl,
|
||||||
|
username: args.tipTopup.bankAccount,
|
||||||
|
password: args.tipTopup.bankPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
const paytoUri = addPaytoQueryParams(tipReserveResp.payto_uri, {
|
||||||
|
message: `tip-reserve ${tipReserveResp.reserve_pub}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("payto URI:", paytoUri);
|
||||||
|
|
||||||
|
const transactions = await bankAccessApiClient.getTransactions();
|
||||||
|
console.log("transactions:", j2s(transactions));
|
||||||
|
|
||||||
|
await bankAccessApiClient.createTransaction({
|
||||||
|
amount,
|
||||||
|
paytoUri,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
deploymentCli
|
||||||
|
.subcommand("tipCleanup", "tip-cleanup")
|
||||||
|
.requiredOption("merchantBaseUrl", ["--merchant-url"], clk.STRING)
|
||||||
|
.requiredOption("merchantApikey", ["--merchant-apikey"], clk.STRING)
|
||||||
|
.flag("dryRun", ["--dry-run"])
|
||||||
|
.action(async (args) => {
|
||||||
|
const merchantClient = new MerchantApiClient(
|
||||||
|
args.tipCleanup.merchantBaseUrl,
|
||||||
|
{
|
||||||
|
method: "token",
|
||||||
|
token: args.tipCleanup.merchantApikey,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await merchantClient.getPrivateInstanceInfo();
|
||||||
|
console.log(res);
|
||||||
|
|
||||||
|
const tipRes = await merchantClient.getPrivateTipReserves();
|
||||||
|
console.log(tipRes);
|
||||||
|
|
||||||
|
for (const reserve of tipRes.reserves) {
|
||||||
|
if (Amounts.isZero(reserve.exchange_initial_amount)) {
|
||||||
|
if (args.tipCleanup.dryRun) {
|
||||||
|
logger.info(`dry run, would purge reserve ${reserve}`);
|
||||||
|
} else {
|
||||||
|
await merchantClient.deleteTippingReserve({
|
||||||
|
reservePub: reserve.reserve_pub,
|
||||||
|
purge: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: Now delete reserves that are not filled yet
|
||||||
|
});
|
||||||
|
|
||||||
|
deploymentCli
|
||||||
|
.subcommand("tipStatus", "tip-status")
|
||||||
|
.requiredOption("merchantBaseUrl", ["--merchant-url"], clk.STRING)
|
||||||
|
.requiredOption("merchantApikey", ["--merchant-apikey"], clk.STRING)
|
||||||
|
.action(async (args) => {
|
||||||
|
const merchantClient = new MerchantApiClient(
|
||||||
|
args.tipStatus.merchantBaseUrl,
|
||||||
|
{
|
||||||
|
method: "token",
|
||||||
|
token: args.tipStatus.merchantApikey,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await merchantClient.getPrivateInstanceInfo();
|
||||||
|
console.log(res);
|
||||||
|
|
||||||
|
const tipRes = await merchantClient.getPrivateTipReserves();
|
||||||
|
console.log(j2s(tipRes));
|
||||||
|
});
|
||||||
|
|
||||||
deploymentCli
|
deploymentCli
|
||||||
.subcommand("lintExchange", "lint-exchange", {
|
.subcommand("lintExchange", "lint-exchange", {
|
||||||
help: "Run checks on the exchange deployment.",
|
help: "Run checks on the exchange deployment.",
|
||||||
|
@ -59,7 +59,7 @@ export interface HttpRequestOptions {
|
|||||||
*/
|
*/
|
||||||
cancellationToken?: CancellationToken;
|
cancellationToken?: CancellationToken;
|
||||||
|
|
||||||
body?: string | ArrayBuffer | Record<string, unknown>;
|
body?: string | ArrayBuffer | object;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -344,9 +344,8 @@ export function getExpiry(
|
|||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface HttpLibArgs {
|
export interface HttpLibArgs {
|
||||||
enableThrottling?: boolean,
|
enableThrottling?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function encodeBody(body: any): ArrayBuffer {
|
export function encodeBody(body: any): ArrayBuffer {
|
||||||
@ -364,3 +363,16 @@ export function encodeBody(body: any): ArrayBuffer {
|
|||||||
}
|
}
|
||||||
throw new TypeError("unsupported request body type");
|
throw new TypeError("unsupported request body type");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDefaultHeaders(method: string): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (method === "POST" || method === "PUT" || method === "PATCH") {
|
||||||
|
// Default to JSON if we have a body
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
}
|
||||||
|
|
||||||
|
headers["Accept"] = "application/json";
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
@ -23,7 +23,7 @@ import * as http from "node:http";
|
|||||||
import * as https from "node:https";
|
import * as https from "node:https";
|
||||||
import { RequestOptions } from "node:http";
|
import { RequestOptions } from "node:http";
|
||||||
import { TalerError } from "./errors.js";
|
import { TalerError } from "./errors.js";
|
||||||
import { encodeBody, HttpLibArgs } from "./http-common.js";
|
import { encodeBody, getDefaultHeaders, HttpLibArgs } from "./http-common.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_REQUEST_TIMEOUT_MS,
|
DEFAULT_REQUEST_TIMEOUT_MS,
|
||||||
Headers,
|
Headers,
|
||||||
@ -85,8 +85,7 @@ export class HttpLibImpl implements HttpRequestLibrary {
|
|||||||
timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS;
|
timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = { ...opt?.headers };
|
const requestHeadersMap = { ...getDefaultHeaders(method), ...opt?.headers };
|
||||||
headers["Content-Type"] = "application/json";
|
|
||||||
|
|
||||||
let reqBody: ArrayBuffer | undefined;
|
let reqBody: ArrayBuffer | undefined;
|
||||||
|
|
||||||
@ -114,7 +113,7 @@ export class HttpLibImpl implements HttpRequestLibrary {
|
|||||||
host: parsedUrl.hostname,
|
host: parsedUrl.hostname,
|
||||||
method: method,
|
method: method,
|
||||||
path,
|
path,
|
||||||
headers: opt?.headers,
|
headers: requestHeadersMap,
|
||||||
};
|
};
|
||||||
|
|
||||||
const chunks: Uint8Array[] = [];
|
const chunks: Uint8Array[] = [];
|
||||||
|
@ -21,7 +21,7 @@
|
|||||||
*/
|
*/
|
||||||
import { Logger } from "@gnu-taler/taler-util";
|
import { Logger } from "@gnu-taler/taler-util";
|
||||||
import { TalerError } from "./errors.js";
|
import { TalerError } from "./errors.js";
|
||||||
import { encodeBody, HttpLibArgs } from "./http-common.js";
|
import { encodeBody, getDefaultHeaders, HttpLibArgs } from "./http-common.js";
|
||||||
import {
|
import {
|
||||||
Headers,
|
Headers,
|
||||||
HttpRequestLibrary,
|
HttpRequestLibrary,
|
||||||
@ -54,7 +54,7 @@ export class HttpLibImpl implements HttpRequestLibrary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fetch(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> {
|
async fetch(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> {
|
||||||
const method = opt?.method ?? "GET";
|
const method = (opt?.method ?? "GET").toUpperCase();
|
||||||
|
|
||||||
logger.trace(`Requesting ${method} ${url}`);
|
logger.trace(`Requesting ${method} ${url}`);
|
||||||
|
|
||||||
@ -72,19 +72,18 @@ export class HttpLibImpl implements HttpRequestLibrary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let data: ArrayBuffer | undefined = undefined;
|
let data: ArrayBuffer | undefined = undefined;
|
||||||
let headers: string[] = [];
|
const requestHeadersMap = { ...getDefaultHeaders(method), ...opt?.headers };
|
||||||
if (opt?.headers) {
|
let headersList: string[] = [];
|
||||||
for (let headerName of Object.keys(opt.headers)) {
|
for (let headerName of Object.keys(requestHeadersMap)) {
|
||||||
headers.push(`${headerName}: ${opt.headers[headerName]}`);
|
headersList.push(`${headerName}: ${requestHeadersMap[headerName]}`);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (method.toUpperCase() === "POST") {
|
if (method === "POST") {
|
||||||
data = encodeBody(opt?.body);
|
data = encodeBody(opt?.body);
|
||||||
}
|
}
|
||||||
const res = await qjsOs.fetchHttp(url, {
|
const res = await qjsOs.fetchHttp(url, {
|
||||||
method,
|
method,
|
||||||
data,
|
data,
|
||||||
headers,
|
headers: headersList,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
requestMethod: method,
|
requestMethod: method,
|
||||||
|
@ -37,6 +37,7 @@ import {
|
|||||||
TalerErrorCode,
|
TalerErrorCode,
|
||||||
} from "@gnu-taler/taler-util";
|
} from "@gnu-taler/taler-util";
|
||||||
import {
|
import {
|
||||||
|
createPlatformHttpLib,
|
||||||
HttpRequestLibrary,
|
HttpRequestLibrary,
|
||||||
readSuccessResponseJsonOrThrow,
|
readSuccessResponseJsonOrThrow,
|
||||||
} from "@gnu-taler/taler-util/http";
|
} from "@gnu-taler/taler-util/http";
|
||||||
@ -277,3 +278,62 @@ export namespace BankAccessApi {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BankAccessApiClientArgs {
|
||||||
|
baseUrl: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BankAccessApiCreateTransactionRequest {
|
||||||
|
amount: AmountString;
|
||||||
|
paytoUri: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BankAccessApiClient {
|
||||||
|
httpLib = createPlatformHttpLib();
|
||||||
|
|
||||||
|
constructor(private args: BankAccessApiClientArgs) {}
|
||||||
|
|
||||||
|
async getTransactions(): Promise<void> {
|
||||||
|
const reqUrl = new URL(
|
||||||
|
`accounts/${this.args.username}/transactions`,
|
||||||
|
this.args.baseUrl,
|
||||||
|
);
|
||||||
|
const authHeaderValue = makeBasicAuthHeader(
|
||||||
|
this.args.username,
|
||||||
|
this.args.password,
|
||||||
|
);
|
||||||
|
const resp = await this.httpLib.fetch(reqUrl.href, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeaderValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await readSuccessResponseJsonOrThrow(resp, codecForAny());
|
||||||
|
logger.info(`result: ${j2s(res)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTransaction(
|
||||||
|
req: BankAccessApiCreateTransactionRequest,
|
||||||
|
): Promise<any> {
|
||||||
|
const reqUrl = new URL(
|
||||||
|
`accounts/${this.args.username}/transactions`,
|
||||||
|
this.args.baseUrl,
|
||||||
|
);
|
||||||
|
const authHeaderValue = makeBasicAuthHeader(
|
||||||
|
this.args.username,
|
||||||
|
this.args.password,
|
||||||
|
);
|
||||||
|
const resp = await this.httpLib.fetch(reqUrl.href, {
|
||||||
|
method: "POST",
|
||||||
|
body: req,
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeaderValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return await readSuccessResponseJsonOrThrow(resp, codecForAny());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user