Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -245,20 +245,35 @@ public IEnumerator Identify_AndFlush_FlushReportsOk()
// dropdown before Init rather than upgrading mid-test.
yield return LoadAndInit(initialConsent: SampleAppUi.Consent.Full);

_root!.Q<TextField>(SampleAppUi.IdentityFields.Id).value = "il2cpp-test-user-" + DateTime.UtcNow.Ticks;
_root!.Q<TextField>(SampleAppUi.IdentityFields.Id).value = "email|il2cpp-test-user-" + DateTime.UtcNow.Ticks;
_root.Q<Button>(SampleAppUi.Buttons.Identify).Click();
yield return null;

yield return FlushAndAssertNoErrors();
}

[UnityTest]
public IEnumerator Identify_PassportWithInvalidIdFormat_WarnsAndDropsCall()
{
// Identity type dropdown defaults to Passport; "12345" isn't Passport-shaped.
yield return LoadAndInit(initialConsent: SampleAppUi.Consent.Full);

_root!.Q<TextField>(SampleAppUi.IdentityFields.Id).value = "12345";
_root.Q<Button>(SampleAppUi.Buttons.Identify).Click();
yield return SampleAppTestHelpers.WaitForLogEntry(_root, SampleAppUi.LogLabels.Sdk, LogLevels.Warn, 5f);

Assert.IsNull(ImmutableAudience.UserId,
"an invalid Passport ID must be a full no-op, not just logged");
}

