From 323e91aeaa01ab6b669d04b0db77834dc6460991 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 10 Nov 2026 20:55:55 +0000 Subject: [PATCH 1/4] Test that evidence keys ignore letter case --- .../tests/test_evidence_key_case.py | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 pipeline/fiftyone_pipeline_core/tests/test_evidence_key_case.py diff --git a/pipeline/fiftyone_pipeline_core/tests/test_evidence_key_case.py b/pipeline/fiftyone_pipeline_core/tests/test_evidence_key_case.py new file mode 100644 index 00000000..abb98e29 --- /dev/null +++ b/pipeline/fiftyone_pipeline_core/tests/test_evidence_key_case.py @@ -0,0 +1,64 @@ +# ********************************************************************* +# This Original Work is copyright of 51 Degrees Mobile Experts Limited. +# Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House, +# Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU. +# +# This Original Work is licensed under the European Union Public Licence +# (EUPL) v.1.2 and is subject to its terms as set out below. +# +# If a copy of the EUPL was not distributed with this file, You can obtain +# one at https://opensource.org/licenses/EUPL-1.2. +# +# The 'Compatible Licences' set out in the Appendix to the EUPL (as may be +# amended by the European Commission) shall be deemed incompatible for +# the purposes of the Work and the provisions of the compatibility +# clause in Article 5 of the EUPL shall not apply. +# +# If using the Work as, or as part of, a network application, by +# including the attribution notice(s) required under Article 5 of the EUPL +# in the end user terms of the application under an appropriate heading, +# such notice(s) shall fulfill the requirements of that article. +# ********************************************************************* + +"""Evidence keys ignore letter case, as the Evidence page of the pipeline +specification says, so header.User-Agent and header.user-agent are the same +key.""" + +import unittest + +from fiftyone_pipeline_core.pipelinebuilder import PipelineBuilder + +from .classes.exampleflowelement1 import ExampleFlowElement1 + + +def flowdata(): + return PipelineBuilder({ + "add_javascript_builder": False, + "use_setheader_properties": False}) \ + .add(ExampleFlowElement1()).build().create_flowdata() + + +class EvidenceKeyCaseTests(unittest.TestCase): + + def test_a_key_is_read_in_any_case(self): + data = flowdata() + data.evidence.add("header.User-Agent", "a browser") + self.assertEqual("a browser", data.evidence.get("header.user-agent")) + self.assertEqual("a browser", data.evidence.get("HEADER.USER-AGENT")) + + def test_keys_that_differ_only_in_case_are_one_key(self): + data = flowdata() + data.evidence.add("header.User-Agent", "first") + data.evidence.add("header.user-agent", "second") + self.assertEqual(1, len(data.evidence.get_all())) + self.assertEqual("second", data.evidence.get("header.User-Agent")) + + def test_a_key_in_any_case_survives_filtering(self): + data = flowdata() + data.evidence.add("Header.User-Agent", "a browser") + data.process() + self.assertEqual("a browser", data.evidence.get("header.user-agent")) + + +if __name__ == "__main__": + unittest.main() From 997e2692e4a5aad6888ef5fdaf9a93331fcf19bf Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 10 Nov 2026 20:56:24 +0000 Subject: [PATCH 2/4] Treat evidence keys that differ only in letter case as one key, as the specification says --- .../src/fiftyone_pipeline_core/evidence.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/evidence.py b/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/evidence.py index 02b45ce7..ee6b8f5d 100644 --- a/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/evidence.py +++ b/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/evidence.py @@ -35,6 +35,9 @@ def __init__(self, flowdata): @param flowdata: Parent FlowData """ self.evidence = {} + # Each key in lower case to the key as first added, because keys + # ignore letter case. + self.keys = {} self.evidence_filtered = False self.flowdata = flowdata self.unaccepted = {} @@ -50,6 +53,11 @@ def add(self, key, value): @param value: a piece of evidence """ + folded = str(key).lower() + if folded in self.keys: + key = self.keys[folded] + else: + self.keys[folded] = key self.evidence[key] = value def add_from_dict(self, evidence_dictionary): @@ -77,9 +85,10 @@ def get(self, key): @return: A piece of evidence """ - if key in self.evidence: + found = self.keys.get(str(key).lower()) + if found is not None and found in self.evidence: - return self.evidence[key] + return self.evidence[found] else: @@ -127,5 +136,6 @@ def filter(self) -> None: in self.flowdata.pipeline.flow_elements ]): self.unaccepted[key] = self.evidence.pop(key) + del self.keys[str(key).lower()] self.evidence_filtered = True From 4cf965dc5d7dd2a97f46d973da0631d174d4707b Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 12 Nov 2026 00:19:23 +0000 Subject: [PATCH 3/4] Test that the elements reading evidence keys ignore letter case get_all gives each key in the letters it was added with, so the Sequence Element's key filter and the JavaScript Builder's reads are tested with keys in other letters. --- .../tests/test_evidence_key_case.py | 60 ++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/pipeline/fiftyone_pipeline_core/tests/test_evidence_key_case.py b/pipeline/fiftyone_pipeline_core/tests/test_evidence_key_case.py index abb98e29..48d26770 100644 --- a/pipeline/fiftyone_pipeline_core/tests/test_evidence_key_case.py +++ b/pipeline/fiftyone_pipeline_core/tests/test_evidence_key_case.py @@ -22,11 +22,16 @@ """Evidence keys ignore letter case, as the Evidence page of the pipeline specification says, so header.User-Agent and header.user-agent are the same -key.""" +key. get_all gives each key in the letters it was added with, so an element +that matches a key it holds matches it in lower case.""" +import json import unittest +from unittest import mock +from fiftyone_pipeline_core import javascriptbuilder from fiftyone_pipeline_core.pipelinebuilder import PipelineBuilder +from fiftyone_pipeline_core.sequenceelement import SequenceElement from .classes.exampleflowelement1 import ExampleFlowElement1 @@ -38,6 +43,33 @@ def flowdata(): .add(ExampleFlowElement1()).build().create_flowdata() +class _Captured: + """Records the variables of the last render and renders nothing.""" + + def __init__(self): + self.variables = None + + def render(self, template, variables): + self.variables = dict(variables) + return "" + + +def rendered_variables(evidence): + """The variables the JavaScript Builder renders its script from, after a + pipeline holding the Sequence Element has processed the evidence given.""" + + pipeline = PipelineBuilder({"javascript_builder_settings": { + "minify": False, "endpoint": "/json"}}).build() + data = pipeline.create_flowdata() + for key, value in evidence.items(): + data.evidence.add(key, value) + captured = _Captured() + with mock.patch.object( + javascriptbuilder.chevron, "render", captured.render): + data.process() + return captured.variables + + class EvidenceKeyCaseTests(unittest.TestCase): def test_a_key_is_read_in_any_case(self): @@ -59,6 +91,32 @@ def test_a_key_in_any_case_survives_filtering(self): data.process() self.assertEqual("a browser", data.evidence.get("header.user-agent")) + def test_the_sequence_element_accepts_its_keys_in_any_case(self): + """Without this the two keys move to the evidence set aside, and the + Sequence Element then starts a session of its own.""" + + data = PipelineBuilder({ + "add_javascript_builder": False, + "use_setheader_properties": False}) \ + .add(SequenceElement()).build().create_flowdata() + data.evidence.add("Query.Session-Id", "abc-123") + data.evidence.add("QUERY.SEQUENCE", "4") + data.process() + self.assertEqual({}, data.evidence.get_unaccepted()) + self.assertEqual("abc-123", data.evidence.get("query.session-id")) + + def test_the_script_takes_the_session_and_sequence_in_any_case(self): + variables = rendered_variables({ + "Query.Session-Id": "abc-123", "QUERY.SEQUENCE": "4"}) + self.assertEqual("abc-123", variables["_sessionId"]) + # The Sequence Element counts this request as the next one. + self.assertEqual(5, variables["_sequence"]) + + def test_the_script_sends_a_query_value_whose_key_is_in_any_case(self): + variables = rendered_variables({"Query.Example": "a value"}) + self.assertEqual( + {"Example": "a+value"}, json.loads(variables["_parameters"])) + if __name__ == "__main__": unittest.main() From 7ce3317550c62b5f2ee565c1970fa82fb921bea8 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 12 Nov 2026 00:19:24 +0000 Subject: [PATCH 4/4] Match evidence keys in lower case where an element reads them The Sequence Element's key filter and the JavaScript Builder's key filter, session id, sequence and query parameters all matched the keys get_all gives with letter case, so a key added in other letters was missed. Each now matches in lower case, and a name sent on keeps the letters it was added with. --- .../javascriptbuilder.py | 29 ++++++++++++++----- .../fiftyone_pipeline_core/sequenceelement.py | 11 ++++--- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/javascriptbuilder.py b/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/javascriptbuilder.py index 59bdf9bd..86a84fc4 100644 --- a/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/javascriptbuilder.py +++ b/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/javascriptbuilder.py @@ -228,14 +228,17 @@ def url_encode(value): class JavaScriptBuilderEvidenceKeyFilter(EvidenceKeyFilter): # The query string, which the script sends back to the callback, and the - # host and protocol the callback URL is built from. + # host and protocol the callback URL is built from. Evidence keys ignore + # letter case, so each is matched in lower case. def filter_evidence_key(self, key): - if key.startswith("query."): + folded = str(key).lower() + + if folded.startswith("query."): return True - if key == "header.host" or key == "header.protocol": + if folded == "header.host" or folded == "header.protocol": return True - + return False @@ -413,12 +416,22 @@ def process_internal(self, flowdata): + self.settings["_objName"] + "' is used.") query_params = self.get_evidence_key_filter().filter_evidence(flowdata.evidence.get_all()) + # Evidence keys ignore letter case, and get_all gives each key in + # the letters it was added with, so a key is matched here in lower + # case and keeps those letters where it is sent on. + folded = {str(key).lower(): key for key in query_params} + + def query_param(name): + key = folded.get(name) + return None if key is None else query_params[key] + # Both are written into the script, so each always has a safe value. # The session id is written inside quotes and is empty when absent or # not safe, and the sequence is written as bare code, so it is always # a positive number. - variables["_sessionId"] = get_session_id(query_params.get("query.session-id")) - variables["_sequence"] = get_sequence(query_params.get("query.sequence")) + variables["_sessionId"] = get_session_id( + query_param("query.session-id")) + variables["_sequence"] = get_sequence(query_param("query.sequence")) # The session id and the sequence are left out, because the script # appends both to its own request after it has taken the record of @@ -435,8 +448,8 @@ def process_internal(self, flowdata): (url_encode(param.split(".", 1)[1]), url_encode(query_params[param])) for param in query_params.keys() - if param.startswith("query.") - and param not in EXCLUDED_PARAMETERS + if str(param).lower().startswith("query.") + and str(param).lower() not in EXCLUDED_PARAMETERS ]) # Written without spaces, as the .NET builder writes it. variables["_parameters"] = json.dumps( diff --git a/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/sequenceelement.py b/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/sequenceelement.py index cd85a857..2aaf8283 100644 --- a/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/sequenceelement.py +++ b/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/sequenceelement.py @@ -29,12 +29,15 @@ class SequenceElementEvidenceKeyFilter(EvidenceKeyFilter): # The session id and the sequence. The result changes as the sequence - # grows, so they are part of the ETag. + # grows, so they are part of the ETag. Evidence keys ignore letter case, + # so each is matched in lower case. def filter_evidence_key(self, key): - - if (key == "query.sequence" or key == "query.session-id"): + + folded = str(key).lower() + + if (folded == "query.sequence" or folded == "query.session-id"): return True - + return False