test: D1-5 enforce LiquidTagSubset via restricted parser, reject unsupported tags
This commit is contained in:
@@ -118,7 +118,7 @@ All reconciled to follow code. Specs must be self-consistent and match code.
|
|||||||
| D1-2 | ~~UniqueTranslationPerLanguage invariant~~ | translation.allium:94 | **Resolved:** test added (re-upsert updates not duplicates; direct duplicate insert rejected). Same bug as D1-1 — `Posts.Translation` declared the migration's index name but ecto_sqlite3 derives the violated-constraint name from columns, so duplicates crashed instead of returning a changeset error; fixed `unique_constraint` name to `:post_translations_translation_for_language_index` |
|
| D1-2 | ~~UniqueTranslationPerLanguage invariant~~ | translation.allium:94 | **Resolved:** test added (re-upsert updates not duplicates; direct duplicate insert rejected). Same bug as D1-1 — `Posts.Translation` declared the migration's index name but ecto_sqlite3 derives the violated-constraint name from columns, so duplicates crashed instead of returning a changeset error; fixed `unique_constraint` name to `:post_translations_translation_for_language_index` |
|
||||||
| D1-3 | ~~BundledDefaultTemplatesExistOutsideProjectData~~ | template.allium:65 | **Resolved:** added 4 tests in `template_lookup_priority_test.exs` — with no Template rows for the project, `load_template_source/3` resolves bundled single-post/post-list/not-found defaults (and still resolves when the project has no `templates/` directory at all) |
|
| D1-3 | ~~BundledDefaultTemplatesExistOutsideProjectData~~ | template.allium:65 | **Resolved:** added 4 tests in `template_lookup_priority_test.exs` — with no Template rows for the project, `load_template_source/3` resolves bundled single-post/post-list/not-found defaults (and still resolves when the project has no `templates/` directory at all) |
|
||||||
| D1-4 | ~~UserTemplateDirectoryOverridesBundledDefaults~~ | template.allium:75 | **Resolved:** added 2 tests in `template_lookup_priority_test.exs` — a published project Template row with the bundled default slug (`single-post`) wins over the bundled default both when resolving `:post` with no explicit slug and when the slug is requested explicitly |
|
| D1-4 | ~~UserTemplateDirectoryOverridesBundledDefaults~~ | template.allium:75 | **Resolved:** added 2 tests in `template_lookup_priority_test.exs` — a published project Template row with the bundled default slug (`single-post`) wins over the bundled default both when resolving `:post` with no explicit slug and when the slug is requested explicitly |
|
||||||
| D1-5 | LiquidTagSubset (5 tags only) | template.allium:179 | Write test: unsupported tag raises error |
|
| D1-5 | ~~LiquidTagSubset (5 tags only)~~ | template.allium:179 | **Resolved:** added `BDS.Rendering.LiquidParser`, a restricted Liquex parser recognizing only the subset (if/for/assign/render + `{{ }}` output); any other tag (`unless`, `case`, `capture`, `tablerow`, `cycle`, `increment`, …) leaves unmatched input and fails `eos/0`. Wired into `validate_liquid` (publish gate), `template_selection.render_template`, `filters.render_macro_source`, and MCP `validate_template` so validation and rendering share the same surface; 6 parametrized tests added asserting unsupported tags are rejected at publish |
|
||||||
| D1-6 | LiquidFilterSubset (4 standard + 2 custom) | template.allium:191 | Write test: unsupported filter raises error |
|
| D1-6 | LiquidFilterSubset (4 standard + 2 custom) | template.allium:191 | Write test: unsupported filter raises error |
|
||||||
| D1-7 | LiquidOperatorSubset | template.allium:210 | Write test: unsupported operator raises error |
|
| D1-7 | LiquidOperatorSubset | template.allium:210 | Write test: unsupported operator raises error |
|
||||||
| D1-8 | MacroTimeout guarantee | script.allium:94-95 | Write test: macro times out within budget |
|
| D1-8 | MacroTimeout guarantee | script.allium:94-95 | Write test: macro times out within budget |
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ defmodule BDS.MCP.Tools do
|
|||||||
|
|
||||||
@spec validate_template(String.t()) :: {:ok, %{valid: boolean(), errors: [String.t()]}}
|
@spec validate_template(String.t()) :: {:ok, %{valid: boolean(), errors: [String.t()]}}
|
||||||
def validate_template(source) when is_binary(source) do
|
def validate_template(source) when is_binary(source) do
|
||||||
case Liquex.parse(source) do
|
case Liquex.parse(source, BDS.Rendering.LiquidParser) do
|
||||||
{:ok, _ast} ->
|
{:ok, _ast} ->
|
||||||
{:ok, %{valid: true, errors: []}}
|
{:ok, %{valid: true, errors: []}}
|
||||||
|
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ defmodule BDS.Rendering.Filters do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp render_macro_source(template_path, template_source, assigns, context) do
|
defp render_macro_source(template_path, template_source, assigns, context) do
|
||||||
with {:ok, template_ast} <- Liquex.parse(template_source),
|
with {:ok, template_ast} <- Liquex.parse(template_source, BDS.Rendering.LiquidParser),
|
||||||
{:ok, rendered} <- safe_liquex_render(template_ast, context, assigns) do
|
{:ok, rendered} <- safe_liquex_render(template_ast, context, assigns) do
|
||||||
rendered
|
rendered
|
||||||
else
|
else
|
||||||
|
|||||||
66
lib/bds/rendering/liquid_parser.ex
Normal file
66
lib/bds/rendering/liquid_parser.ex
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
defmodule BDS.Rendering.LiquidParser do
|
||||||
|
@moduledoc """
|
||||||
|
Restricted Liquid parser enforcing the `LiquidTagSubset` invariant
|
||||||
|
(`specs/template.allium`).
|
||||||
|
|
||||||
|
Only the tags used by the bundled starter templates are recognized:
|
||||||
|
|
||||||
|
* `{% if %}` / `{% elsif %}` / `{% else %}` / `{% endif %}`
|
||||||
|
* `{% for %}` / `{% endfor %}`
|
||||||
|
* `{% assign %}`
|
||||||
|
* `{% render 'partial', name: value %}`
|
||||||
|
* `{{ object }}` output (and whitespace-stripped variants `{%- -%}` / `{{- -}}`)
|
||||||
|
|
||||||
|
Any tag outside this subset (`unless`, `case`, `capture`, `raw`, `comment`,
|
||||||
|
`cycle`, `tablerow`, `increment`, `decrement`, `liquid`, `echo`, `include`)
|
||||||
|
leaves unmatched input and fails the `eos/0` check, producing a parse error.
|
||||||
|
|
||||||
|
Pass this module as the second argument to `Liquex.parse/2` (and
|
||||||
|
`Liquex.parse!/2`) so validation and rendering share the same surface.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import NimbleParsec
|
||||||
|
|
||||||
|
@tags [
|
||||||
|
Liquex.Tag.AssignTag,
|
||||||
|
Liquex.Tag.ForTag,
|
||||||
|
Liquex.Tag.IfTag,
|
||||||
|
Liquex.Tag.RenderTag,
|
||||||
|
Liquex.Tag.ObjectTag
|
||||||
|
]
|
||||||
|
|
||||||
|
tags_parser = Enum.map(@tags, &tag(&1.parse(), {:tag, &1}))
|
||||||
|
|
||||||
|
# Ensure the tags are loaded into scope, otherwise function_exported? will
|
||||||
|
# return false.
|
||||||
|
Enum.each(@tags, &Code.ensure_loaded!/1)
|
||||||
|
|
||||||
|
liquid_tags_parser =
|
||||||
|
@tags
|
||||||
|
|> Enum.filter(&function_exported?(&1, :parse_liquid_tag, 0))
|
||||||
|
|> Enum.map(&tag(&1.parse_liquid_tag(), {:tag, &1}))
|
||||||
|
|> choice()
|
||||||
|
|
||||||
|
# Special case for leading spaces before `{%-` and `{{-`.
|
||||||
|
leading_whitespace =
|
||||||
|
empty()
|
||||||
|
# credo:disable-for-lines:1
|
||||||
|
|> Liquex.Parser.Literal.whitespace(1)
|
||||||
|
|> lookahead(choice([string("{%-"), string("{{-")]))
|
||||||
|
|> ignore()
|
||||||
|
|
||||||
|
base =
|
||||||
|
choice(
|
||||||
|
tags_parser ++
|
||||||
|
[
|
||||||
|
# credo:disable-for-lines:2
|
||||||
|
Liquex.Parser.Literal.text(),
|
||||||
|
leading_whitespace
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
defcombinatorp(:document, repeat(base))
|
||||||
|
defcombinatorp(:liquid_tag_contents, repeat(liquid_tags_parser))
|
||||||
|
|
||||||
|
defparsec(:parse, parsec(:document) |> eos())
|
||||||
|
end
|
||||||
@@ -132,7 +132,7 @@ defmodule BDS.Rendering.TemplateSelection do
|
|||||||
@spec render_template(String.t(), String.t(), map()) ::
|
@spec render_template(String.t(), String.t(), map()) ::
|
||||||
{:ok, String.t()} | {:error, String.t()}
|
{:ok, String.t()} | {:error, String.t()}
|
||||||
def render_template(project_id, source, assigns) do
|
def render_template(project_id, source, assigns) do
|
||||||
with {:ok, template_ast} <- Liquex.parse(source),
|
with {:ok, template_ast} <- Liquex.parse(source, BDS.Rendering.LiquidParser),
|
||||||
{:ok, _rendered} = ok <- safe_liquex_render(template_ast, project_id, assigns) do
|
{:ok, _rendered} = ok <- safe_liquex_render(template_ast, project_id, assigns) do
|
||||||
ok
|
ok
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -350,7 +350,7 @@ defmodule BDS.Templates do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp validate_liquid(source) do
|
defp validate_liquid(source) do
|
||||||
case Liquex.parse(source) do
|
case Liquex.parse(source, BDS.Rendering.LiquidParser) do
|
||||||
{:ok, _ast} -> :ok
|
{:ok, _ast} -> :ok
|
||||||
{:error, reason, line} -> {:error, "#{reason} at line #{line}"}
|
{:error, reason, line} -> {:error, "#{reason} at line #{line}"}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -315,6 +315,31 @@ defmodule BDS.TemplatesTest do
|
|||||||
assert published.status == :published
|
assert published.status == :published
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# LiquidTagSubset invariant (template.allium): only {% if %}, {% for %},
|
||||||
|
# {% assign %}, {% render %} (plus {{ }} output) are supported. Any tag
|
||||||
|
# outside that subset must be rejected at publish time.
|
||||||
|
for tag <- ["unless", "case", "capture", "tablerow", "cycle", "increment"] do
|
||||||
|
@tag_name tag
|
||||||
|
test "publish_template rejects unsupported Liquid tag #{tag}", %{
|
||||||
|
project: project
|
||||||
|
} do
|
||||||
|
tag = @tag_name
|
||||||
|
|
||||||
|
assert {:ok, template} =
|
||||||
|
BDS.Templates.create_template(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Tag #{tag}",
|
||||||
|
kind: :post,
|
||||||
|
content: "{% #{tag} foo %}bar{% end#{tag} %}"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:error, {:invalid_liquid, _reason}} = BDS.Templates.publish_template(template.id)
|
||||||
|
|
||||||
|
reloaded = Repo.get!(BDS.Templates.Template, template.id)
|
||||||
|
assert reloaded.status == :draft
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
test "rebuild_templates_from_files recreates published templates from disk", %{
|
test "rebuild_templates_from_files recreates published templates from disk", %{
|
||||||
project: project,
|
project: project,
|
||||||
temp_dir: temp_dir
|
temp_dir: temp_dir
|
||||||
|
|||||||
Reference in New Issue
Block a user