From f08d69e9ea817a8f18c01a2ce3d2778d902e507f Mon Sep 17 00:00:00 2001 From: "Elias W. BA" Date: Wed, 12 Nov 2025 16:00:34 +0000 Subject: [PATCH 1/7] fix: prevent atom exhaustion in merge_projects mix task --- lib/mix/tasks/merge_projects.ex | 45 +++-- test/mix/tasks/merge_projects_test.exs | 236 +++++++++++++++++++++++-- 2 files changed, 247 insertions(+), 34 deletions(-) diff --git a/lib/mix/tasks/merge_projects.ex b/lib/mix/tasks/merge_projects.ex index 3672b9179c..99d46517b6 100644 --- a/lib/mix/tasks/merge_projects.ex +++ b/lib/mix/tasks/merge_projects.ex @@ -89,30 +89,45 @@ defmodule Mix.Tasks.Lightning.MergeProjects do write_output(output, output_path) end - defp perform_merge(source_project, target_project) do + defp perform_merge(source_data, target_data) do + # Convert string-keyed maps to atom-keyed maps using only existing atoms + # This prevents atom exhaustion attacks while allowing the merge algorithm + # to work with its expected data structure + source_project = atomize_keys(source_data) + target_project = atomize_keys(target_data) + MergeProjects.merge_project(source_project, target_project) rescue - e in KeyError -> + ArgumentError -> Mix.raise(""" - Failed to merge projects - missing required field: #{inspect(e.key)} - - #{Exception.message(e)} + Failed to merge projects - encountered unknown field in JSON - This may indicate incompatible or corrupted project state files. - Please verify both files are valid Lightning project exports. + This may indicate the JSON contains invalid or unexpected fields. + Please ensure both files are valid Lightning project exports. """) + end - e -> - Mix.raise(""" - Failed to merge projects - - #{Exception.message(e)} + defp atomize_keys(data) when is_map(data) do + Map.new(data, fn {key, value} -> + atom_key = + if is_binary(key) do + # Only convert to atoms that already exist + # This prevents creating new atoms from malicious input + String.to_existing_atom(key) + else + key + end + + {atom_key, atomize_keys(value)} + end) + end - This may indicate incompatible project structures or corrupted data. - Please verify both files are valid Lightning project exports. - """) + defp atomize_keys(data) when is_list(data) do + Enum.map(data, &atomize_keys/1) end + defp atomize_keys(data), do: data + defp encode_json(project) do Jason.encode!(project, pretty: true) rescue diff --git a/test/mix/tasks/merge_projects_test.exs b/test/mix/tasks/merge_projects_test.exs index debb6b5365..ff038fd6eb 100644 --- a/test/mix/tasks/merge_projects_test.exs +++ b/test/mix/tasks/merge_projects_test.exs @@ -12,7 +12,7 @@ defmodule Mix.Tasks.Lightning.MergeProjectsTest do source_state = build_simple_project( id: "source-id", - name: "Source", + name: "source-project", workflow_name: "Test Workflow", job_name: "Job 1", job_body: "console.log('updated')" @@ -21,7 +21,7 @@ defmodule Mix.Tasks.Lightning.MergeProjectsTest do target_state = build_simple_project( id: "target-id", - name: "Target", + name: "target-project", env: "production", workflow_name: "Test Workflow", job_name: "Job 1", @@ -42,8 +42,7 @@ defmodule Mix.Tasks.Lightning.MergeProjectsTest do {:ok, result} = Jason.decode(output) assert result["id"] == "target-id" - assert result["name"] == "Target" - assert result["env"] == "production" + assert result["name"] == "target-project" assert length(result["workflows"]) == 1 workflow = hd(result["workflows"]) @@ -59,10 +58,18 @@ defmodule Mix.Tasks.Lightning.MergeProjectsTest do tmp_dir: tmp_dir } do source_state = - build_project_state(id: "source-id", name: "Source", workflows: []) + build_project_state( + id: "source-id", + name: "source-project", + workflows: [] + ) target_state = - build_project_state(id: "target-id", name: "Target", workflows: []) + build_project_state( + id: "target-id", + name: "target-project", + workflows: [] + ) source_file = Path.join(tmp_dir, "source.json") target_file = Path.join(tmp_dir, "target.json") @@ -85,17 +92,25 @@ defmodule Mix.Tasks.Lightning.MergeProjectsTest do {:ok, result} = Jason.decode(content) assert result["id"] == "target-id" - assert result["name"] == "Target" + assert result["name"] == "target-project" end test "writes merged output to file when -o flag is provided", %{ tmp_dir: tmp_dir } do source_state = - build_project_state(id: "source-id", name: "Source", workflows: []) + build_project_state( + id: "source-id", + name: "source-project", + workflows: [] + ) target_state = - build_project_state(id: "target-id", name: "Target", workflows: []) + build_project_state( + id: "target-id", + name: "target-project", + workflows: [] + ) source_file = Path.join(tmp_dir, "source.json") target_file = Path.join(tmp_dir, "target.json") @@ -267,8 +282,11 @@ defmodule Mix.Tasks.Lightning.MergeProjectsTest do test "raises clear error when output directory does not exist", %{ tmp_dir: tmp_dir } do - source_state = build_project_state(id: "s", name: "S", workflows: []) - target_state = build_project_state(id: "t", name: "T", workflows: []) + source_state = + build_project_state(id: "s", name: "source-project", workflows: []) + + target_state = + build_project_state(id: "t", name: "target-project", workflows: []) source_file = Path.join(tmp_dir, "source.json") target_file = Path.join(tmp_dir, "target.json") @@ -292,8 +310,11 @@ defmodule Mix.Tasks.Lightning.MergeProjectsTest do test "raises clear error when output directory is not writable", %{ tmp_dir: tmp_dir } do - source_state = build_project_state(id: "s", name: "S", workflows: []) - target_state = build_project_state(id: "t", name: "T", workflows: []) + source_state = + build_project_state(id: "s", name: "source-project", workflows: []) + + target_state = + build_project_state(id: "t", name: "target-project", workflows: []) source_file = Path.join(tmp_dir, "source.json") target_file = Path.join(tmp_dir, "target.json") @@ -325,8 +346,11 @@ defmodule Mix.Tasks.Lightning.MergeProjectsTest do %{ tmp_dir: tmp_dir } do - source_state = build_project_state(id: "s", name: "S", workflows: []) - target_state = build_project_state(id: "t", name: "T", workflows: []) + source_state = + build_project_state(id: "s", name: "source-project", workflows: []) + + target_state = + build_project_state(id: "t", name: "target-project", workflows: []) source_file = Path.join(tmp_dir, "source.json") target_file = Path.join(tmp_dir, "target.json") @@ -356,14 +380,12 @@ defmodule Mix.Tasks.Lightning.MergeProjectsTest do end describe "run/1 - merge operation errors" do - test "raises clear error when project structure causes merge to fail", %{ + test "raises error when project structure causes merge to fail", %{ tmp_dir: tmp_dir } do source_file = Path.join(tmp_dir, "source.json") target_file = Path.join(tmp_dir, "target.json") - # Source with workflows as a string instead of array - # This will cause an error when MergeProjects tries to iterate File.write!( source_file, Jason.encode!(%{ @@ -378,9 +400,185 @@ defmodule Mix.Tasks.Lightning.MergeProjectsTest do Jason.encode!(%{"id" => "t", "name" => "T", "workflows" => []}) ) - assert_raise Mix.Error, ~r/Failed to merge projects/, fn -> + # The merge will fail with Protocol.UndefinedError when trying to + # enumerate workflows (which is a string instead of a list) + # This is expected behavior - we let the merge algorithm fail naturally + assert_raise Protocol.UndefinedError, fn -> + Mix.Tasks.Lightning.MergeProjects.run([source_file, target_file]) + end + end + end + + describe "run/1 - security" do + test "safely handles JSON with unknown keys without creating new atoms", %{ + tmp_dir: tmp_dir + } do + source_file = Path.join(tmp_dir, "source.json") + target_file = Path.join(tmp_dir, "target.json") + + # JSON with unknown keys that don't correspond to existing atoms + File.write!( + source_file, + Jason.encode!(%{ + "id" => Ecto.UUID.generate(), + "name" => "source-project", + "malicious_unknown_key_12345" => "value", + "workflows" => [] + }) + ) + + target_state = build_project_state(id: "t", name: "target-project") + File.write!(target_file, Jason.encode!(target_state)) + + # Should raise ArgumentError when trying to convert unknown key to atom + assert_raise Mix.Error, ~r/encountered unknown field/, fn -> Mix.Tasks.Lightning.MergeProjects.run([source_file, target_file]) end end end + + describe "run/1 - flexibility for testing (Joe's requirements)" do + test "allows non-UUID IDs for testing purposes", %{tmp_dir: tmp_dir} do + source_state = + build_simple_project( + id: "test-source-1", + name: "source-project", + workflow_name: "Test Workflow", + job_name: "Job 1", + job_body: "console.log('updated')" + ) + + target_state = + build_simple_project( + id: "test-target-1", + name: "target-project", + workflow_name: "Test Workflow", + job_name: "Job 1", + job_body: "console.log('old')" + ) + + source_file = Path.join(tmp_dir, "source.json") + target_file = Path.join(tmp_dir, "target.json") + + File.write!(source_file, Jason.encode!(source_state)) + File.write!(target_file, Jason.encode!(target_state)) + + output = + capture_io(fn -> + Mix.Tasks.Lightning.MergeProjects.run([source_file, target_file]) + end) + + {:ok, result} = Jason.decode(output) + + assert result["id"] == "test-target-1" + assert result["name"] == "target-project" + end + + test "handles projects with simple numeric IDs", %{tmp_dir: tmp_dir} do + source_state = + build_project_state( + id: "1", + name: "source-project", + workflows: [] + ) + + target_state = + build_project_state( + id: "2", + name: "target-project", + workflows: [] + ) + + source_file = Path.join(tmp_dir, "source.json") + target_file = Path.join(tmp_dir, "target.json") + + File.write!(source_file, Jason.encode!(source_state)) + File.write!(target_file, Jason.encode!(target_state)) + + output = + capture_io(fn -> + Mix.Tasks.Lightning.MergeProjects.run([source_file, target_file]) + end) + + {:ok, result} = Jason.decode(output) + + assert result["id"] == "2" + assert result["name"] == "target-project" + end + + test "successfully merges projects with deeply nested structures", %{ + tmp_dir: tmp_dir + } do + source_state = + build_simple_project( + id: "source", + name: "Source Project", + workflow_name: "Workflow 1", + job_name: "Job 1", + job_body: "console.log('updated')" + ) + + target_state = + build_simple_project( + id: "target", + name: "Target Project", + workflow_name: "Workflow 1", + job_name: "Job 1", + job_body: "console.log('old')" + ) + + source_file = Path.join(tmp_dir, "source.json") + target_file = Path.join(tmp_dir, "target.json") + + File.write!(source_file, Jason.encode!(source_state)) + File.write!(target_file, Jason.encode!(target_state)) + + output = + capture_io(fn -> + Mix.Tasks.Lightning.MergeProjects.run([source_file, target_file]) + end) + + {:ok, result} = Jason.decode(output) + + assert result["id"] == "target" + + workflow = hd(result["workflows"]) + assert workflow["name"] == "Workflow 1" + + job = hd(workflow["jobs"]) + assert job["body"] == "console.log('updated')" + assert job["name"] == "Job 1" + end + + test "works offline without database access", %{tmp_dir: tmp_dir} do + source_state = + build_project_state( + id: "offline-source", + name: "source-project", + workflows: [] + ) + + target_state = + build_project_state( + id: "offline-target", + name: "target-project", + workflows: [] + ) + + source_file = Path.join(tmp_dir, "source.json") + target_file = Path.join(tmp_dir, "target.json") + + File.write!(source_file, Jason.encode!(source_state)) + File.write!(target_file, Jason.encode!(target_state)) + + output = + capture_io(fn -> + Mix.Tasks.Lightning.MergeProjects.run([source_file, target_file]) + end) + + {:ok, result} = Jason.decode(output) + + assert result["id"] == "offline-target" + end + end end From eca8f3fa5148e125b1c803c4d09c47631ae40f2f Mon Sep 17 00:00:00 2001 From: "Elias W. BA" Date: Wed, 12 Nov 2025 16:21:03 +0000 Subject: [PATCH 2/7] refactor: remove unnecessary comments --- lib/mix/tasks/merge_projects.ex | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lib/mix/tasks/merge_projects.ex b/lib/mix/tasks/merge_projects.ex index 99d46517b6..4acd664a3f 100644 --- a/lib/mix/tasks/merge_projects.ex +++ b/lib/mix/tasks/merge_projects.ex @@ -90,9 +90,6 @@ defmodule Mix.Tasks.Lightning.MergeProjects do end defp perform_merge(source_data, target_data) do - # Convert string-keyed maps to atom-keyed maps using only existing atoms - # This prevents atom exhaustion attacks while allowing the merge algorithm - # to work with its expected data structure source_project = atomize_keys(source_data) target_project = atomize_keys(target_data) @@ -111,8 +108,6 @@ defmodule Mix.Tasks.Lightning.MergeProjects do Map.new(data, fn {key, value} -> atom_key = if is_binary(key) do - # Only convert to atoms that already exist - # This prevents creating new atoms from malicious input String.to_existing_atom(key) else key From d28fe5e0131879ffb916f0676f61fa5dc977392a Mon Sep 17 00:00:00 2001 From: "Elias W. BA" Date: Wed, 12 Nov 2025 16:22:27 +0000 Subject: [PATCH 3/7] test: remove unreliable security test --- test/mix/tasks/merge_projects_test.exs | 28 -------------------------- 1 file changed, 28 deletions(-) diff --git a/test/mix/tasks/merge_projects_test.exs b/test/mix/tasks/merge_projects_test.exs index ff038fd6eb..79ae8f3c91 100644 --- a/test/mix/tasks/merge_projects_test.exs +++ b/test/mix/tasks/merge_projects_test.exs @@ -409,34 +409,6 @@ defmodule Mix.Tasks.Lightning.MergeProjectsTest do end end - describe "run/1 - security" do - test "safely handles JSON with unknown keys without creating new atoms", %{ - tmp_dir: tmp_dir - } do - source_file = Path.join(tmp_dir, "source.json") - target_file = Path.join(tmp_dir, "target.json") - - # JSON with unknown keys that don't correspond to existing atoms - File.write!( - source_file, - Jason.encode!(%{ - "id" => Ecto.UUID.generate(), - "name" => "source-project", - "malicious_unknown_key_12345" => "value", - "workflows" => [] - }) - ) - - target_state = build_project_state(id: "t", name: "target-project") - File.write!(target_file, Jason.encode!(target_state)) - - # Should raise ArgumentError when trying to convert unknown key to atom - assert_raise Mix.Error, ~r/encountered unknown field/, fn -> - Mix.Tasks.Lightning.MergeProjects.run([source_file, target_file]) - end - end - end - describe "run/1 - flexibility for testing (Joe's requirements)" do test "allows non-UUID IDs for testing purposes", %{tmp_dir: tmp_dir} do source_state = From 172e0116afb27ba09b1244d124c42b72403a27f6 Mon Sep 17 00:00:00 2001 From: "Elias W. BA" Date: Wed, 12 Nov 2025 16:30:53 +0000 Subject: [PATCH 4/7] refactor: simplify atomize_keys with inline conditional --- lib/mix/tasks/merge_projects.ex | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/lib/mix/tasks/merge_projects.ex b/lib/mix/tasks/merge_projects.ex index 4acd664a3f..c8241734c2 100644 --- a/lib/mix/tasks/merge_projects.ex +++ b/lib/mix/tasks/merge_projects.ex @@ -106,13 +106,7 @@ defmodule Mix.Tasks.Lightning.MergeProjects do defp atomize_keys(data) when is_map(data) do Map.new(data, fn {key, value} -> - atom_key = - if is_binary(key) do - String.to_existing_atom(key) - else - key - end - + atom_key = if is_binary(key), do: String.to_existing_atom(key), else: key {atom_key, atomize_keys(value)} end) end From 3206067e40e4afaec99e3c4fa3da0a2417ce9bce Mon Sep 17 00:00:00 2001 From: "Elias W. BA" Date: Fri, 14 Nov 2025 00:52:29 +0000 Subject: [PATCH 5/7] fix: complete atom exhaustion protection in merge_projects task Address PR feedback by implementing comprehensive atom safety: - Remove keys: :atoms from Jason.decode to prevent DoS attacks - Rename atomize_keys to atomize for accuracy (atomizes entire structures) - Add ensure_schemas_loaded() to load schema modules before atomization - Use module attribute @required_schemas for maintainable schema list - Add test coverage for ArgumentError rescue on unknown atoms - Optimize schema loading placement (after file validation) This ensures String.to_existing_atom/1 works safely by guaranteeing all schema field atoms (id, name, workflows, etc.) exist in memory before JSON key conversion. Fixes #3615 --- lib/mix/tasks/merge_projects.ex | 43 +++++++++++++++++--------- test/mix/tasks/merge_projects_test.exs | 35 +++++++++++++++++++++ 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/lib/mix/tasks/merge_projects.ex b/lib/mix/tasks/merge_projects.ex index c8241734c2..bf106da8d3 100644 --- a/lib/mix/tasks/merge_projects.ex +++ b/lib/mix/tasks/merge_projects.ex @@ -34,6 +34,16 @@ defmodule Mix.Tasks.Lightning.MergeProjects do alias Lightning.Projects.MergeProjects + # Schema modules that must be loaded before atomizing JSON keys. + # These schemas define the field atoms used in project export files. + @required_schemas [ + Lightning.Projects.Project, + Lightning.Workflows.Workflow, + Lightning.Workflows.Job, + Lightning.Workflows.Trigger, + Lightning.Workflows.Edge + ] + @impl Mix.Task def run(args) do {opts, positional, invalid} = @@ -80,20 +90,28 @@ defmodule Mix.Tasks.Lightning.MergeProjects do end source_project = read_state_file(source_file, "source") - target_project = read_state_file(target_file, "target") + ensure_schemas_loaded() + merged_project = perform_merge(source_project, target_project) output = encode_json(merged_project) write_output(output, output_path) end - defp perform_merge(source_data, target_data) do - source_project = atomize_keys(source_data) - target_project = atomize_keys(target_data) + defp ensure_schemas_loaded do + # IMPORTANT: Load schema modules to ensure their field atoms exist in memory. + # This enables safe String.to_existing_atom/1 conversion when atomizing JSON keys. + # + # When adding schemas referenced in project exports, add them to the + # @required_schemas list at the top of this module to prevent ArgumentError + # during merge operations. + Enum.each(@required_schemas, &Code.ensure_loaded/1) + end - MergeProjects.merge_project(source_project, target_project) + defp perform_merge(source_data, target_data) do + MergeProjects.merge_project(atomize(source_data), atomize(target_data)) rescue ArgumentError -> Mix.raise(""" @@ -104,18 +122,18 @@ defmodule Mix.Tasks.Lightning.MergeProjects do """) end - defp atomize_keys(data) when is_map(data) do + defp atomize(data) when is_map(data) do Map.new(data, fn {key, value} -> atom_key = if is_binary(key), do: String.to_existing_atom(key), else: key - {atom_key, atomize_keys(value)} + {atom_key, atomize(value)} end) end - defp atomize_keys(data) when is_list(data) do - Enum.map(data, &atomize_keys/1) + defp atomize(data) when is_list(data) do + Enum.map(data, &atomize/1) end - defp atomize_keys(data), do: data + defp atomize(data), do: data defp encode_json(project) do Jason.encode!(project, pretty: true) @@ -239,11 +257,8 @@ defmodule Mix.Tasks.Lightning.MergeProjects do """) end - case Jason.decode(content, keys: :atoms) do + case Jason.decode(content) do {:ok, data} -> - # Jason's keys: :atoms option converts all string keys to atoms - # This is safe for controlled JSON file input (not arbitrary user input) - # The merge_project function requires atom keys for dot notation access data {:error, %Jason.DecodeError{} = error} -> diff --git a/test/mix/tasks/merge_projects_test.exs b/test/mix/tasks/merge_projects_test.exs index 79ae8f3c91..44b2a59831 100644 --- a/test/mix/tasks/merge_projects_test.exs +++ b/test/mix/tasks/merge_projects_test.exs @@ -407,6 +407,41 @@ defmodule Mix.Tasks.Lightning.MergeProjectsTest do Mix.Tasks.Lightning.MergeProjects.run([source_file, target_file]) end end + + test "raises error when JSON contains unknown fields (atom exhaustion protection)", + %{ + tmp_dir: tmp_dir + } do + source_file = Path.join(tmp_dir, "source.json") + target_file = Path.join(tmp_dir, "target.json") + + # Create JSON with a field that won't exist as an atom + # This protects against atom exhaustion attacks + File.write!( + source_file, + Jason.encode!(%{ + "id" => "source-id", + "name" => "Source", + "unknown_field_that_does_not_exist_as_atom_#{System.unique_integer()}" => + "value", + "workflows" => [] + }) + ) + + File.write!( + target_file, + Jason.encode!(%{"id" => "t", "name" => "T", "workflows" => []}) + ) + + assert_raise Mix.Error, + ~r/Failed to merge projects - encountered unknown field in JSON/, + fn -> + Mix.Tasks.Lightning.MergeProjects.run([ + source_file, + target_file + ]) + end + end end describe "run/1 - flexibility for testing (Joe's requirements)" do From 82af592839f2ff2df968e890329f4e84a02636bc Mon Sep 17 00:00:00 2001 From: "Elias W. BA" Date: Fri, 14 Nov 2025 01:04:20 +0000 Subject: [PATCH 6/7] refactor: remove duplicate merge test Consolidates two nearly identical tests that were testing the same functionality with different ID formats. The remaining test now has clearer comments explaining what each assertion verifies. Addresses Rory's feedback about test duplication around lines 9-55 and 704-746 in the test file. --- test/mix/tasks/merge_projects_test.exs | 50 +++----------------------- 1 file changed, 5 insertions(+), 45 deletions(-) diff --git a/test/mix/tasks/merge_projects_test.exs b/test/mix/tasks/merge_projects_test.exs index 76aa8f704a..6d14ce8e6b 100644 --- a/test/mix/tasks/merge_projects_test.exs +++ b/test/mix/tasks/merge_projects_test.exs @@ -41,17 +41,21 @@ defmodule Mix.Tasks.Lightning.MergeProjectsTest do {:ok, result} = Jason.decode(output) + # Preserves target project metadata assert result["id"] == "target-id" assert result["name"] == "target-project" assert length(result["workflows"]) == 1 + # Merges workflow structure workflow = hd(result["workflows"]) target_workflow = hd(target_state["workflows"]) assert workflow["id"] == target_workflow["id"] assert workflow["name"] == "Test Workflow" + # Updates job content from source job = hd(workflow["jobs"]) assert job["body"] == "console.log('updated')" + assert job["name"] == "Job 1" end test "writes merged output to file when --output flag is provided", %{ @@ -632,7 +636,7 @@ defmodule Mix.Tasks.Lightning.MergeProjectsTest do end end - describe "run/1 - flexibility for testing (Joe's requirements)" do + describe "run/1 - flexibility for testing" do test "allows non-UUID IDs for testing purposes", %{tmp_dir: tmp_dir} do source_state = build_simple_project( @@ -701,50 +705,6 @@ defmodule Mix.Tasks.Lightning.MergeProjectsTest do assert result["name"] == "target-project" end - test "successfully merges projects with deeply nested structures", %{ - tmp_dir: tmp_dir - } do - source_state = - build_simple_project( - id: "source", - name: "Source Project", - workflow_name: "Workflow 1", - job_name: "Job 1", - job_body: "console.log('updated')" - ) - - target_state = - build_simple_project( - id: "target", - name: "Target Project", - workflow_name: "Workflow 1", - job_name: "Job 1", - job_body: "console.log('old')" - ) - - source_file = Path.join(tmp_dir, "source.json") - target_file = Path.join(tmp_dir, "target.json") - - File.write!(source_file, Jason.encode!(source_state)) - File.write!(target_file, Jason.encode!(target_state)) - - output = - capture_io(fn -> - Mix.Tasks.Lightning.MergeProjects.run([source_file, target_file]) - end) - - {:ok, result} = Jason.decode(output) - - assert result["id"] == "target" - - workflow = hd(result["workflows"]) - assert workflow["name"] == "Workflow 1" - - job = hd(workflow["jobs"]) - assert job["body"] == "console.log('updated')" - assert job["name"] == "Job 1" - end - test "works offline without database access", %{tmp_dir: tmp_dir} do source_state = build_project_state( From 655c8fc3d9f92bd66b45113a2ce8012d3b780c44 Mon Sep 17 00:00:00 2001 From: "Elias W. BA" Date: Fri, 14 Nov 2025 01:15:35 +0000 Subject: [PATCH 7/7] refactor: clarify test name for standalone execution --- test/mix/tasks/merge_projects_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/mix/tasks/merge_projects_test.exs b/test/mix/tasks/merge_projects_test.exs index 6d14ce8e6b..472f20c77d 100644 --- a/test/mix/tasks/merge_projects_test.exs +++ b/test/mix/tasks/merge_projects_test.exs @@ -705,7 +705,7 @@ defmodule Mix.Tasks.Lightning.MergeProjectsTest do assert result["name"] == "target-project" end - test "works offline without database access", %{tmp_dir: tmp_dir} do + test "runs standalone without database fixtures", %{tmp_dir: tmp_dir} do source_state = build_project_state( id: "offline-source",