Skip to content

Commit

Permalink
Apply Validation Logic to gTag (#1693)
Browse files Browse the repository at this point in the history
* Remove textbox when gtag.js is checkeked

* Validate for firebaseAppId OR measurementID

* Fix schema validation for app_instance_id vs client_id

* Try to remove weirdly formatted error messaging

* Item json schema validations for items array
  • Loading branch information
marycodes2 authored Aug 2, 2023
1 parent c8da06f commit ec43cff
Show file tree
Hide file tree
Showing 9 changed files with 413 additions and 129 deletions.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -18,55 +18,72 @@ const RESERVED_USER_PROPERTY_NAMES = [

// formatCheckLib provides additional validations for payload not included in
// the schema validations. All checks are consistent with Firebase documentation.
export const formatCheckLib = (payload, firebaseAppId, api_secret) => {
export const formatCheckLib = (payload, instanceId, api_secret, useFirebase) => {
let errors: ValidationMessage[] = []

const appInstanceIdErrors = isValidAppInstanceId(payload)
const appOrClientErrors = isValidAppOrClientId(payload, useFirebase)
const eventNameErrors = isValidEventName(payload)
const userPropertyNameErrors = isValidUserPropertyName(payload)
const currencyErrors = isValidCurrencyType(payload)
const emptyItemsErrors = isItemsEmpty(payload)
const itemsRequiredKeyErrors = itemsHaveRequiredKey(payload)
const firebaseAppIdErrors = isfirebaseAppIdValid(firebaseAppId)
const instanceIdErrors = isInstanceIdValid(instanceId, useFirebase)
const apiSecretErrors = isApiSecretNotNull(api_secret)
const sizeErrors = isTooBig(payload)

return [
...errors,
...appInstanceIdErrors,
...appOrClientErrors,
...eventNameErrors,
...userPropertyNameErrors,
...currencyErrors,
...emptyItemsErrors,
...itemsRequiredKeyErrors,
...firebaseAppIdErrors,
...instanceIdErrors,
...apiSecretErrors,
...sizeErrors,
]
}

const isValidAppInstanceId = (payload) => {
const isValidAppOrClientId = (payload, useFirebase) => {
let errors: ValidationMessage[] = []
const appInstanceId = payload.app_instance_id
const clientId = payload.client_id

if (appInstanceId) {
if (appInstanceId?.length !== 32) {
if (useFirebase) {
if (appInstanceId) {
if (appInstanceId?.length !== 32) {
errors.push({
description: `Measurement app_instance_id is expected to be a 32 digit hexadecimal number but was [${appInstanceId.length}] digits.`,
validationCode: "value_invalid",
fieldPath: "app_instance_id"
})
}

if (!appInstanceId.match(/^[A-Fa-f0-9]+$/)) {
let nonChars = appInstanceId.split('').filter((letter: string)=> {
return (!/[0-9A-Fa-f]/.test(letter))
})

errors.push({
description: `Measurement app_instance_id contains non hexadecimal character [${nonChars[0]}].`,
validationCode: "value_invalid",
fieldPath: "app_instance_id"
})
}
} else {
errors.push({
description: `Measurement app_instance_id is expected to be a 32 digit hexadecimal number but was [${appInstanceId.length}] digits.`,
description: "Measurement requires an app_instance_id.",
validationCode: "value_invalid",
fieldPath: "app_instance_id"
})
}

if (!appInstanceId.match(/^[A-Fa-f0-9]+$/)) {
let nonChars = appInstanceId.split('').filter((letter: string)=> {
return (!/[0-9A-Fa-f]/.test(letter))
})

} else {
if (!clientId) {
errors.push({
description: `Measurement app_instance_id contains non hexadecimal character [${nonChars[0]}].`,
description: "Measurement requires a client_id.",
validationCode: "value_invalid",
fieldPath: "app_instance_id"
fieldPath: "client_id"
})
}
}
Expand Down Expand Up @@ -182,15 +199,27 @@ const requiredKeysEmpty = (itemsObj) => {
return !(itemsObj.item_id || itemsObj.item_name)
}

const isfirebaseAppIdValid = (firebaseAppId) => {
const isInstanceIdValid = (instanceId, useFirebase) => {
let errors: ValidationMessage[] = []
const firebaseAppId = instanceId?.firebase_app_id
const measurementId = instanceId?.measurement_id

if (firebaseAppId && !firebaseAppId.match(/[0-9]:[0-9]+:[a-zA-Z]+:[a-zA-Z0-9]+$/)) {
errors.push({
description: `${firebaseAppId} does not follow firebase_app_id pattern of X:XX:XX:XX at path`,
validationCode: "value_invalid",
fieldPath: "firebase_app_id"
})
if (useFirebase) {
if (firebaseAppId && !firebaseAppId.match(/[0-9]:[0-9]+:[a-zA-Z]+:[a-zA-Z0-9]+$/)) {
errors.push({
description: `${firebaseAppId} does not follow firebase_app_id pattern of X:XX:XX:XX at path`,
validationCode: "value_invalid",
fieldPath: "firebase_app_id"
})
}
} else {
if (!measurementId) {
errors.push({
description: "Unable to find non-empty parameter [measurement_id] value in request.",
validationCode: "value_invalid",
fieldPath: "measurement_id"
})
}
}

return errors
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ const API_DOC_EVENT_URL = 'https://developers.google.com/analytics/devguides/col
const API_DOC_USER_PROPERTIES = 'https://developers.google.com/analytics/devguides/collection/protocol/ga4/user-properties?hl=en&client_type=firebase'
const API_DOC_SENDING_EVENTS_URL = 'https://developers.google.com/analytics/devguides/collection/protocol/ga4/sending-events?hl=en&client_type=firebase'
const API_DOC_JSON_POST_BODY = 'https://developers.google.com/analytics/devguides/collection/protocol/ga4/reference?hl=en&client_type=firebase#payload_post_body'
const API_DOC_GTAG = 'https://developers.google.com/analytics/devguides/collection/protocol/ga4/sending-events?client_type=gtag#required_parameters'

const BASE_PAYLOAD_ATTRIBUTES = ['app_instance_id', 'api_secret', 'firebase_app_id', 'user_id', 'timestamp_micros', 'user_properties', 'non_personalized_ads']

// formats error messages for clarity; add documentation to each error
export const formatErrorMessages = (errors, payload) => {
export const formatErrorMessages = (errors, payload, useFirebase) => {
const formattedErrors = errors.map(error => {
const { description, fieldPath } = error

Expand All @@ -28,6 +29,7 @@ export const formatErrorMessages = (errors, payload) => {
error['description'] = description.slice(0, end_index) + ALPHA_NUMERIC_OVERRIDE

return error

} else if (BASE_PAYLOAD_ATTRIBUTES.includes(fieldPath?.slice(2))) {
error['fieldPath'] = fieldPath.slice(2)

Expand All @@ -39,20 +41,22 @@ export const formatErrorMessages = (errors, payload) => {
})

const documentedErrors = formattedErrors.map(error => {
error['documentation'] = addDocumentation(error, payload)
error['documentation'] = addDocumentation(error, payload, useFirebase)
return error
})

return documentedErrors
}

const addDocumentation = (error, payload) => {
const addDocumentation = (error, payload, useFirebase) => {
const { fieldPath, validationCode } = error

if (validationCode === 'max-length-error' || validationCode === 'max-properties-error' || validationCode === 'max-body-size') {
return API_DOC_LIMITATIONS_URL
} else if (fieldPath?.startsWith('#/events/')) {
return API_DOC_EVENT_URL + payload?.events[0]?.name
} else if (!useFirebase && (fieldPath === 'client_id' || fieldPath === 'measurement_id')) {
return API_DOC_GTAG
} else if (BASE_PAYLOAD_ATTRIBUTES.includes(fieldPath)) {
return API_DOC_BASE_PAYLOAD_URL + fieldPath
} else if (fieldPath === '#/user_properties') {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import "jest"
import { invalid } from "moment"
import { ValidationStatus } from "../../types"
import { Validator } from "../validator"
import { baseContentSchema } from "./baseContent"

Expand All @@ -17,45 +19,129 @@ describe("baseContentSchema", () => {
expect(validator.isValid(validInput)).toEqual(true)
})

test("is not valid when an app_instance_id has dashes", () => {
test("is not valid with an additional property", () => {
const invalidInput = {
'app_instance_id': '0239500a-23af-4ab0-a79c-58c4042ea175',
'events': []
'app_instance_id': 'bc17b822c8c84a7e84ac7e010cc2f740',
'events': [{
'name': 'something',
'params': {}
}],
'additionalProperty': 123
}

let validator = new Validator(baseContentSchema)

expect(validator.isValid(invalidInput)).toEqual(false)
})

test("is not valid when an app_instance_id is not 32 chars", () => {
const invalidInput = {
'app_instance_id': 'bc17b822c8c84a7e84ac7e010cc2f74',
'events': []
test("validates specific event names", () => {
const validInput = {
'app_instance_id': 'bc17b822c8c84a7e84ac7e010cc2f740',
'events': [{
'name': 'purchase',
'params': {
'transaction_id': '894982',
'value': 89489,
'currency': 'USD',
'items': [
{
'item_name': 'test'
}
]
}
}],
}

let validator = new Validator(baseContentSchema)

expect(validator.isValid(invalidInput)).toEqual(false)
expect(validator.isValid(validInput)).toEqual(true)
})

test("is not valid with an additional property", () => {
const invalidInput = {
test("validates params don't have reserved suffixes", () => {
const validInput = {
'app_instance_id': 'bc17b822c8c84a7e84ac7e010cc2f740',
'events': [],
'additionalProperty': 123
'events': [{
'name': 'purchase',
'params': {
'transaction_id': '894982',
'value': 89489,
'currency': 'USD',
'ga_test': '123',
'items': [
{
'item_name': 'test'
}
]
}
}],
}

let validator = new Validator(baseContentSchema)

expect(validator.isValid(invalidInput)).toEqual(false)
expect(validator.isValid(validInput)).toEqual(false)
})

test("validates required keys are present for certain events", () => {
const validInput = {
'app_instance_id': 'bc17b822c8c84a7e84ac7e010cc2f740',
'events': [{
'name': 'purchase',
'params': {
'transaction_id': '894982',
'currency': 'USD',
'items': [
{
'item_name': 'test'
}
]
}
}],
}

let validator = new Validator(baseContentSchema)

expect(validator.isValid(validInput)).toEqual(false)
})

test("is not valid without app_instance_id", () => {
const invalidInput = {'events': []}
test("does NOT validate empty item aray for named events, because the error message is too complex and this is validated in formatCheckErrors", () => {
const validInput = {
'app_instance_id': 'bc17b822c8c84a7e84ac7e010cc2f740',
'events': [{
'name': 'purchase',
'params': {
'transaction_id': '894982',
'value': 89489,
'currency': 'USD',
'items': [{}]
}
}],
}

let validator = new Validator(baseContentSchema)

expect(validator.isValid(invalidInput)).toEqual(false)
expect(validator.isValid(validInput)).toEqual(true)
})

test("validates that items don't have reserved name keys", () => {
const validInput = {
'app_instance_id': 'bc17b822c8c84a7e84ac7e010cc2f740',
'events': [{
'name': 'purchase',
'params': {
'transaction_id': '894982',
'value': 89489,
'currency': 'USD',
'items': [
{
'ga_test': 'et'
}
]
}
}],
}

let validator = new Validator(baseContentSchema)

expect(validator.isValid(validInput)).toEqual(false)
})
})
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
// Base JSON Body Content Schema.

// from google3.corp.gtech.ads.infrastructure.mapps_s2s_event_validator.schemas import events
import { userPropertiesSchema } from './userProperties'
import { eventsSchema } from './events'

export const baseContentSchema = {
"type": "object",
"required": ["app_instance_id", "events"],
"required": ["events"],
"additionalProperties": false,
"properties": {
"app_instance_id": {
"type": "string",
"format": "app_instance_id"
},
"client_id": {
"type": "string",
},
"user_id": {
"type": "string"
},
Expand All @@ -25,4 +27,4 @@ export const baseContentSchema = {
},
"events": eventsSchema,
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { buildEvents } from "./schemaBuilder"
import { itemsSchema } from "./eventTypes/items"

export const eventSchema = {
"type": "object",
Expand All @@ -12,7 +13,8 @@ export const eventSchema = {
"pattern": "^[A-Za-z][A-Za-z0-9_]*$",
"maxLength": 40
},
"params": {"type": "object"}
"params": {"type": "object"},
"items": itemsSchema
},
"allOf": buildEvents()
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import { ITEM_FIELDS } from "./fieldDefinitions"

export const itemSchema = {
"type": "object",
"required": [],
"patternProperties": {
".": {
"maxLength": 100
}
},
"propertyNames": {
"maxLength": 40
"maxLength": 40,
"pattern":
"^(?!ga_|google_|firebase_)[A-Za-z][A-Za-z0-9_]*$",
},
"properties": ITEM_FIELDS,
"anyOf": [{
Expand Down
Loading

0 comments on commit ec43cff

Please sign in to comment.