ChipDNAClientCLI/Tests/StreamingTests.cs

532 lines
30 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
namespace Creditcall.ChipDna.Client
{
internal static class StreamingTests
{
private static int Main()
{
var tests = new Action[] {
SharedExecutionReturnsAuthoritativeResultOnce,
TimeoutDoesNotCancelCompletion,
StartAndTaskErrorsKeepExceptionBehavior,
SubscriptionIsRemovedOnSuccessAndException,
OwnershipAcceptsReferenceLessProgressAndSuppressesPermanently,
RepeatedReferenceSuppressesProgress,
NativeMiuraSequenceKeepsFinalAuthoritative,
NativeMiuraRecoveryCyclesKeepFinalAuthoritative,
NativeNotificationUsesNotificationKey,
NativeOwnershipAndRemovalBoundaries,
PreDispatchFailureReleasesOnlyUnusedReference,
UnprovenStartFailuresSuppressObservation,
AsyncErrorSuppressesOnlyActiveObservation,
TimeoutUnsubscribesAndKeepsFuturePaymentsEnabled,
QueueCapacityCannotDropFinal,
ConcurrentCallbacksKeepValidLinesAndOneFinal,
DisconnectDoesNotInterruptExecution,
FinalFieldsExcludeSensitiveParameters
};
int failed = 0;
foreach (var test in tests)
{
try { test(); Console.WriteLine("PASS " + test.Method.Name); }
catch (Exception ex) { failed++; Console.WriteLine("FAIL " + test.Method.Name + ": " + ex); }
}
Console.WriteLine($"{tests.Length - failed}/{tests.Length} tests passed");
return failed == 0 ? 0 : 1;
}
private static void Check(bool condition, string message)
{
if (!condition) throw new Exception(message);
}
private static EventParameters Native(string key, string value, string reference = null)
{
var fields = new Dictionary<string, string>();
if (key != null) fields[key] = value;
if (reference != null) fields["REFERENCE"] = reference;
// SDK event construction is internal; no ClientHelper or terminal is started.
return (EventParameters)Activator.CreateInstance(typeof(EventParameters),
BindingFlags.Instance | BindingFlags.NonPublic, null, new object[] { fields }, null);
}
private static void CheckUnsubscribed(TransactionProgressTap tap)
{
var listener = typeof(TransactionProgressTap).GetField("listener", BindingFlags.Instance | BindingFlags.NonPublic);
Check(listener.GetValue(tap) == null, "progress subscription survived execution");
}
private static void SharedExecutionReturnsAuthoritativeResultOnce()
{
foreach (var outcome in new[] { "Approved", "Declined", "Cancelled" })
{
var expected = new Dictionary<string, string> { { "TRANSACTION_RESULT", outcome }, { "REFERENCE", "existing-ref" } };
var source = new TaskCompletionSource<Dictionary<string, string>>();
int starts = 0, suppressions = 0;
var result = TransactionExecution.Run(() => { starts++; source.SetResult(expected); },
() => source.Task, () => suppressions++, TimeSpan.FromSeconds(1));
Check(ReferenceEquals(expected, result) && starts == 1 && suppressions == 0,
"shared execution changed result or start count for " + outcome);
}
}
private static void TimeoutDoesNotCancelCompletion()
{
var source = new TaskCompletionSource<Dictionary<string, string>>();
int starts = 0, suppressions = 0;
var result = TransactionExecution.Run(() => starts++, () => source.Task, () => suppressions++, TimeSpan.Zero);
Check(result["TRANSACTION_RESULT"] == "TIMEOUT" &&
result["ERRORS"] == "Transaction timed out waiting for device response", "timeout fields changed");
Check(starts == 1 && suppressions == 1 && !source.Task.IsCompleted, "timeout cancelled or restarted the transaction");
source.SetResult(new Dictionary<string, string> { { "TRANSACTION_RESULT", "Approved" } });
Check(source.Task.Result["TRANSACTION_RESULT"] == "Approved", "late SDK completion was disabled");
}
private static void StartAndTaskErrorsKeepExceptionBehavior()
{
int suppressions = 0;
try
{
TransactionExecution.Run(() => { throw new InvalidOperationException("start"); },
() => Task.FromResult(new Dictionary<string, string>()), () => suppressions++, TimeSpan.Zero);
throw new Exception("expected original start exception");
}
catch (InvalidOperationException) { }
var fault = new TaskCompletionSource<Dictionary<string, string>>();
fault.SetException(new IOException("SDK fault"));
try
{
TransactionExecution.Run(() => { }, () => fault.Task, () => suppressions++, TimeSpan.FromSeconds(1));
throw new Exception("expected original Task.Wait AggregateException");
}
catch (AggregateException) { }
Check(suppressions == 2, "ambiguous exception did not suppress observation");
}
private static void SubscriptionIsRemovedOnSuccessAndException()
{
var tap = new TransactionProgressTap();
int first = 0, second = 0;
tap.Observe((s,v) => first++, () => {
tap.Begin("first");
tap.Report("UPDATE", Native("UPDATE", "CardRequested", "first"));
tap.End();
return new Dictionary<string, string>();
});
CheckUnsubscribed(tap);
tap.Begin("after-first");
tap.Report("UPDATE", Native("UPDATE", "CardRequested", "after-first"));
tap.End();
try
{
tap.Observe((s,v) => second++, () => {
tap.Begin("second");
tap.Report("UPDATE", Native("UPDATE", "CardRequested", "second"));
throw new IOException("test");
});
}
catch (IOException) { }
tap.Report("UPDATE", Native("UPDATE", "CardRequested", "second"));
Check(first == 1 && second == 1, $"subscriptions leaked: {first}/{second}");
CheckUnsubscribed(tap);
}
private static void OwnershipAcceptsReferenceLessProgressAndSuppressesPermanently()
{
var tap = new TransactionProgressTap();
int callbacks = 0, payments = 0;
tap.Observe((s,v) => callbacks++, () => {
payments++; tap.Begin("first");
tap.Report("UPDATE", Native("UPDATE", "CardRequested", null));
tap.Report("UPDATE", Native("UPDATE", "CardRequested", "other"));
tap.Report("UPDATE", Native("UPDATE", "CardRequested", "first"));
tap.Suppress();
tap.Report("UPDATE", Native("UPDATE", "CardRequested", "first"));
tap.End();
return new Dictionary<string, string>();
});
tap.Observe((s,v) => callbacks++, () => {
payments++; tap.Begin("later");
tap.Report("UPDATE", Native("UPDATE", "CardRequested", "first"));
tap.Report("UPDATE", Native("UPDATE", "CardRequested", "later"));
tap.End();
return new Dictionary<string, string>();
});
Check(callbacks == 2 && payments == 2, "active reference-less progress was dropped, suppression leaked, or later payments were disabled");
}
private static void RepeatedReferenceSuppressesProgress()
{
var tap = new TransactionProgressTap();
int callbacks = 0;
tap.Observe((s,v) => callbacks++, () => {
tap.Begin("same-second-reference");
tap.End();
tap.Begin("same-second-reference");
tap.Report("UPDATE", Native("UPDATE", "CardRequested", "same-second-reference"));
return new Dictionary<string, string>();
});
Check(callbacks == 0, "reused reference attributed an ambiguous event");
}
private static void NativeMiuraSequenceKeepsFinalAuthoritative()
{
foreach (var outcome in new[] { "Approved", "Declined" })
using (var output = new MemoryStream())
{
var tap = new TransactionProgressTap();
var writer = new TransactionStreamWriter(output);
var expected = new Dictionary<string, string> { { "TRANSACTION_RESULT", outcome }, { "REFERENCE", "miura-ref" } };
int starts = 0;
var result = tap.Observe(writer.Progress, () => TransactionExecution.Run(() => {
starts++;
tap.Begin("miura-ref");
tap.Report("UPDATE", Native("UPDATE", "CardRequested"));
tap.Report("UPDATE", Native("UPDATE", "CardRequested"));
tap.Report("CARD_STATUS", Native("NOTIFICATION", "Inserted"));
foreach (var update in new[] { "TransactionStarted", "PinEntryStarted", "OnlineAuthRequested", "OnlineAuthCompleted" })
tap.Report("UPDATE", Native("UPDATE", update));
tap.End(); // TransactionFinished closes observation before final processing.
tap.Report("UPDATE", Native("UPDATE", "CardRemovalRequested"));
tap.Report("CARD_STATUS", Native("NOTIFICATION", "Removed"));
}, () => Task.FromResult(expected), tap.Suppress, TimeSpan.FromSeconds(1)));
writer.Complete(new { type = "result", result });
writer.Deliver();
var frames = Frames(output);
var values = frames.Take(frames.Length - 1).Select(f => f["value"].Value<string>());
Check(values.SequenceEqual(new[] { "CardRequested", "CardRequested", "Inserted", "TransactionStarted", "PinEntryStarted", "OnlineAuthRequested", "OnlineAuthCompleted" }),
"SDK 3.17 Miura sequence lost or changed normal reference-less progress");
Check(starts == 1 && ReferenceEquals(result, expected) && frames.Length == 8 &&
frames.Last()["result"]["TRANSACTION_RESULT"].Value<string>() == outcome,
"progress changed start count or authoritative " + outcome + " result");
CheckUnsubscribed(tap);
}
}
private static void NativeMiuraRecoveryCyclesKeepFinalAuthoritative()
{
foreach (var cycles in new[] { 1, 3 })
foreach (var outcome in new[] { "Approved", "Declined" })
using (var output = new MemoryStream())
{
var tap = new TransactionProgressTap();
var writer = new TransactionStreamWriter(output);
var expected = new Dictionary<string, string> { { "TRANSACTION_RESULT", outcome } };
int starts = 0;
var result = tap.Observe(writer.Progress, () => TransactionExecution.Run(() => {
starts++;
tap.Begin("recovery");
for (int i = 0; i < cycles; i++)
{
tap.Report("UPDATE", Native("UPDATE", "CardRequested"));
tap.Report("CARD_STATUS", Native("NOTIFICATION", "Inserted"));
tap.Report("UPDATE", Native("UPDATE", "CardRemovalRequested"));
tap.Report("CARD_STATUS", Native("NOTIFICATION", "Removed"));
}
tap.Report("UPDATE", Native("UPDATE", "CardRequested"));
tap.Report("CARD_STATUS", Native("NOTIFICATION", "Inserted"));
foreach (var update in new[] { "TransactionStarted", "PinEntryStarted", "OnlineAuthRequested", "OnlineAuthCompleted" })
tap.Report("UPDATE", Native("UPDATE", update));
tap.End();
tap.Report("UPDATE", Native("UPDATE", "CardRemovalRequested"));
}, () => Task.FromResult(expected), tap.Suppress, TimeSpan.FromSeconds(1)));
writer.Complete(new { type = "result", result });
writer.Deliver();
var frames = Frames(output);
var expectedProgress = Enumerable.Range(0, cycles)
.SelectMany(i => new[] { "UPDATE:CardRequested", "CARD_STATUS:Inserted", "UPDATE:CardRemovalRequested" })
.Concat(new[] { "UPDATE:CardRequested", "CARD_STATUS:Inserted", "UPDATE:TransactionStarted",
"UPDATE:PinEntryStarted", "UPDATE:OnlineAuthRequested", "UPDATE:OnlineAuthCompleted" });
Check(frames.Take(frames.Length - 1).All(f => f["type"].Value<string>() == "status") &&
frames.Take(frames.Length - 1).Select(f => f["source"] + ":" + f["value"]).SequenceEqual(expectedProgress),
"Miura recovery prompts were dropped, reordered, deduplicated, or included Removed");
Check(starts == 1 && ReferenceEquals(result, expected) &&
frames.Count(f => f["type"].Value<string>() == "result") == 1 &&
frames.Last()["result"]["TRANSACTION_RESULT"].Value<string>() == outcome,
"recovery progress changed transaction execution or the authoritative final result");
CheckUnsubscribed(tap);
}
}
private static void NativeNotificationUsesNotificationKey()
{
var tap = new TransactionProgressTap();
var delivered = new List<string>();
tap.Observe((source, value) => delivered.Add(source + ":" + value), () => {
tap.Begin("native-key");
tap.Report("CARD_STATUS", Native("CARD_STATUS", "Inserted"));
foreach (var value in new[] { "Inserted", "Tapped", "Swiped" })
tap.Report("CARD_STATUS", Native("NOTIFICATION", value));
tap.Report("CARD_STATUS", Native("NOTIFICATION", "Removed", "native-key"));
tap.Report("CARD_STATUS", Native("NOTIFICATION", "Unknown"));
tap.Report("UPDATE", Native("PAN", "4761730000001133"));
tap.Report("UPDATE", null);
tap.End();
return new Dictionary<string, string>();
});
Check(delivered.SequenceEqual(new[] { "CARD_STATUS:Inserted", "CARD_STATUS:Tapped", "CARD_STATUS:Swiped" }),
"native NOTIFICATION extraction used CARD_STATUS, exposed card data, or changed the wire source");
}
private static void NativeOwnershipAndRemovalBoundaries()
{
var tap = new TransactionProgressTap();
var delivered = new List<string>();
var removals = new[] { "CardRemovalRequested", "CardRemovalEnforced" };
tap.Observe((source, value) => delivered.Add(source + ":" + value), () => {
foreach (var removal in removals)
foreach (var reference in new[] { null, "", "first", "other" })
tap.Report("UPDATE", Native("UPDATE", removal, reference));
tap.Begin("first");
tap.Report("UPDATE", Native("UPDATE", "CardRequested", ""));
tap.Report("UPDATE", Native("UPDATE", "PinEntryStarted", "other"));
tap.Report("SIGNATURE", Native(null, null));
tap.Report("PAUSE", Native("PAUSE_STATE", "PostCardDetails"));
foreach (var removal in removals)
foreach (var reference in new[] { null, "", "first", "other" })
{
tap.Report("UPDATE", Native("UPDATE", removal, reference));
tap.Report("CARD_STATUS", Native("NOTIFICATION", "Removed", reference));
}
tap.End();
foreach (var removal in removals)
foreach (var reference in new[] { null, "", "first", "other" })
tap.Report("UPDATE", Native("UPDATE", removal, reference));
tap.Report("UPDATE", Native("UPDATE", "CardRequested"));
tap.Begin("next");
// Accepted best-effort limitation: unidentified progress, including removal,
// is allowed in this new window even if it came from an older queue.
foreach (var removal in removals)
{
tap.Report("UPDATE", Native("UPDATE", removal));
tap.Report("UPDATE", Native("UPDATE", removal, "first"));
}
tap.Report("CARD_STATUS", Native("NOTIFICATION", "Removed"));
tap.Report("UPDATE", Native("UPDATE", "PinEntryStarted", "first"));
tap.Report("UPDATE", Native("UPDATE", "CardRequested"));
tap.Report("UPDATE", Native("UPDATE", "Approved"));
tap.End();
return new Dictionary<string, string>();
});
Check(delivered.SequenceEqual(new[] { "UPDATE:CardRequested", "SIGNATURE:Requested", "PAUSE:Paused",
"UPDATE:CardRemovalRequested", "UPDATE:CardRemovalRequested", "UPDATE:CardRemovalRequested",
"UPDATE:CardRemovalEnforced", "UPDATE:CardRemovalEnforced", "UPDATE:CardRemovalEnforced",
"UPDATE:CardRemovalRequested", "UPDATE:CardRemovalEnforced", "UPDATE:CardRequested" }),
"active-window, explicit-reference, or removal ownership changed");
}
private static void PreDispatchFailureReleasesOnlyUnusedReference()
{
var tap = new TransactionProgressTap();
int starts = 0, callbacks = 0;
foreach (var rejected in new[] { true, false })
{
var expected = rejected
? new Dictionary<string, string> { { "ERRORS", "ClientNotConnectedToServer" }, { "REFERENCE", "same-second" } }
: new Dictionary<string, string> { { "TRANSACTION_RESULT", "Approved" }, { "REFERENCE", "same-second" } };
var result = tap.Observe((source, value) => callbacks++, () => TransactionExecution.Run(() => {
starts++;
tap.Begin("same-second");
if (rejected) tap.StartFailed("ClientNotConnectedToServer");
else tap.Report("UPDATE", Native("UPDATE", "CardRequested"));
if (!rejected) tap.End();
tap.Report("UPDATE", Native("UPDATE", "PinEntryStarted"));
}, () => Task.FromResult(expected), tap.Suppress, TimeSpan.FromSeconds(1)));
Check(ReferenceEquals(result, expected), "pre-dispatch handling changed the financial result");
CheckUnsubscribed(tap);
}
Check(starts == 2 && callbacks == 1, "proven pre-dispatch rejection prevented a safe same-reference retry");
}
private static void UnprovenStartFailuresSuppressObservation()
{
foreach (var error in new[] { "AmountTooSmall", "MissingParameter", "NoPinPadsAvailable", "TransactionInProgress",
"ClientNotConnectedToServer,AmountTooSmall", " ClientNotConnectedToServer", "500005", "Unknown" })
{
var tap = new TransactionProgressTap();
int starts = 0, callbacks = 0;
foreach (var first in new[] { true, false })
{
var expected = first ? new Dictionary<string, string> { { "ERRORS", error } }
: new Dictionary<string, string> { { "TRANSACTION_RESULT", "Approved" } };
var result = tap.Observe((source, value) => callbacks++, () => TransactionExecution.Run(() => {
starts++;
tap.Begin(first ? "failed" : "later");
if (first) tap.StartFailed(error);
// A subsequent safe rejection must never clear an existing latch.
tap.StartFailed("ClientNotConnectedToServer");
tap.Report("UPDATE", Native("UPDATE", "CardRequested"));
foreach (var removal in new[] { "CardRemovalRequested", "CardRemovalEnforced" })
{
tap.Report("UPDATE", Native("UPDATE", removal));
tap.Report("UPDATE", Native("UPDATE", removal, "later"));
}
tap.Report("UPDATE", Native("UPDATE", "CardRequested", "later"));
tap.End();
}, () => Task.FromResult(expected), tap.Suppress, TimeSpan.FromSeconds(1)));
Check(ReferenceEquals(result, expected), "observational handling changed result for " + error);
}
Check(starts == 2 && callbacks == 0, "unproven error recovered progress or disabled payment: " + error);
}
}
private static void AsyncErrorSuppressesOnlyActiveObservation()
{
var tap = new TransactionProgressTap();
int callbacks = 0, starts = 0;
tap.SuppressIfActive(); // Idle SDK errors have no active transaction to make ambiguous.
foreach (var reference in new[] { "first", "later" })
{
var expected = new Dictionary<string, string> { { "TRANSACTION_RESULT", "Approved" } };
var result = tap.Observe((source, value) => callbacks++, () => TransactionExecution.Run(() => {
starts++;
tap.Begin(reference);
tap.Report("UPDATE", Native("UPDATE", "CardRequested"));
tap.SuppressIfActive();
foreach (var removal in new[] { "CardRemovalRequested", "CardRemovalEnforced" })
{
tap.Report("UPDATE", Native("UPDATE", removal));
tap.Report("UPDATE", Native("UPDATE", removal, reference));
}
tap.Report("UPDATE", Native("UPDATE", "PinEntryStarted"));
tap.End();
}, () => Task.FromResult(expected), tap.Suppress, TimeSpan.FromSeconds(1)));
Check(ReferenceEquals(result, expected), "asynchronous SDK error altered financial execution");
}
Check(starts == 2 && callbacks == 1, "asynchronous error failed to latch suppression or suppressed while idle");
}
private static void TimeoutUnsubscribesAndKeepsFuturePaymentsEnabled()
{
var tap = new TransactionProgressTap();
var completion = new TaskCompletionSource<Dictionary<string, string>>();
int starts = 0, callbacks = 0;
var timeout = tap.Observe((source, value) => callbacks++, () => TransactionExecution.Run(() => {
starts++;
tap.Begin("timeout");
tap.Report("UPDATE", Native("UPDATE", "CardRequested"));
}, () => completion.Task, tap.Suppress, TimeSpan.Zero));
Check(timeout["TRANSACTION_RESULT"] == "TIMEOUT" && !completion.Task.IsCompleted, "timeout changed financial completion");
CheckUnsubscribed(tap);
tap.Report("UPDATE", Native("UPDATE", "PinEntryStarted"));
completion.SetResult(new Dictionary<string, string> { { "TRANSACTION_RESULT", "Approved" } });
tap.End();
var expected = new Dictionary<string, string> { { "TRANSACTION_RESULT", "Declined" } };
var later = tap.Observe((source, value) => callbacks++, () => TransactionExecution.Run(() => {
starts++;
tap.Begin("later");
tap.Report("UPDATE", Native("UPDATE", "CardRequested"));
foreach (var removal in new[] { "CardRemovalRequested", "CardRemovalEnforced" })
{
tap.Report("UPDATE", Native("UPDATE", removal));
tap.Report("UPDATE", Native("UPDATE", removal, "later"));
}
tap.Report("UPDATE", Native("UPDATE", "PinEntryStarted", "later"));
tap.End();
}, () => Task.FromResult(expected), tap.Suppress, TimeSpan.FromSeconds(1)));
Check(starts == 2 && callbacks == 1 && ReferenceEquals(later, expected), "timeout suppression leaked progress or blocked future payments");
CheckUnsubscribed(tap);
}
private static JObject[] Frames(MemoryStream stream)
{
var text = Encoding.UTF8.GetString(stream.ToArray());
Check(text.Length == 0 || text[0] != '\uFEFF', "stream contained a BOM");
return text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries).Select(JObject.Parse).ToArray();
}
private static void QueueCapacityCannotDropFinal()
{
using (var output = new MemoryStream())
{
var writer = new TransactionStreamWriter(output);
for (int i = 0; i < 1000; i++) writer.Progress("UPDATE", "CardRequested");
writer.Complete(new { type = "result", result = new { TRANSACTION_RESULT = "Approved" } });
writer.Complete(new { type = "error", error = "duplicate" });
writer.Progress("UPDATE", "PinEntryStarted");
writer.Deliver();
writer.Deliver();
var frames = Frames(output);
Check(frames.Length == 129 && frames.Last()["type"].Value<string>() == "result", "full queue dropped or duplicated final");
Check(frames.Take(128).All(f => f["value"].Value<string>() == "CardRequested"), "progress arrived after final");
}
}
private static void ConcurrentCallbacksKeepValidLinesAndOneFinal()
{
using (var output = new MemoryStream())
{
var writer = new TransactionStreamWriter(output);
var delivery = Task.Run(() => writer.Deliver());
var tap = new TransactionProgressTap();
tap.Observe(writer.Progress, () => {
tap.Begin("concurrent");
Parallel.For(0, 5000, i => tap.Report("UPDATE", Native("UPDATE", "PinEntryStarted")));
tap.End();
return new Dictionary<string, string>();
});
writer.Complete(new { type = "result", result = new { TRANSACTION_RESULT = "Declined" } });
Check(delivery.Wait(TimeSpan.FromSeconds(5)), "writer did not finish");
var frames = Frames(output);
Check(frames.Count(f => f["type"].Value<string>() == "result") == 1 &&
frames.Last()["type"].Value<string>() == "result", "concurrent callbacks corrupted terminal framing");
Check(frames.Where(f => f["type"].Value<string>() == "status").All(f =>
f["source"].Value<string>() == "UPDATE" && f["value"].Value<string>() == "PinEntryStarted"),
"concurrent callbacks corrupted status fields");
}
}
private sealed class DisconnectedStream : MemoryStream
{
public override void Write(byte[] buffer, int offset, int count) { throw new IOException("disconnected"); }
}
private static void DisconnectDoesNotInterruptExecution()
{
using (var output = new DisconnectedStream())
{
var writer = new TransactionStreamWriter(output);
writer.Progress("UPDATE", "CardRequested");
writer.Deliver();
var expected = new Dictionary<string, string> { { "TRANSACTION_RESULT", "Approved" } };
int starts = 0;
var result = TransactionExecution.Run(() => starts++, () => Task.FromResult(expected),
() => { throw new Exception("disconnect suppressed financial execution"); }, TimeSpan.FromSeconds(1));
writer.Complete(new { type = "result", result });
writer.Progress("UPDATE", "CardRequested");
Check(starts == 1 && ReferenceEquals(result, expected), "disconnect changed final transaction result");
}
}
private static void FinalFieldsExcludeSensitiveParameters()
{
var fields = TransactionStreamFields.Required(new Dictionary<string, string> {
{ "TRANSACTION_RESULT", "Approved" }, { "PAN", "1234567890123456" },
{ "PAN_MASKED", "1234567890123456" }, { "CVV", "123" }, { "PIN", "1234" },
{ "TRACK_DATA", "track-secret" }, { "RAW", "raw-secret" }, { "CARD_HASH", "hash" },
{ "CARD_REFERENCE", "ref" }, { "RECEIPT_DATA_CARDHOLDER", "receipt" }
});
Check(!fields.ContainsKey("PAN") && !fields.ContainsKey("CVV") && !fields.ContainsKey("PIN") &&
!fields.ContainsKey("TRACK_DATA") && !fields.ContainsKey("RAW") && fields["PAN_MASKED"] == "",
"sensitive fields crossed structured boundary");
Check(fields["CARD_HASH"] == "hash" && fields["CARD_REFERENCE"] == "ref" && fields["RECEIPT_DATA_CARDHOLDER"] == "receipt", "required final fields were lost");
Check(TransactionStreamFields.MaskedCardNumber("123456******1234") == "123456******1234" &&
TransactionStreamFields.MaskedCardNumber("476173******1133") == "476173******1133" &&
TransactionStreamFields.MaskedCardNumber("************1133") == "************1133" &&
TransactionStreamFields.MaskedCardNumber("1234567890123456****") == "" &&
TransactionStreamFields.MaskedCardNumber("34373631373330303030303031313333") == "", "valid masking rejected or encoding/full digits treated as masking");
Check(!TransactionStreamFields.IsProgress("PAN", "CardRequested") &&
!TransactionStreamFields.IsProgress("UPDATE", "1234567890123456") &&
!TransactionStreamFields.IsProgress("CARD_STATUS", "Removed"), "unsafe progress permitted");
}
}
}