@@ -42,9 +42,11 @@ func (a Attrs) get(key string, defaultVal any) any {
|
||||
if a == nil {
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
if v, ok := a[key]; ok {
|
||||
return v
|
||||
}
|
||||
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
@@ -52,6 +54,7 @@ func (a Attrs) getString(key string, defaultVal string) string {
|
||||
if v, ok := a.get(key, defaultVal).(string); ok {
|
||||
return v
|
||||
}
|
||||
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
@@ -59,11 +62,13 @@ func (a Attrs) getStringPtr(key string) *string {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if v, ok := a[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return &s
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -71,6 +76,7 @@ func (a Attrs) getInt(key string, defaultVal int) int {
|
||||
if a == nil {
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
if v, ok := a[key]; ok {
|
||||
switch val := v.(type) {
|
||||
case int:
|
||||
@@ -81,6 +87,7 @@ func (a Attrs) getInt(key string, defaultVal int) int {
|
||||
return int(val)
|
||||
}
|
||||
}
|
||||
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
@@ -88,11 +95,13 @@ func (a Attrs) getBool(key string, defaultVal bool) bool {
|
||||
if a == nil {
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
if v, ok := a[key]; ok {
|
||||
if b, ok := v.(bool); ok {
|
||||
return b
|
||||
}
|
||||
}
|
||||
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
@@ -167,9 +176,11 @@ func CreateThirdParty(c *testutil.Client, attrs ...Attrs) string {
|
||||
if desc := a.getStringPtr("description"); desc != nil {
|
||||
input["description"] = *desc
|
||||
}
|
||||
|
||||
if url := a.getStringPtr("websiteUrl"); url != nil {
|
||||
input["websiteUrl"] = *url
|
||||
}
|
||||
|
||||
if cat := a.getStringPtr("category"); cat != nil {
|
||||
input["category"] = *cat
|
||||
}
|
||||
@@ -348,6 +359,7 @@ func CreateTask(c *testutil.Client, measureID *string, attrs ...Attrs) string {
|
||||
if measureID != nil {
|
||||
input["measureId"] = *measureID
|
||||
}
|
||||
|
||||
if desc := a.getStringPtr("description"); desc != nil {
|
||||
input["description"] = *desc
|
||||
}
|
||||
@@ -951,6 +963,7 @@ func CreateAccessSource(c *testutil.Client, organizationID string, attrs ...Attr
|
||||
if csvData := a.getStringPtr("csvData"); csvData != nil {
|
||||
input["csvData"] = *csvData
|
||||
}
|
||||
|
||||
if connectorID := a.getStringPtr("connectorId"); connectorID != nil {
|
||||
input["connectorId"] = *connectorID
|
||||
}
|
||||
@@ -1446,7 +1459,9 @@ func ReportDetectedResources(c *testutil.Client, bannerID string, count int) {
|
||||
url := fmt.Sprintf("%s/api/cookie-banner/v1/%s/report", c.BaseURL(), bannerID)
|
||||
resp, err := http.Post(url, "application/json", bytes.NewReader(body))
|
||||
require.NoError(c.T, err, "report detected resources request failed")
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
require.Equal(c.T, http.StatusNoContent, resp.StatusCode, "report detected resources unexpected status")
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ func AssertTimestampsOnUpdate(t *testing.T, createdAt, updatedAt, originalCreate
|
||||
|
||||
func AssertOptionalStringEqual(t *testing.T, expected, actual *string, fieldName string) {
|
||||
t.Helper()
|
||||
|
||||
if expected == nil {
|
||||
assert.Nil(t, actual, "%s should be nil", fieldName)
|
||||
} else {
|
||||
@@ -95,6 +96,7 @@ func AssertOrderedAscending[T cmp.Ordered](t *testing.T, values []T, fieldName s
|
||||
|
||||
func AssertOrderedDescending[T cmp.Ordered](t *testing.T, values []T, fieldName string) {
|
||||
t.Helper()
|
||||
|
||||
reversed := slices.Clone(values)
|
||||
slices.Reverse(reversed)
|
||||
assert.True(t, slices.IsSorted(reversed), "%s should be in descending order, got: %v", fieldName, values)
|
||||
@@ -102,6 +104,7 @@ func AssertOrderedDescending[T cmp.Ordered](t *testing.T, values []T, fieldName
|
||||
|
||||
func AssertTimesOrderedAscending(t *testing.T, times []time.Time, fieldName string) {
|
||||
t.Helper()
|
||||
|
||||
isSorted := slices.IsSortedFunc(times, func(a, b time.Time) int {
|
||||
return a.Compare(b)
|
||||
})
|
||||
@@ -110,6 +113,7 @@ func AssertTimesOrderedAscending(t *testing.T, times []time.Time, fieldName stri
|
||||
|
||||
func AssertTimesOrderedDescending(t *testing.T, times []time.Time, fieldName string) {
|
||||
t.Helper()
|
||||
|
||||
isSorted := slices.IsSortedFunc(times, func(a, b time.Time) int {
|
||||
return b.Compare(a)
|
||||
})
|
||||
@@ -118,9 +122,11 @@ func AssertTimesOrderedDescending(t *testing.T, times []time.Time, fieldName str
|
||||
|
||||
func AssertNodeNotAccessible(t *testing.T, err error, nodeIsNil bool, resourceType string) {
|
||||
t.Helper()
|
||||
|
||||
if err == nil {
|
||||
assert.True(t, nodeIsNil, "should not be able to access %s from another org", resourceType)
|
||||
}
|
||||
|
||||
// If there's an error, that's also acceptable (access denied)
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
func generateUniqueID() string {
|
||||
randomBytes := make([]byte, 4)
|
||||
_, _ = rand.Read(randomBytes)
|
||||
|
||||
return fmt.Sprintf("%d-%s", time.Now().UnixNano(), hex.EncodeToString(randomBytes))
|
||||
}
|
||||
|
||||
@@ -139,6 +140,7 @@ func (c *Client) SetupTestUserInOrg(ownerClient *Client) {
|
||||
c.userID = identityID
|
||||
c.profileID = profileID
|
||||
ownerClient.inviteUser(profileID)
|
||||
|
||||
token := c.getActivationToken(email)
|
||||
passwordToken := c.activateUser(token)
|
||||
c.resetPassword(password, passwordToken)
|
||||
@@ -285,12 +287,14 @@ func (c *Client) updateOwnMembershipRole(role coredata.MembershipRole) {
|
||||
require.NoError(c.T, err, "cannot query organization members")
|
||||
|
||||
var membershipID string
|
||||
|
||||
for _, edge := range qResult.Node.Members.Edges {
|
||||
if edge.Node.Identity.ID == c.userID.String() {
|
||||
membershipID = edge.Node.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
require.NotEmpty(c.T, membershipID, "membership not found for user")
|
||||
|
||||
// Update the role
|
||||
@@ -422,6 +426,7 @@ func (c *Client) getActivationToken(email string) string {
|
||||
|
||||
c.T.Logf("activation token not found")
|
||||
c.T.FailNow()
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
@@ -50,9 +50,11 @@ func (e GraphQLError) Code() string {
|
||||
if e.Extensions == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if code, ok := e.Extensions["code"].(string); ok {
|
||||
return code
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -62,9 +64,11 @@ func (e GraphQLErrors) Error() string {
|
||||
if len(e) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if len(e) == 1 {
|
||||
return e[0].Message
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s (and %d more errors)", e[0].Message, len(e)-1)
|
||||
}
|
||||
|
||||
@@ -83,12 +87,14 @@ func (c *Client) doWithEndpoint(endpoint string, query string, variables map[str
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
@@ -160,6 +166,7 @@ func (c *Client) ExecuteShouldFail(query string, variables map[string]any) error
|
||||
c.T.Helper()
|
||||
_, err := c.Do(query, variables)
|
||||
require.Error(c.T, err, "expected GraphQL request to fail but it succeeded")
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -188,6 +195,7 @@ func (c *Client) ExecuteWithFiles(query string, variables map[string]any, files
|
||||
func (c *Client) executeMultipart(query string, variables map[string]any, files map[string]UploadFile, result any) error {
|
||||
// Create multipart writer using standard library
|
||||
var buf bytes.Buffer
|
||||
|
||||
writer := multipart.NewWriter(&buf)
|
||||
|
||||
// Build the operations JSON
|
||||
@@ -195,6 +203,7 @@ func (c *Client) executeMultipart(query string, variables map[string]any, files
|
||||
"query": query,
|
||||
"variables": variables,
|
||||
}
|
||||
|
||||
operationsJSON, err := json.Marshal(operations)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot marshal operations: %w", err)
|
||||
@@ -207,14 +216,17 @@ func (c *Client) executeMultipart(query string, variables map[string]any, files
|
||||
|
||||
// Build the map for file variables (sorted for deterministic order)
|
||||
fileMap := make(map[string][]string)
|
||||
|
||||
fileOrder := make([]string, 0, len(files))
|
||||
for path := range files {
|
||||
fileOrder = append(fileOrder, path)
|
||||
}
|
||||
|
||||
// Sort for deterministic ordering
|
||||
for i, path := range fileOrder {
|
||||
fileMap[fmt.Sprintf("%d", i)] = []string{"variables." + path}
|
||||
}
|
||||
|
||||
mapJSON, err := json.Marshal(fileMap)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot marshal map: %w", err)
|
||||
@@ -239,6 +251,7 @@ func (c *Client) executeMultipart(query string, variables map[string]any, files
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create file part %s: %w", path, err)
|
||||
}
|
||||
|
||||
if _, err := part.Write(file.Content); err != nil {
|
||||
return fmt.Errorf("cannot write file content %s: %w", path, err)
|
||||
}
|
||||
@@ -253,6 +266,7 @@ func (c *Client) executeMultipart(query string, variables map[string]any, files
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
// Execute request
|
||||
@@ -260,6 +274,7 @@ func (c *Client) executeMultipart(query string, variables map[string]any, files
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
|
||||
@@ -45,12 +45,14 @@ func (c *Client) SearchMails(query string) (*MailpitSearchResponse, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
@@ -75,12 +77,14 @@ func (c *Client) CheckMessageLinks(messageID string) (*MailpitLinkCheckResponse,
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
|
||||
@@ -128,6 +128,7 @@ func (mc *MCPClient) doRequest(method string, params any) (json.RawMessage, erro
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
req.Header.Set("Authorization", "Bearer "+mc.apiToken)
|
||||
|
||||
if mc.sessionID != "" {
|
||||
req.Header.Set("Mcp-Session-Id", mc.sessionID)
|
||||
}
|
||||
@@ -136,6 +137,7 @@ func (mc *MCPClient) doRequest(method string, params any) (json.RawMessage, erro
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
@@ -198,6 +200,7 @@ func (mc *MCPClient) CallTool(toolName string, args map[string]any) *MCPToolResu
|
||||
require.NoError(mc.t, err, "MCP tools/call %s failed", toolName)
|
||||
|
||||
var toolResult MCPToolResult
|
||||
|
||||
err = json.Unmarshal(result, &toolResult)
|
||||
require.NoError(mc.t, err, "cannot unmarshal tool result for %s", toolName)
|
||||
|
||||
@@ -212,6 +215,7 @@ func (mc *MCPClient) CallToolExpectToolError(toolName string, args map[string]an
|
||||
require.NotEmpty(mc.t, tr.Content, "tool %s returned no content", toolName)
|
||||
|
||||
var text string
|
||||
|
||||
err := json.Unmarshal(tr.Content[0].Text, &text)
|
||||
require.NoError(mc.t, err, "cannot unmarshal error text for %s", toolName)
|
||||
|
||||
@@ -227,6 +231,7 @@ func (mc *MCPClient) CallToolInto(toolName string, args map[string]any, dest any
|
||||
// The text field in MCP content is a JSON-encoded string of the output.
|
||||
// First unmarshal the raw JSON to get the string.
|
||||
var textStr string
|
||||
|
||||
err := json.Unmarshal(tr.Content[0].Text, &textStr)
|
||||
require.NoError(mc.t, err, "cannot unmarshal text content for %s", toolName)
|
||||
|
||||
|
||||
@@ -129,6 +129,7 @@ func postForm(
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot post form: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
@@ -153,6 +154,7 @@ func postJSON(
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot post json: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
@@ -181,6 +183,7 @@ func getJSON(
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute request: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
@@ -213,6 +216,7 @@ func postFormWithBasicAuth(
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute request: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
@@ -298,10 +302,12 @@ func OAuth2Authorize(
|
||||
}
|
||||
|
||||
reqURL := oauth2BaseURL(c) + "/authorize?" + params.Encode()
|
||||
|
||||
resp, err := noRedirectClient.Get(reqURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot get authorize: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
@@ -521,6 +527,7 @@ func OAuth2TokenWithDeviceCode(
|
||||
if err := json.Unmarshal(raw.Body, &result); err != nil {
|
||||
return nil, nil, raw, fmt.Errorf("cannot decode token response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil, raw, nil
|
||||
}
|
||||
|
||||
@@ -799,6 +806,7 @@ func GeneratePKCE() (verifier, challenge string) {
|
||||
for i := range b {
|
||||
b[i] = charset[rand.IntN(len(charset))]
|
||||
}
|
||||
|
||||
verifier = string(b)
|
||||
|
||||
h := sha256.Sum256([]byte(verifier))
|
||||
@@ -814,11 +822,14 @@ func IsConsentRedirect(resp *OAuth2HTTPResponse) bool {
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
return false
|
||||
}
|
||||
|
||||
loc := resp.Header.Get("Location")
|
||||
|
||||
u, err := url.Parse(loc)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return u.Query().Get("consent_id") != ""
|
||||
}
|
||||
|
||||
@@ -831,14 +842,17 @@ func ExtractConsentIDFromResponse(resp *OAuth2HTTPResponse) (string, error) {
|
||||
if loc == "" {
|
||||
return "", fmt.Errorf("no Location header in redirect response")
|
||||
}
|
||||
|
||||
u, err := url.Parse(loc)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot parse redirect url: %w", err)
|
||||
}
|
||||
|
||||
consentID := u.Query().Get("consent_id")
|
||||
if consentID == "" {
|
||||
return "", fmt.Errorf("no consent_id in redirect url: %s", loc)
|
||||
}
|
||||
|
||||
return consentID, nil
|
||||
}
|
||||
|
||||
@@ -850,12 +864,14 @@ func ExtractConsentID(body []byte) (string, error) {
|
||||
s := string(body)
|
||||
|
||||
needle := `name="consent_id" value="`
|
||||
|
||||
idx := strings.Index(s, needle)
|
||||
if idx == -1 {
|
||||
return "", fmt.Errorf("consent_id not found in page")
|
||||
}
|
||||
|
||||
start := idx + len(needle)
|
||||
|
||||
end := strings.Index(s[start:], `"`)
|
||||
if end == -1 {
|
||||
return "", fmt.Errorf("malformed consent_id value")
|
||||
@@ -890,6 +906,7 @@ func OAuth2PerformAuthorizationCodeFlow(
|
||||
require.NoError(t, err)
|
||||
|
||||
var code string
|
||||
|
||||
if IsConsentRedirect(authResp) {
|
||||
consentID, err := ExtractConsentIDFromResponse(authResp)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -32,9 +32,11 @@ func ProseMirrorTextDoc(text string) string {
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
b, err := json.Marshal(doc)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return string(b)
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ func (s *switchableWriter) Write(p []byte) (int, error) {
|
||||
s.mu.Lock()
|
||||
w := s.w
|
||||
s.mu.Unlock()
|
||||
|
||||
return w.Write(p)
|
||||
}
|
||||
|
||||
@@ -104,6 +105,7 @@ func Setup() {
|
||||
cmd.Stderr = os.Stderr
|
||||
} else {
|
||||
var buf bytes.Buffer
|
||||
|
||||
testEnv.outputBuf = &buf
|
||||
sw := &switchableWriter{w: &buf}
|
||||
testEnv.outputWriter = sw
|
||||
@@ -128,14 +130,18 @@ func Setup() {
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := waitForServer(ctx, testEnv.BaseURL+"/api/console/v1/graphql", 30*time.Second); err != nil {
|
||||
testEnv.dumpOutputOnFailure("API server failed to start", err)
|
||||
_ = testEnv.cmd.Process.Kill()
|
||||
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := waitForServer(ctx, testEnv.MailpitBaseURL+"/api/v1/messages", 30*time.Second); err != nil {
|
||||
testEnv.dumpOutputOnFailure("MailPit server failed to start", err)
|
||||
_ = testEnv.cmd.Process.Kill()
|
||||
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -161,11 +167,13 @@ func (e *TestEnv) dumpOutputOnFailure(context string, err error) {
|
||||
|
||||
if e.outputBuf != nil && e.outputBuf.Len() > 0 {
|
||||
output := e.outputBuf.Bytes()
|
||||
|
||||
const maxTail = 10_000
|
||||
if len(output) > maxTail {
|
||||
fmt.Fprintf(os.Stderr, "e2etest: (showing last %d bytes of output)\n", maxTail)
|
||||
output = output[len(output)-maxTail:]
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "--- probod output start ---\n%s\n--- probod output end ---\n", output)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "e2etest: no captured output available\n")
|
||||
@@ -224,6 +232,7 @@ func GetBaseURL() string {
|
||||
if testEnv == nil {
|
||||
return "http://localhost:8080"
|
||||
}
|
||||
|
||||
return testEnv.BaseURL
|
||||
}
|
||||
|
||||
@@ -231,6 +240,7 @@ func GetMailpitBaseURL() string {
|
||||
if testEnv == nil {
|
||||
return "http://localhost:8025"
|
||||
}
|
||||
|
||||
return testEnv.MailpitBaseURL
|
||||
}
|
||||
|
||||
@@ -305,6 +315,7 @@ func generateConfig() (string, error) {
|
||||
if v, ok := env[key]; ok {
|
||||
return v
|
||||
}
|
||||
|
||||
return os.Getenv(key)
|
||||
})
|
||||
|
||||
@@ -317,6 +328,7 @@ func generateConfig() (string, error) {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create temp dir: %w", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(tmpDir, "probod.yml")
|
||||
|
||||
if err := bootstrap.WriteConfig(cfg, path); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user