[UnityTest]
public IEnumerator Alias_AndFlush_FlushReportsOk()
{
yield return LoadAndInit(initialConsent: SampleAppUi.Consent.Full);

_root!.Q<TextField>(SampleAppUi.IdentityFields.AliasFromId).value = "anon-" + DateTime.UtcNow.Ticks;
_root.Q<TextField>(SampleAppUi.IdentityFields.AliasToId).value = "user-" + DateTime.UtcNow.Ticks;
// Identity dropdowns default to Passport for both sides.
_root!.Q<TextField>(SampleAppUi.IdentityFields.AliasFromId).value = "email|anon-" + DateTime.UtcNow.Ticks;
_root.Q<TextField>(SampleAppUi.IdentityFields.AliasToId).value = "email|user-" + DateTime.UtcNow.Ticks;
_root.Q<Button>(SampleAppUi.Buttons.Alias).Click();
yield return null;

Expand Down Expand Up @@ -292,7 +307,7 @@ public IEnumerator SetConsent_FullAfterAnonymous_AcceptsIdentify()

yield return SetConsentVia(SampleAppUi.Buttons.ConsentFull);

_root!.Q<TextField>(SampleAppUi.IdentityFields.Id).value = "consent-test-" + DateTime.UtcNow.Ticks;
_root!.Q<TextField>(SampleAppUi.IdentityFields.Id).value = "email|consent-test-" + DateTime.UtcNow.Ticks;
_root.Q<Button>(SampleAppUi.Buttons.Identify).Click();
yield return null;

Expand Down Expand Up @@ -406,7 +421,7 @@ public IEnumerator SetConsent_AnonymousFromFull_StripsUserIdFromTrack()
// appear, which means the strip path is intact under IL2CPP.
yield return LoadAndInit(initialConsent: SampleAppUi.Consent.Full);

_root!.Q<TextField>(SampleAppUi.IdentityFields.Id).value = "downgrade-user-" + DateTime.UtcNow.Ticks;
_root!.Q<TextField>(SampleAppUi.IdentityFields.Id).value = "email|downgrade-user-" + DateTime.UtcNow.Ticks;
_root.Q<Button>(SampleAppUi.Buttons.Identify).Click();
yield return SampleAppTestHelpers.WaitForLogEntry(_root, SampleAppUi.LogLabels.Identify, LogLevels.Ok, 5f);

Expand All @@ -426,7 +441,7 @@ public IEnumerator IdentifyTraits_AfterIdentify_FlushReportsOk()
// a different reflection/serialiser path than Identify(id) alone.
yield return LoadAndInit(initialConsent: SampleAppUi.Consent.Full);

_root!.Q<TextField>(SampleAppUi.IdentityFields.Id).value = "traits-user-" + DateTime.UtcNow.Ticks;
_root!.Q<TextField>(SampleAppUi.IdentityFields.Id).value = "email|traits-user-" + DateTime.UtcNow.Ticks;
_root.Q<Button>(SampleAppUi.Buttons.Identify).Click();
yield return SampleAppTestHelpers.WaitForLogEntry(_root, SampleAppUi.LogLabels.Identify, LogLevels.Ok, 5f);

Expand Down Expand Up @@ -735,7 +750,7 @@ public IEnumerator IdentityPanel_PopulatesUserIdAfterIdentify()
{
yield return LoadAndInit(initialConsent: SampleAppUi.Consent.Full);

var userId = "panel-user-" + DateTime.UtcNow.Ticks;
var userId = "email|panel-user-" + DateTime.UtcNow.Ticks;
_root!.Q<TextField>(SampleAppUi.IdentityFields.Id).value = userId;
_root.Q<Button>(SampleAppUi.Buttons.Identify).Click();
yield return SampleAppTestHelpers.WaitForLogEntry(_root, SampleAppUi.LogLabels.Identify, LogLevels.Ok, 5f);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@ internal static class LogLabels
internal const string Identify = "identify()";
internal const string IdentifyTraits = "identify(traits)";
internal const string Alias = "alias()";

// Label AudienceSample.cs.RouteSdkLogToPane gives every mirrored SDK log line.
internal const string Sdk = "sdk";
}

// ---- Consent dropdown / status values ----
Expand Down
51 changes: 49 additions & 2 deletions src/Packages/Audience/Runtime/ImmutableAudience.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;

Expand Down Expand Up @@ -40,6 +41,15 @@ public static class ImmutableAudience
// Gate against overlapping timer ticks (Timer callbacks run on independent ThreadPool threads).
private static int _sendInFlight;

// Passport IDs are "<connection>|<id>" (e.g. "email|abc123", "google-oauth2|123")
// or, for some accounts, a bare UUID with no connection prefix.
// Not RegexOptions.Compiled: that needs Reflection.Emit, unavailable under IL2CPP/AOT.
private static readonly Regex PassportIdPattern = new(@"^[^|]+\|[^|]+$");

private static readonly Regex PassportUuidPattern = new(
@"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
RegexOptions.IgnoreCase);

// volatile: assigned on the Unity main thread at SubsystemRegistration,
// read from the drain thread in Track / Identify paths.
// The assignments happen before any event can fire in practice, but
Expand Down Expand Up @@ -411,7 +421,10 @@ private static void TrackFromSession(string eventName, Dictionary<string, object
// -----------------------------------------------------------------

/// <summary>
/// Attaches a known user ID to subsequent events.
/// Attaches a known user ID to subsequent events. When <paramref name="identityType"/> is
/// <see cref="IdentityType.Passport"/>, <paramref name="userId"/> must look like a real
/// Passport ID (<c>connection|id</c> or a UUID) — otherwise the call is dropped and a
/// warning is logged.
/// </summary>
/// <param name="userId">The player's identifier within the chosen provider.</param>
/// <param name="identityType">The identity provider that issued <paramref name="userId"/>.</param>
Expand All @@ -427,6 +440,15 @@ public static void Identify(string userId, IdentityType identityType, Dictionary
return;
}

// Trim once: the validated id and the stored/sent id must match.
userId = userId.Trim();

if (identityType == IdentityType.Passport && !IsValidPassportId(userId))
{
Log.Warn(AudienceLogs.IdentifyPassportIdInvalidFormat(userId));
return;
}

AudienceConfig? config;
ConsentLevel level;
// Update consent + userId under the init lock so they always move
Expand All @@ -453,8 +475,17 @@ public static void Identify(string userId, IdentityType identityType, Dictionary
EnqueueIdentity(msg);
}

private static bool IsValidPassportId(string id)
{
var trimmed = id.Trim();
return PassportIdPattern.IsMatch(trimmed) || PassportUuidPattern.IsMatch(trimmed);
}

/// <summary>
/// Links two user IDs for the same player.
/// Links two user IDs for the same player. When either side's identity
/// type is <see cref="IdentityType.Passport"/>, that side's id must look
/// like a real Passport ID (<c>connection|id</c> or a UUID) — otherwise
/// the call is dropped and a warning is logged.
/// </summary>
/// <param name="fromId">The previously-known identifier.</param>
/// <param name="fromType">Identity provider for <paramref name="fromId"/>.</param>
Expand All @@ -469,6 +500,22 @@ public static void Alias(string fromId, IdentityType fromType, string toId, Iden
Log.Warn(AudienceLogs.AliasEmptyIds);
return;
}

// Trim once: the validated ids and the sent ids must match.
fromId = fromId.Trim();
toId = toId.Trim();

if (fromType == IdentityType.Passport && !IsValidPassportId(fromId))
{
Log.Warn(AudienceLogs.AliasPassportIdInvalidFormat("from", fromId));
return;
}
if (toType == IdentityType.Passport && !IsValidPassportId(toId))
{
Log.Warn(AudienceLogs.AliasPassportIdInvalidFormat("to", toId));
return;
}

var state = _state;
if (!state.Level.CanIdentify())
{
Expand Down
10 changes: 10 additions & 0 deletions src/Packages/Audience/Runtime/Utility/Log.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,19 @@ internal static string TrackIEventEmptyName(string evtTypeName) =>
internal static string IdentifyDiscarded(ConsentLevel current) =>
$"Identify discarded. Requires Full consent, current is {current}.";

internal static string IdentifyPassportIdInvalidFormat(string userId) =>
$"Identify called with identityType Passport but userId \"{userId}\" doesn't look like a " +
"Passport ID (expected a format like \"email|123\" or a UUID). Check you're passing the " +
"Passport user ID, not your own internal user ID. Call ignored.";

internal static string AliasDiscarded(ConsentLevel current) =>
$"Alias discarded. Requires Full consent, current is {current}.";

internal static string AliasPassportIdInvalidFormat(string side, string id) =>
$"Alias called with {side}Type Passport but {side}Id \"{id}\" doesn't look like a " +
"Passport ID (expected a format like \"email|123\" or a UUID). Check you're passing the " +
"Passport user ID, not your own internal user ID. Call ignored.";

// ---- Consent / Shutdown ----

internal static string ConsentPersistFailed(Exception ex) =>
Expand Down
Loading
Loading