diff --git a/filter_plugins/csp_hashes.py b/filter_plugins/csp_hashes.py
new file mode 100644
index 00000000..f1bb8491
--- /dev/null
+++ b/filter_plugins/csp_hashes.py
@@ -0,0 +1,30 @@
+from ansible.errors import AnsibleFilterError
+import copy
+
+def append_csp_hash(applications, application_id, code_one_liner):
+ """
+ Ensures that applications[application_id].csp.hashes['script-src-elem']
+ exists and appends the given one-liner (if not already present).
+ """
+ if not isinstance(applications, dict):
+ raise AnsibleFilterError("`applications` must be a dict")
+ if application_id not in applications:
+ raise AnsibleFilterError(f"Unknown application_id: {application_id}")
+
+ apps = copy.deepcopy(applications)
+ app = apps[application_id]
+ csp = app.setdefault('csp', {})
+ hashes = csp.setdefault('hashes', {})
+
+ existing = hashes.get('script-src-elem', [])
+ if code_one_liner not in existing:
+ existing.append(code_one_liner)
+ hashes['script-src-elem'] = existing
+
+ return apps
+
+class FilterModule(object):
+ def filters(self):
+ return {
+ 'append_csp_hash': append_csp_hash
+ }
diff --git a/filter_plugins/text_filters.py b/filter_plugins/text_filters.py
new file mode 100644
index 00000000..88ae3295
--- /dev/null
+++ b/filter_plugins/text_filters.py
@@ -0,0 +1,44 @@
+# filter_plugins/text_filters.py
+
+from ansible.errors import AnsibleFilterError
+import re
+
+def to_one_liner(s):
+ """
+ Collapse any multi-line string into a single line,
+ trim extra whitespace, and remove JavaScript comments.
+ Supports removal of both '//' line comments and '/*...*/' block comments,
+ but preserves '//' inside string literals and templating expressions.
+ """
+ if not isinstance(s, str):
+ raise AnsibleFilterError("to_one_liner() expects a string")
+
+ # 1) Remove block comments /* ... */
+ no_block_comments = re.sub(r'/\*.*?\*/', '', s, flags=re.DOTALL)
+
+ # 2) Extract string literals to protect them from comment removal
+ string_pattern = re.compile(r"'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\"")
+ literals = []
+ def _extract(match):
+ idx = len(literals)
+ literals.append(match.group(0))
+ return f"__STR{idx}__"
+ temp = string_pattern.sub(_extract, no_block_comments)
+
+ # 3) Remove line comments // ...
+ temp = re.sub(r'//.*$', '', temp, flags=re.MULTILINE)
+
+ # 4) Restore string literals
+ for idx, lit in enumerate(literals):
+ temp = temp.replace(f"__STR{idx}__", lit)
+
+ # 5) Collapse all whitespace
+ one_liner = re.sub(r'\s+', ' ', temp).strip()
+
+ return one_liner
+
+class FilterModule(object):
+ def filters(self):
+ return {
+ 'to_one_liner': to_one_liner,
+ }
diff --git a/roles/docker-akaunting/vars/configuration.yml b/roles/docker-akaunting/vars/configuration.yml
index 9290b760..6e56e5a0 100644
--- a/roles/docker-akaunting/vars/configuration.yml
+++ b/roles/docker-akaunting/vars/configuration.yml
@@ -6,7 +6,7 @@ setup_admin_email: "{{ users.administrator.email }}"
features:
matomo: true
css: true
- portfolio_iframe: false
+ portfolio_iframe: true
central_database: true
credentials:
domains:
diff --git a/roles/docker-attendize/vars/configuration.yml b/roles/docker-attendize/vars/configuration.yml
index 84ce7fee..18979832 100644
--- a/roles/docker-attendize/vars/configuration.yml
+++ b/roles/docker-attendize/vars/configuration.yml
@@ -5,7 +5,7 @@ credentials:
features:
matomo: true
css: true
- portfolio_iframe: false
+ portfolio_iframe: true
central_database: true
docker:
services:
diff --git a/roles/docker-portfolio/templates/config.yaml.j2 b/roles/docker-portfolio/templates/config.yaml.j2
index 8f02288b..6ea27af4 100644
--- a/roles/docker-portfolio/templates/config.yaml.j2
+++ b/roles/docker-portfolio/templates/config.yaml.j2
@@ -6,8 +6,8 @@ accounts:
icon:
class: fa-solid fa-users
children:
- - name: Publishing Channels
- description: Platforms where I share content.
+ - name: Follow Us
+ description: Follow us to stay up to recieve the newest CyMaIS updates
icon:
class: fas fa-newspaper
{% if ["mastodon", "bluesky"] | any_in(group_names) %}
@@ -32,7 +32,7 @@ accounts:
icon:
class: fa-brands fa-bluesky
alternatives:
- - link: accounts.publishingchannels.microblogs.mastodon
+ - link: accounts.followus.microblogs.mastodon
identifier: "{{service_provider.contact.bluesky}}"
{% endif %}
{% endif %}
@@ -102,7 +102,6 @@ company:
navigation:
header:
children:
- - link: accounts.publishingchannels
- name: Contact
description: Get in touch with {{ 'us' if service_provider.type == 'legal' else 'me' }}
icon:
@@ -146,4 +145,10 @@ navigation:
class: fa-solid fa-expand-arrows-alt
onclick: "toggleFullscreen()"
+ - name: Open in new tab
+ description: Open the currently embedded iframe URL in a fresh browser tab
+ icon:
+ class: fa-solid fa-up-right-from-square
+ onclick: openIframeInNewTab()
+
{% include 'footer_menu.yaml.j2' %}
\ No newline at end of file
diff --git a/roles/docker-portfolio/templates/javascript.js.j2 b/roles/docker-portfolio/templates/javascript.js.j2
new file mode 100644
index 00000000..2577a75a
--- /dev/null
+++ b/roles/docker-portfolio/templates/javascript.js.j2
@@ -0,0 +1,30 @@
+window.addEventListener("message", function(event) {
+ const allowedSuffix = ".{{ primary_domain }}";
+ const origin = event.origin;
+
+ // 1. Only allow messages from *.{{ primary_domain }}
+ if (!origin.endsWith(allowedSuffix)) return;
+
+ const data = event.data;
+
+ // 2. Only process valid iframeLocationChange messages
+ if (data && data.type === "iframeLocationChange" && typeof data.href === "string") {
+ try {
+ const hrefUrl = new URL(data.href);
+
+ // 3. Only allow redirects to *.{{ primary_domain }}
+ if (!hrefUrl.hostname.endsWith(allowedSuffix)) return;
+
+ // 4. Update the ?iframe= parameter in the browser URL
+ const newUrl = new URL(window.location);
+ newUrl.searchParams.set("iframe", hrefUrl.href);
+ window.history.replaceState({}, "", newUrl);
+ } catch (e) {
+ // Invalid or malformed URL – ignore
+ }
+ }
+});
+
+{% if enable_debug | bool %}
+console.log("[iframe-sync] Listener for iframe messages is active.");
+{% endif %}
diff --git a/roles/docker-portfolio/vars/configuration.yml b/roles/docker-portfolio/vars/configuration.yml
index 2a7ac7ac..fd722b20 100644
--- a/roles/docker-portfolio/vars/configuration.yml
+++ b/roles/docker-portfolio/vars/configuration.yml
@@ -3,7 +3,7 @@ features:
css: true
portfolio_iframe: false
simpleicons: true # Activate Brand Icons for your groups
-nasa_api_key: false # Set api key to use the Nasa Picture of the Day as Background
+ javascript: true # Necessary for URL sync
csp:
whitelist:
script-src-elem:
diff --git a/roles/nginx-modifier-all/tasks/main.yml b/roles/nginx-modifier-all/tasks/main.yml
index b63a230c..97051d55 100644
--- a/roles/nginx-modifier-all/tasks/main.yml
+++ b/roles/nginx-modifier-all/tasks/main.yml
@@ -6,4 +6,14 @@
- name: "Activate Global Matomo Tracking for {{domain}}"
include_role:
name: nginx-modifier-matomo
- when: applications | is_feature_enabled('matomo',application_id)
\ No newline at end of file
+ when: applications | is_feature_enabled('matomo',application_id)
+
+- name: "Activate Portfolio iFrame Notifier for {{ domain }}"
+ include_role:
+ name: nginx-modifier-iframe
+ when: applications | is_feature_enabled('portfolio_iframe', application_id)
+
+- name: "Activate Javascript for {{ domain }}"
+ include_role:
+ name: nginx-modifier-javascript
+ when: applications | is_feature_enabled('javascript', application_id)
\ No newline at end of file
diff --git a/roles/nginx-modifier-all/templates/global.includes.conf.j2 b/roles/nginx-modifier-all/templates/global.includes.conf.j2
index 2f4d5d99..729ba2e7 100644
--- a/roles/nginx-modifier-all/templates/global.includes.conf.j2
+++ b/roles/nginx-modifier-all/templates/global.includes.conf.j2
@@ -2,20 +2,32 @@
sub_filter_once off;
sub_filter_types text/html;
-{% set features_css_final = applications.get(application_id).get('features').get('css') | bool %}
-{% set features_matomo_final = applications.get(application_id).get('features').get('matomo') | bool %}
+{% set modifier_css_enabled = applications | is_feature_enabled('css',application_id) %}
+{% set modifier_matomo_enabled = applications | is_feature_enabled('matomo',application_id) %}
+{% set modifier_iframe_enabled = applications | is_feature_enabled('portfolio_iframe',application_id) %}
+{% set modifier_javascript_enabled = applications | is_feature_enabled('javascript',application_id) %}
-
-{% if features_matomo_final | bool %}
- {# Include Global Matomo Tracking #}
- {% include 'roles/nginx-modifier-matomo/templates/matomo-tracking.conf.j2' %}
+{% if modifier_iframe_enabled or modifier_css_enabled or modifier_matomo_enabled or modifier_javascript_enabled %}
+sub_filter '' '
+ {%- if modifier_css_enabled -%}
+ {%- include "roles/nginx-modifier-css/templates/head_sub.j2" -%}
+ {%- endif -%}
+ {%- if modifier_matomo_enabled -%}
+ {%- include "roles/nginx-modifier-matomo/templates/head_sub.j2" -%}
+ {%- endif -%}
+ {%- if modifier_iframe_enabled -%}
+ {%- include "roles/nginx-modifier-iframe/templates/head_sub.j2" -%}
+ {%- endif -%}
+ {%- if modifier_javascript_enabled -%}
+ {%- include "roles/nginx-modifier-javascript/templates/head_sub.j2" -%}
+ {%- endif -%}
+ ';
{% endif %}
-{% if features_css_final | bool or features_matomo_final | bool %}
- sub_filter '' '{% if features_matomo_final | bool %}{% include 'roles/nginx-modifier-matomo/templates/script.j2' %}{% endif %}{% if features_css_final | bool %}{% include 'roles/nginx-modifier-css/templates/link.j2' %}{% endif %}';
+{% if modifier_css_enabled | bool %}
+{% include 'roles/nginx-modifier-css/templates/location.conf.j2' %}
{% endif %}
-{% if features_css_final | bool %}
- {# Include Global CSS Location #}
- {% include 'roles/nginx-modifier-css/templates/location.conf.j2' %}
-{% endif %}
+{% if modifier_matomo_enabled %}
+{% include 'roles/nginx-modifier-matomo/templates/matomo-tracking.conf.j2' %}
+{% endif %}
\ No newline at end of file
diff --git a/roles/nginx-modifier-css/templates/link.j2 b/roles/nginx-modifier-css/templates/head_sub.j2
similarity index 100%
rename from roles/nginx-modifier-css/templates/link.j2
rename to roles/nginx-modifier-css/templates/head_sub.j2
diff --git a/roles/nginx-modifier-iframe/README.md b/roles/nginx-modifier-iframe/README.md
new file mode 100644
index 00000000..0078afbe
--- /dev/null
+++ b/roles/nginx-modifier-iframe/README.md
@@ -0,0 +1,24 @@
+
+# 🌐 iFrame Notifier for Nginx
+
+This Ansible role injects a small JavaScript snippet into your HTML responses that enables parent pages to get notified whenever the iframe’s location changes and forces external links to open in a new tab.
+
+---
+
+## Features
+
+- **Location Change Notification**
+ Uses `postMessage` to inform the parent window of any URL changes inside the iframe (including pushState/popState events) for seamless SPA support.
+
+- **External Link Handling**
+ Automatically sets `target="_blank"` and `rel="noopener"` on links pointing outside your primary domain to improve security and user experience.
+
+- **Easy CSP Integration**
+ Calculates a CSP hash for the injected script so you can safely allow it via your Content Security Policy.
+
+---
+
+## Author
+
+Developed by **Kevin Veen-Birkenbach**
+[https://www.veen.world](https://www.veen.world) 🎉
\ No newline at end of file
diff --git a/roles/nginx-modifier-iframe/meta/main.yml b/roles/nginx-modifier-iframe/meta/main.yml
new file mode 100644
index 00000000..24cf628c
--- /dev/null
+++ b/roles/nginx-modifier-iframe/meta/main.yml
@@ -0,0 +1,28 @@
+
+---
+galaxy_info:
+ author: "Kevin Veen-Birkenbach"
+ description: "Injects a JS snippet into HTML to notify parent windows of iframe location changes and force external links to new tabs."
+ company: |
+ Kevin Veen-Birkenbach
+ Consulting & Coaching Solutions
+ https://www.veen.world
+ license: "CyMaIS NonCommercial License (CNCL)"
+ repository: https://s.veen.world/cymais
+ issue_tracker_url: https://s.veen.world/cymaisissues
+ documentation: https://s.veen.world/cymais
+ license_url: "https://s.veen.world/cncl"
+ min_ansible_version: "2.9"
+ platforms:
+ - name: Archlinux
+ versions:
+ - rolling
+ galaxy_tags:
+ - nginx
+ - iframe
+ - javascript
+ - csp
+ - security
+ - postMessage
+dependencies:
+ - nginx
\ No newline at end of file
diff --git a/roles/nginx-modifier-iframe/tasks/main.yml b/roles/nginx-modifier-iframe/tasks/main.yml
new file mode 100644
index 00000000..238cbd3f
--- /dev/null
+++ b/roles/nginx-modifier-iframe/tasks/main.yml
@@ -0,0 +1,12 @@
+- name: Load iFrame handler JS template
+ set_fact:
+ iframe_code: "{{ lookup('template','iframe-handler.js.j2') }}"
+
+- name: Collapse iFrame code into one-liner
+ set_fact:
+ iframe_code_one_liner: "{{ iframe_code | to_one_liner }}"
+
+- name: Append iFrame CSP hash
+ set_fact:
+ applications: "{{ applications | append_csp_hash(application_id, iframe_code_one_liner) }}"
+ changed_when: false
diff --git a/roles/nginx-modifier-iframe/templates/head_sub.j2 b/roles/nginx-modifier-iframe/templates/head_sub.j2
new file mode 100644
index 00000000..1ba92d16
--- /dev/null
+++ b/roles/nginx-modifier-iframe/templates/head_sub.j2
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/roles/nginx-modifier-iframe/templates/iframe-handler.js.j2 b/roles/nginx-modifier-iframe/templates/iframe-handler.js.j2
new file mode 100644
index 00000000..89388065
--- /dev/null
+++ b/roles/nginx-modifier-iframe/templates/iframe-handler.js.j2
@@ -0,0 +1,46 @@
+(function() {
+ var primary = "{{ primary_domain }}";
+ var allowedOrigin = "https://{{ domains | get_domain('portfolio') }}";
+
+ function notifyParent() {
+ try {
+ window.parent.postMessage({
+ type: "iframeLocationChange",
+ href: window.location.href
+ }, allowedOrigin);
+ } catch (e) {}
+ }
+
+ function forceExternalLinks() {
+ Array.prototype.forEach.call(document.querySelectorAll("a[href]"), function(a) {
+ try {
+ var url = new URL(a.href, location);
+ if (!url.hostname.endsWith(primary)) {
+ a.target = "_blank";
+ a.rel = "noopener";
+ }
+ } catch (e) {}
+ });
+ }
+
+ window.addEventListener("load", function() {
+ notifyParent();
+ forceExternalLinks();
+ });
+ window.addEventListener("popstate", function() {
+ notifyParent();
+ forceExternalLinks();
+ });
+
+ // SPA support
+ var _pushState = history.pushState;
+ history.pushState = function() {
+ _pushState.apply(history, arguments);
+ notifyParent();
+ forceExternalLinks();
+ };
+})();
+
+{% if enable_debug | bool %}
+console.log("[iframe-sync] Sender for iframe messages is active.");
+{% endif %}
\ No newline at end of file
diff --git a/roles/nginx-modifier-javascript/README.md b/roles/nginx-modifier-javascript/README.md
new file mode 100644
index 00000000..0ccac899
--- /dev/null
+++ b/roles/nginx-modifier-javascript/README.md
@@ -0,0 +1,28 @@
+# 🌐 Global JavaScript Injector for Nginx
+
+## Description
+
+This Ansible role injects a custom JavaScript snippet into all HTML responses served by Nginx. It leverages Nginx’s `sub_filter` to seamlessly insert your application-specific script just before the closing `` tag, ensuring that your code runs on every page load—perfect for global feature flags, analytics, or UI enhancements.
+
+## Features
+
+- **One-line Script Injection**
+ Collapses your JavaScript into a single line and injects it via `sub_filter` for minimal footprint and maximal compatibility.
+
+- **Easy CSP Integration**
+ Automatically computes and appends a CSP hash entry for your script, so you can lock down Content Security Policy without lifting a finger.
+
+- **Conditional Activation**
+ Activates only when you enable the `javascript` feature for a given application, keeping your server blocks clean and performant.
+
+- **Debug Mode**
+ Supports an `enable_debug` flag that appends optional `console.log` statements for easier troubleshooting in staging or development.
+
+## Author
+
+Developed by **Kevin Veen-Birkenbach**
+Consulting & Coaching Solutions — [veen.world](https://www.veen.world)
+
+---
+
+Happy automating! 🎉
diff --git a/roles/nginx-modifier-javascript/meta/main.yml b/roles/nginx-modifier-javascript/meta/main.yml
new file mode 100644
index 00000000..b70d0241
--- /dev/null
+++ b/roles/nginx-modifier-javascript/meta/main.yml
@@ -0,0 +1,28 @@
+---
+galaxy_info:
+ author: "Kevin Veen-Birkenbach"
+ description: "Injects a custom JavaScript snippet into Nginx-served HTML responses via sub_filter."
+ company: |
+ Kevin Veen-Birkenbach
+ Consulting & Coaching Solutions
+ https://www.veen.world
+ license: "CyMaIS NonCommercial License (CNCL)"
+ license_url: "https://s.veen.world/cncl"
+ min_ansible_version: "2.9"
+ platforms:
+ - name: Archlinux
+ versions:
+ - rolling
+ galaxy_tags:
+ - nginx
+ - javascript
+ - csp
+ - sub_filter
+ - injection
+ - global
+ repository: "https://s.veen.world/cymais"
+ documentation: "https://s.veen.world/cymais"
+ issue_tracker_url: "https://s.veen.world/cymaisissues"
+
+dependencies:
+ - nginx
diff --git a/roles/nginx-modifier-javascript/tasks/main.yml b/roles/nginx-modifier-javascript/tasks/main.yml
new file mode 100644
index 00000000..ea0842ca
--- /dev/null
+++ b/roles/nginx-modifier-javascript/tasks/main.yml
@@ -0,0 +1,12 @@
+- name: "Load JavaScript code for '{{ application_id }}'"
+ set_fact:
+ javascript_code: "{{ lookup('template', modifier_javascript_template_file) }}"
+
+- name: "Collapse Javascript code into one-liner for '{{application_id}}'"
+ set_fact:
+ javascript_code_one_liner: "{{ javascript_code | to_one_liner }}"
+
+- name: "Append Javascript CSP hash for '{{application_id}}'"
+ set_fact:
+ applications: "{{ applications | append_csp_hash(application_id, javascript_code_one_liner) }}"
+ changed_when: false
diff --git a/roles/nginx-modifier-javascript/templates/head_sub.j2 b/roles/nginx-modifier-javascript/templates/head_sub.j2
new file mode 100644
index 00000000..1731b48b
--- /dev/null
+++ b/roles/nginx-modifier-javascript/templates/head_sub.j2
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/roles/nginx-modifier-javascript/vars/main.yml b/roles/nginx-modifier-javascript/vars/main.yml
new file mode 100644
index 00000000..fcc5896a
--- /dev/null
+++ b/roles/nginx-modifier-javascript/vars/main.yml
@@ -0,0 +1 @@
+modifier_javascript_template_file: "{{ playbook_dir }}/roles/docker-{{ application_id }}/templates/javascript.js.j2"
\ No newline at end of file
diff --git a/roles/nginx-modifier-matomo/tasks/main.yml b/roles/nginx-modifier-matomo/tasks/main.yml
index 99bd42d6..a6886d1c 100644
--- a/roles/nginx-modifier-matomo/tasks/main.yml
+++ b/roles/nginx-modifier-matomo/tasks/main.yml
@@ -45,45 +45,16 @@
when: "matomo_site_id is not defined or matomo_site_id is none"
changed_when: false
-- name: Set the Matomo tracking code from a template file
+- name: Load Matomo tracking JS template
set_fact:
- matomo_tracking_code: "{{ lookup('template', 'matomo-tracking.js.j2') }}"
+ matomo_tracking_code: "{{ lookup('template','matomo-tracking.js.j2') }}"
-- name: Set the tracking code as a one-liner
+- name: Collapse Matomo code into one-liner
set_fact:
- matomo_tracking_code_one_liner: "{{ matomo_tracking_code | regex_replace('\\n', '') | regex_replace('\\s+', ' ') }}"
+ matomo_tracking_code_one_liner: "{{ matomo_tracking_code | to_one_liner }}"
-- name: Ensure csp.hashes exists for this app
+- name: Append Matomo CSP hash
set_fact:
- applications: >-
- {{
- applications
- | combine({
- (application_id): {
- 'csp': {
- 'hashes': {}
- }
- }
- }, recursive=True)
- }}
+ applications: "{{ applications | append_csp_hash(application_id, matomo_tracking_code_one_liner) }}"
changed_when: false
-- name: Append Matomo one-liner to script-src inline hashes
- set_fact:
- applications: >-
- {{
- applications
- | combine({
- (application_id): {
- 'csp': {
- 'hashes': {
- 'script-src-elem': (
- applications[application_id]['csp']['hashes'].get('script-src', [])
- + [ matomo_tracking_code_one_liner ]
- )
- }
- }
- }
- }, recursive=True)
- }}
- changed_when: false
diff --git a/roles/nginx-modifier-matomo/templates/script.j2 b/roles/nginx-modifier-matomo/templates/head_sub.j2
similarity index 100%
rename from roles/nginx-modifier-matomo/templates/script.j2
rename to roles/nginx-modifier-matomo/templates/head_sub.j2
diff --git a/roles/nginx-modifier-matomo/templates/matomo-tracking.js.j2 b/roles/nginx-modifier-matomo/templates/matomo-tracking.js.j2
index 7ef04580..f95df441 100644
--- a/roles/nginx-modifier-matomo/templates/matomo-tracking.js.j2
+++ b/roles/nginx-modifier-matomo/templates/matomo-tracking.js.j2
@@ -13,3 +13,7 @@ _paq.push(["enableLinkTracking"]);
var d=document, g=d.createElement("script"), s=d.getElementsByTagName("script")[0];
g.async=true; g.src=u+"matomo.js"; s.parentNode.insertBefore(g,s);
})();
+
+{% if enable_debug | bool %}
+console.log("Matomo is loaded.");
+{% endif %}
\ No newline at end of file
diff --git a/templates/docker_role/templates/javascript.js.j2 b/templates/docker_role/templates/javascript.js.j2
new file mode 100644
index 00000000..5a1bd867
--- /dev/null
+++ b/templates/docker_role/templates/javascript.js.j2
@@ -0,0 +1 @@
+alert('Custom JS loaded');
\ No newline at end of file
diff --git a/templates/docker_role/vars/configuration.yml.j2 b/templates/docker_role/vars/configuration.yml.j2
index 2a35308f..acb29542 100644
--- a/templates/docker_role/vars/configuration.yml.j2
+++ b/templates/docker_role/vars/configuration.yml.j2
@@ -16,6 +16,7 @@ features:
central_database: false # Enable Central Database Network
recaptcha: false # Enable ReCaptcha
oauth2: false # Enable the OAuth2-Proy
+ javascript: false # Enables the custom JS in the javascript.js.j2 file
csp:
whitelist: {} # URL's which should be whitelisted
flags: {} # Flags which should be set
diff --git a/tests/unit/filter_plugins/test_csp_hashes.py b/tests/unit/filter_plugins/test_csp_hashes.py
new file mode 100644
index 00000000..e5cde0c8
--- /dev/null
+++ b/tests/unit/filter_plugins/test_csp_hashes.py
@@ -0,0 +1,52 @@
+# tests/unit/filter_plugins/test_csp_hashes.py
+import unittest
+from ansible.errors import AnsibleFilterError
+from filter_plugins.csp_hashes import append_csp_hash
+
+class TestCspHashes(unittest.TestCase):
+ def setUp(self):
+ # Sample applications dict for testing
+ self.applications = {
+ 'app1': {
+ 'csp': {
+ 'hashes': {
+ 'script-src-elem': ["existing-hash"]
+ }
+ }
+ }
+ }
+ self.code = "new-hash" # example one-liner hash
+
+ def test_appends_new_hash(self):
+ result = append_csp_hash(self.applications, 'app1', self.code)
+ # Original remains unchanged
+ self.assertNotIn(self.code, self.applications['app1']['csp']['hashes']['script-src-elem'])
+ # New result should contain both existing and new
+ self.assertIn('existing-hash', result['app1']['csp']['hashes']['script-src-elem'])
+ self.assertIn(self.code, result['app1']['csp']['hashes']['script-src-elem'])
+
+ def test_does_not_duplicate_existing_hash(self):
+ # Append an existing hash
+ result = append_csp_hash(self.applications, 'app1', 'existing-hash')
+ # Should still only have one instance
+ hashes = result['app1']['csp']['hashes']['script-src-elem']
+ self.assertEqual(hashes.count('existing-hash'), 1)
+
+ def test_creates_missing_csp_structure(self):
+ # Remove csp and hashes keys
+ apps = {'app2': {}}
+ result = append_csp_hash(apps, 'app2', self.code)
+ self.assertIn('csp', result['app2'])
+ self.assertIn('hashes', result['app2']['csp'])
+ self.assertIn(self.code, result['app2']['csp']['hashes']['script-src-elem'])
+
+ def test_non_dict_applications_raises(self):
+ with self.assertRaises(AnsibleFilterError):
+ append_csp_hash('not-a-dict', 'app1', self.code)
+
+ def test_unknown_application_id_raises(self):
+ with self.assertRaises(AnsibleFilterError):
+ append_csp_hash(self.applications, 'unknown', self.code)
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/unit/filter_plugins/test_text_filters.py b/tests/unit/filter_plugins/test_text_filters.py
new file mode 100644
index 00000000..85f07a58
--- /dev/null
+++ b/tests/unit/filter_plugins/test_text_filters.py
@@ -0,0 +1,64 @@
+# tests/unit/filter_plugins/test_text_filters.py
+import unittest
+from ansible.errors import AnsibleFilterError
+from filter_plugins.text_filters import to_one_liner
+
+class TestTextFilters(unittest.TestCase):
+ def test_collapse_whitespace(self):
+ s = """Line one
+
+ Line two
+Line three"""
+ expected = "Line one Line two Line three"
+ self.assertEqual(to_one_liner(s), expected)
+
+ def test_remove_js_line_comments(self):
+ s = "var a = 1; // this is a comment\nvar b = 2;"
+ expected = "var a = 1; var b = 2;"
+ self.assertEqual(to_one_liner(s), expected)
+
+ def test_remove_js_block_comments(self):
+ s = "var a = /* comment inside */ 1; var b = 2;"
+ expected = "var a = 1; var b = 2;"
+ self.assertEqual(to_one_liner(s), expected)
+
+ def test_remove_multiple_comments(self):
+ s = "// first comment\nvar a = 1; /* block comment */ var b = 2; // end comment"
+ expected = "var a = 1; var b = 2;"
+ self.assertEqual(to_one_liner(s), expected)
+
+ def test_strips_leading_trailing_whitespace(self):
+ s = " \n some text here \n "
+ expected = "some text here"
+ self.assertEqual(to_one_liner(s), expected)
+
+ def test_non_string_raises(self):
+ with self.assertRaises(AnsibleFilterError):
+ to_one_liner(123)
+
+ def test_preserve_urls_in_string_literals(self):
+ s = 'var url = "http://example.com/path"; // comment'
+ expected = 'var url = "http://example.com/path";'
+ self.assertEqual(to_one_liner(s), expected)
+
+ def test_preserve_escaped_quotes_and_protocol(self):
+ s = "var s = 'He said \\'//not comment\\''; // remove this"
+ expected = "var s = 'He said \\'//not comment\\'';"
+ self.assertEqual(to_one_liner(s), expected)
+
+ def test_preserve_templating_expressions(self):
+ s = 'var tracker = "//{{ domains | get_domain(\'matomo\') }}/matomo.js"; // loader'
+ expected = 'var tracker = "//{{ domains | get_domain(\'matomo\') }}/matomo.js";'
+ self.assertEqual(to_one_liner(s), expected)
+
+ def test_mixed_string_and_comment(self):
+ s = '''
+ var a = "foo // still part of string";
+ // top-level comment
+ var b = 2; // end comment
+ '''
+ expected = 'var a = "foo // still part of string"; var b = 2;'
+ self.assertEqual(to_one_liner(s), expected)
+
+if __name__ == '__main__':
+ unittest.main()