189 lines
7.0 KiB
Ruby
189 lines
7.0 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "test_helper"
|
|
require "fileutils"
|
|
require "tmpdir"
|
|
|
|
# Smoke test for Opencode::UploadedFilesPrompt. The sandbox_path:
|
|
# inversion (per D14 in the design doc) means we can exercise the
|
|
# happy path against a tmpdir without needing Rails / AR / ActiveStorage
|
|
# fixtures. Behavioral tests covering ActiveStorage attached_blob
|
|
# enumeration live in the host application.
|
|
class Opencode::UploadedFilesPromptTest < Minitest::Test
|
|
# Minimal stub for a user message: #content (the raw user text) and
|
|
# #files (an ActiveStorage-like collection with #attached?). Real
|
|
# behavior is exercised in the host's test suite.
|
|
FakeMessage = Struct.new(:content, :files, keyword_init: true)
|
|
EmptyFiles = Struct.new(:attached) do
|
|
def attached? = attached
|
|
end
|
|
AttachedFiles = Class.new(Array) do
|
|
def attached? = true
|
|
end
|
|
FakeUpload = Struct.new(:filename, :content_type, :content, keyword_init: true) do
|
|
def byte_size = content.bytesize
|
|
def download = content
|
|
end
|
|
|
|
def setup
|
|
@tmpdir = Dir.mktmpdir("opencode-rails-uploaded-prompt-test-")
|
|
end
|
|
|
|
def teardown
|
|
FileUtils.remove_entry(@tmpdir) if @tmpdir && File.exist?(@tmpdir)
|
|
end
|
|
|
|
def test_initialize_takes_three_required_keywords
|
|
params = Opencode::UploadedFilesPrompt.instance_method(:initialize).parameters
|
|
required = params.select { |kind, _| kind == :keyreq }.map(&:last).sort
|
|
|
|
assert_equal %i[sandbox_name_for sandbox_path user_message], required,
|
|
"UploadedFilesPrompt requires user_message:, sandbox_path:, sandbox_name_for:"
|
|
end
|
|
|
|
def test_text_returns_raw_content_when_no_files_attached
|
|
message = FakeMessage.new(content: "hello world", files: EmptyFiles.new(false))
|
|
prompt = Opencode::UploadedFilesPrompt.new(
|
|
user_message: message,
|
|
sandbox_path: @tmpdir,
|
|
sandbox_name_for: ->(file) { file.filename.to_s }
|
|
)
|
|
|
|
assert_equal "hello world", prompt.text,
|
|
"No attached files => text is the raw user content"
|
|
assert_equal({}, prompt.sandbox_file_names,
|
|
"No attached files => sandbox_file_names map stays empty")
|
|
end
|
|
|
|
def test_public_surface
|
|
assert_equal %i[sandbox_file_names text].sort,
|
|
Opencode::UploadedFilesPrompt.instance_methods(false).sort
|
|
end
|
|
|
|
def test_copies_an_upload_without_domain_specific_instructions
|
|
upload = FakeUpload.new(filename: "notes.txt", content_type: "text/plain", content: "hello")
|
|
message = FakeMessage.new(content: "summarize", files: AttachedFiles.new([ upload ]))
|
|
prompt = Opencode::UploadedFilesPrompt.new(
|
|
user_message: message,
|
|
sandbox_path: @tmpdir,
|
|
sandbox_name_for: ->(_file) { "upload.txt" }
|
|
)
|
|
|
|
assert_equal "hello", File.binread(File.join(@tmpdir, "upload.txt"))
|
|
assert_includes prompt.text, "Read each file thoroughly before responding:"
|
|
refute_includes prompt.text, "legal claims"
|
|
refute_includes prompt.text, "reference materials"
|
|
end
|
|
|
|
def test_rejects_a_sibling_prefix_escape
|
|
sandbox = File.join(@tmpdir, "foo")
|
|
sibling = File.join(@tmpdir, "foobar")
|
|
FileUtils.mkdir_p(sibling)
|
|
target = File.join(sibling, "target.txt")
|
|
File.write(target, "safe")
|
|
upload = FakeUpload.new(filename: "notes.txt", content_type: "text/plain", content: "overwritten")
|
|
message = FakeMessage.new(content: "summarize", files: AttachedFiles.new([ upload ]))
|
|
|
|
assert_raises(ArgumentError) do
|
|
Opencode::UploadedFilesPrompt.new(
|
|
user_message: message,
|
|
sandbox_path: sandbox,
|
|
sandbox_name_for: ->(_file) { "../foobar/target.txt" }
|
|
)
|
|
end
|
|
assert_equal "safe", File.read(target)
|
|
end
|
|
|
|
def test_atomically_replaces_a_destination_symlink_without_following_it
|
|
outside = File.join(@tmpdir, "outside.txt")
|
|
sandbox = File.join(@tmpdir, "sandbox")
|
|
FileUtils.mkdir_p(sandbox)
|
|
File.write(outside, "safe")
|
|
File.symlink(outside, File.join(sandbox, "upload.txt"))
|
|
upload = FakeUpload.new(filename: "notes.txt", content_type: "text/plain", content: "overwritten")
|
|
message = FakeMessage.new(content: "summarize", files: AttachedFiles.new([ upload ]))
|
|
|
|
Opencode::UploadedFilesPrompt.new(
|
|
user_message: message,
|
|
sandbox_path: sandbox,
|
|
sandbox_name_for: ->(_file) { "upload.txt" }
|
|
)
|
|
|
|
assert_equal "safe", File.read(outside)
|
|
assert_equal "overwritten", File.read(File.join(sandbox, "upload.txt"))
|
|
refute File.symlink?(File.join(sandbox, "upload.txt"))
|
|
end
|
|
|
|
def test_retried_copy_refreshes_an_existing_sandbox_file
|
|
sandbox = File.join(@tmpdir, "sandbox")
|
|
FileUtils.mkdir_p(sandbox)
|
|
File.write(File.join(sandbox, "upload.txt"), "stale")
|
|
upload = FakeUpload.new(filename: "notes.txt", content_type: "text/plain", content: "fresh")
|
|
message = FakeMessage.new(content: "summarize", files: AttachedFiles.new([ upload ]))
|
|
|
|
Opencode::UploadedFilesPrompt.new(
|
|
user_message: message,
|
|
sandbox_path: sandbox,
|
|
sandbox_name_for: ->(_file) { "upload.txt" }
|
|
)
|
|
|
|
assert_equal "fresh", File.read(File.join(sandbox, "upload.txt"))
|
|
end
|
|
|
|
def test_rejects_a_sandbox_root_replaced_during_the_copy
|
|
sandbox = File.join(@tmpdir, "sandbox")
|
|
moved_sandbox = File.join(@tmpdir, "moved-sandbox")
|
|
FileUtils.mkdir_p(sandbox)
|
|
upload = FakeUpload.new(filename: "notes.txt", content_type: "text/plain", content: "private")
|
|
message = FakeMessage.new(content: "summarize", files: AttachedFiles.new([ upload ]))
|
|
create = Tempfile.method(:create)
|
|
replace_root = lambda do |*args, **kwargs, &block|
|
|
File.rename(sandbox, moved_sandbox)
|
|
FileUtils.mkdir_p(sandbox)
|
|
create.call(*args, **kwargs, &block)
|
|
end
|
|
|
|
Tempfile.stub(:create, replace_root) do
|
|
assert_raises(ArgumentError) do
|
|
Opencode::UploadedFilesPrompt.new(
|
|
user_message: message,
|
|
sandbox_path: sandbox,
|
|
sandbox_name_for: ->(_file) { "upload.txt" }
|
|
)
|
|
end
|
|
end
|
|
|
|
refute File.exist?(File.join(sandbox, "upload.txt"))
|
|
refute File.exist?(File.join(moved_sandbox, "upload.txt"))
|
|
end
|
|
|
|
def test_new_upload_uses_the_normal_umask_file_mode
|
|
upload = FakeUpload.new(filename: "notes.txt", content_type: "text/plain", content: "hello")
|
|
message = FakeMessage.new(content: "summarize", files: AttachedFiles.new([ upload ]))
|
|
|
|
Opencode::UploadedFilesPrompt.new(
|
|
user_message: message,
|
|
sandbox_path: @tmpdir,
|
|
sandbox_name_for: ->(_file) { "upload.txt" }
|
|
)
|
|
|
|
assert_equal 0o666 & ~File.umask, File.stat(File.join(@tmpdir, "upload.txt")).mode & 0o777
|
|
end
|
|
|
|
def test_retried_copy_preserves_an_existing_file_mode
|
|
existing = File.join(@tmpdir, "upload.txt")
|
|
File.write(existing, "stale")
|
|
File.chmod(0o640, existing)
|
|
upload = FakeUpload.new(filename: "notes.txt", content_type: "text/plain", content: "fresh")
|
|
message = FakeMessage.new(content: "summarize", files: AttachedFiles.new([ upload ]))
|
|
|
|
Opencode::UploadedFilesPrompt.new(
|
|
user_message: message,
|
|
sandbox_path: @tmpdir,
|
|
sandbox_name_for: ->(_file) { "upload.txt" }
|
|
)
|
|
|
|
assert_equal 0o640, File.stat(existing).mode & 0o777
|
|
end
|
|
end
|