All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Has been skipped
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m45s
CI/CD / test-sqlite (push) Successful in 2m52s
CI/CD / build-and-deploy (push) Successful in 1m20s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
411 lines
10 KiB
Go
411 lines
10 KiB
Go
package sandbox
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"testing"
|
|
|
|
"go.starlark.net/starlark"
|
|
|
|
"armature/storage"
|
|
)
|
|
|
|
// memStore is an in-memory ObjectStore for testing.
|
|
type memStore struct {
|
|
objects map[string]memObject
|
|
}
|
|
|
|
type memObject struct {
|
|
data []byte
|
|
contentType string
|
|
}
|
|
|
|
func newMemStore() *memStore {
|
|
return &memStore{objects: make(map[string]memObject)}
|
|
}
|
|
|
|
func (m *memStore) Put(_ context.Context, key string, r io.Reader, size int64, contentType string) error {
|
|
data, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m.objects[key] = memObject{data: data, contentType: contentType}
|
|
return nil
|
|
}
|
|
|
|
func (m *memStore) Get(_ context.Context, key string) (io.ReadCloser, int64, string, error) {
|
|
obj, ok := m.objects[key]
|
|
if !ok {
|
|
return nil, 0, "", storage.ErrNotFound
|
|
}
|
|
return io.NopCloser(&memReader{data: obj.data}), int64(len(obj.data)), obj.contentType, nil
|
|
}
|
|
|
|
func (m *memStore) Delete(_ context.Context, key string) error {
|
|
delete(m.objects, key)
|
|
return nil
|
|
}
|
|
|
|
func (m *memStore) DeletePrefix(_ context.Context, prefix string) error {
|
|
for k := range m.objects {
|
|
if strings.HasPrefix(k, prefix) {
|
|
delete(m.objects, k)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *memStore) Exists(_ context.Context, key string) (bool, error) {
|
|
_, ok := m.objects[key]
|
|
return ok, nil
|
|
}
|
|
|
|
func (m *memStore) Healthy(_ context.Context) error { return nil }
|
|
|
|
func (m *memStore) Stats(_ context.Context) (*storage.StorageStats, error) {
|
|
return &storage.StorageStats{Backend: "mem", Configured: true, Healthy: true}, nil
|
|
}
|
|
|
|
func (m *memStore) Backend() string { return "mem" }
|
|
|
|
func (m *memStore) List(_ context.Context, prefix string, limit int) ([]storage.ObjectEntry, error) {
|
|
if limit <= 0 {
|
|
limit = 100
|
|
}
|
|
var entries []storage.ObjectEntry
|
|
for k, obj := range m.objects {
|
|
if strings.HasPrefix(k, prefix) {
|
|
entries = append(entries, storage.ObjectEntry{
|
|
Key: k,
|
|
Size: int64(len(obj.data)),
|
|
ContentType: obj.contentType,
|
|
})
|
|
if len(entries) >= limit {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
// memReader wraps a byte slice as io.ReadCloser.
|
|
type memReader struct {
|
|
data []byte
|
|
pos int
|
|
}
|
|
|
|
func (r *memReader) Read(p []byte) (int, error) {
|
|
if r.pos >= len(r.data) {
|
|
return 0, io.EOF
|
|
}
|
|
n := copy(p, r.data[r.pos:])
|
|
r.pos += n
|
|
if r.pos >= len(r.data) {
|
|
return n, io.EOF
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func (r *memReader) Close() error { return nil }
|
|
|
|
// ── Test helpers ──────────────────────────────
|
|
|
|
func execWithFiles(t *testing.T, canWrite bool, script string) (*Result, error) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
store := newMemStore()
|
|
mod := BuildFilesModule(ctx, FilesModuleConfig{
|
|
PackageID: "test-pkg",
|
|
CanWrite: canWrite,
|
|
Store: store,
|
|
})
|
|
sb := New(DefaultConfig())
|
|
return sb.Exec(ctx, "test.star", script, map[string]starlark.Value{
|
|
"files": mod,
|
|
})
|
|
}
|
|
|
|
func execWithFilesStore(t *testing.T, store *memStore, canWrite bool, script string) (*Result, error) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
mod := BuildFilesModule(ctx, FilesModuleConfig{
|
|
PackageID: "test-pkg",
|
|
CanWrite: canWrite,
|
|
Store: store,
|
|
})
|
|
sb := New(DefaultConfig())
|
|
return sb.Exec(ctx, "test.star", script, map[string]starlark.Value{
|
|
"files": mod,
|
|
})
|
|
}
|
|
|
|
// ── Put + Get round-trip ──────────────────────
|
|
|
|
func TestFilesPut_StringContent(t *testing.T) {
|
|
_, err := execWithFiles(t, true, `
|
|
files.put("hello.txt", "hello world", content_type="text/plain")
|
|
result = files.get("hello.txt")
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("exec error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFilesPut_BytesContent(t *testing.T) {
|
|
result, err := execWithFiles(t, true, `
|
|
files.put("bin.dat", b"\x00\x01\x02\xff")
|
|
got = files.get("bin.dat")
|
|
size = got["size"]
|
|
ct = got["content_type"]
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("exec error: %v", err)
|
|
}
|
|
|
|
size, _ := starlark.AsInt32(result.Globals["size"])
|
|
if size != 4 {
|
|
t.Errorf("size = %d, want 4", size)
|
|
}
|
|
ct := string(result.Globals["ct"].(starlark.String))
|
|
if ct != "application/octet-stream" {
|
|
t.Errorf("content_type = %q, want application/octet-stream", ct)
|
|
}
|
|
}
|
|
|
|
func TestFilesPut_WithMetadata(t *testing.T) {
|
|
result, err := execWithFiles(t, true, `
|
|
files.put("doc.pdf", "pdf content", content_type="application/pdf", metadata={"author": "test", "pages": 5})
|
|
meta = files.meta("doc.pdf")
|
|
author = meta["author"]
|
|
pages = meta["pages"]
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("exec error: %v", err)
|
|
}
|
|
|
|
author := string(result.Globals["author"].(starlark.String))
|
|
if author != "test" {
|
|
t.Errorf("author = %q, want %q", author, "test")
|
|
}
|
|
pages, _ := starlark.AsInt32(result.Globals["pages"])
|
|
if pages != 5 {
|
|
t.Errorf("pages = %d, want 5", pages)
|
|
}
|
|
}
|
|
|
|
func TestFilesPut_SizeLimit(t *testing.T) {
|
|
// Set a small limit for testing.
|
|
t.Setenv("EXT_FILES_MAX_SIZE", "10")
|
|
_, err := execWithFiles(t, true, `
|
|
files.put("big.bin", "this content is longer than ten bytes")
|
|
`)
|
|
if err == nil {
|
|
t.Fatal("expected size limit error")
|
|
}
|
|
if !strings.Contains(err.Error(), "exceeds limit") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
// ── Get ───────────────────────────────────────
|
|
|
|
func TestFilesGet_NotFound(t *testing.T) {
|
|
result, err := execWithFiles(t, false, `
|
|
got = files.get("nonexistent.txt")
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("exec error: %v", err)
|
|
}
|
|
if result.Globals["got"] != starlark.None {
|
|
t.Errorf("expected None, got %v", result.Globals["got"])
|
|
}
|
|
}
|
|
|
|
func TestFilesGet_ReturnsAllFields(t *testing.T) {
|
|
result, err := execWithFiles(t, true, `
|
|
files.put("test.txt", "data", content_type="text/plain", metadata={"k": "v"})
|
|
got = files.get("test.txt")
|
|
has_content = "content" in got
|
|
has_ct = "content_type" in got
|
|
has_size = "size" in got
|
|
has_meta = "metadata" in got
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("exec error: %v", err)
|
|
}
|
|
for _, key := range []string{"has_content", "has_ct", "has_size", "has_meta"} {
|
|
if result.Globals[key] != starlark.True {
|
|
t.Errorf("%s = False, want True", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Meta ──────────────────────────────────────
|
|
|
|
func TestFilesMeta_NotFound(t *testing.T) {
|
|
result, err := execWithFiles(t, false, `
|
|
got = files.meta("nonexistent.txt")
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("exec error: %v", err)
|
|
}
|
|
if result.Globals["got"] != starlark.None {
|
|
t.Errorf("expected None, got %v", result.Globals["got"])
|
|
}
|
|
}
|
|
|
|
func TestFilesMeta_NoCompanion(t *testing.T) {
|
|
result, err := execWithFiles(t, true, `
|
|
files.put("plain.txt", "data")
|
|
meta = files.meta("plain.txt")
|
|
count = len(meta)
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("exec error: %v", err)
|
|
}
|
|
count, _ := starlark.AsInt32(result.Globals["count"])
|
|
if count != 0 {
|
|
t.Errorf("meta length = %d, want 0", count)
|
|
}
|
|
}
|
|
|
|
// ── List ──────────────────────────────────────
|
|
|
|
func TestFilesList_PrefixFilter(t *testing.T) {
|
|
result, err := execWithFiles(t, true, `
|
|
files.put("images/a.png", "a")
|
|
files.put("images/b.png", "b")
|
|
files.put("docs/c.pdf", "c")
|
|
all_entries = files.list()
|
|
img_entries = files.list(prefix="images/")
|
|
all_count = len(all_entries)
|
|
img_count = len(img_entries)
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("exec error: %v", err)
|
|
}
|
|
|
|
allCount, _ := starlark.AsInt32(result.Globals["all_count"])
|
|
imgCount, _ := starlark.AsInt32(result.Globals["img_count"])
|
|
|
|
if allCount != 3 {
|
|
t.Errorf("all_count = %d, want 3", allCount)
|
|
}
|
|
if imgCount != 2 {
|
|
t.Errorf("img_count = %d, want 2", imgCount)
|
|
}
|
|
}
|
|
|
|
func TestFilesList_ExcludesMeta(t *testing.T) {
|
|
result, err := execWithFiles(t, true, `
|
|
files.put("file.txt", "data", metadata={"key": "val"})
|
|
entries = files.list()
|
|
count = len(entries)
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("exec error: %v", err)
|
|
}
|
|
|
|
count, _ := starlark.AsInt32(result.Globals["count"])
|
|
if count != 1 {
|
|
t.Errorf("count = %d, want 1 (should exclude _meta/ companion)", count)
|
|
}
|
|
}
|
|
|
|
// ── Delete ────────────────────────────────────
|
|
|
|
func TestFilesDelete_Idempotent(t *testing.T) {
|
|
_, err := execWithFiles(t, true, `
|
|
files.put("temp.txt", "temp")
|
|
files.delete("temp.txt")
|
|
files.delete("temp.txt")
|
|
exists = files.exists("temp.txt")
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("exec error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFilesDeletePrefix(t *testing.T) {
|
|
result, err := execWithFiles(t, true, `
|
|
files.put("uploads/a.txt", "a")
|
|
files.put("uploads/b.txt", "b")
|
|
files.put("keep.txt", "keep")
|
|
files.delete_prefix("uploads/")
|
|
a_exists = files.exists("uploads/a.txt")
|
|
b_exists = files.exists("uploads/b.txt")
|
|
keep_exists = files.exists("keep.txt")
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("exec error: %v", err)
|
|
}
|
|
|
|
if result.Globals["a_exists"] != starlark.False {
|
|
t.Error("uploads/a.txt should be deleted")
|
|
}
|
|
if result.Globals["b_exists"] != starlark.False {
|
|
t.Error("uploads/b.txt should be deleted")
|
|
}
|
|
if result.Globals["keep_exists"] != starlark.True {
|
|
t.Error("keep.txt should be preserved")
|
|
}
|
|
}
|
|
|
|
// ── Exists ────────────────────────────────────
|
|
|
|
func TestFilesExists(t *testing.T) {
|
|
result, err := execWithFiles(t, true, `
|
|
files.put("exists.txt", "yes")
|
|
yes = files.exists("exists.txt")
|
|
no = files.exists("nope.txt")
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("exec error: %v", err)
|
|
}
|
|
|
|
if result.Globals["yes"] != starlark.True {
|
|
t.Error("exists should be True")
|
|
}
|
|
if result.Globals["no"] != starlark.False {
|
|
t.Error("nope should be False")
|
|
}
|
|
}
|
|
|
|
// ── Name Validation ──────────────────────────
|
|
|
|
func TestFilesNameValidation(t *testing.T) {
|
|
badNames := []string{
|
|
"../escape",
|
|
"/absolute",
|
|
"_meta/reserved",
|
|
"",
|
|
}
|
|
|
|
for _, name := range badNames {
|
|
t.Run(name, func(t *testing.T) {
|
|
script := fmt.Sprintf(`files.exists(%q)`, name)
|
|
_, err := execWithFiles(t, false, script)
|
|
if err == nil {
|
|
t.Errorf("expected error for name %q", name)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── Write Permission Gate ────────────────────
|
|
|
|
func TestFilesWritePermission(t *testing.T) {
|
|
// Read-only mode: put/delete/delete_prefix should not be available.
|
|
_, err := execWithFiles(t, false, `
|
|
files.put("test.txt", "data")
|
|
`)
|
|
if err == nil {
|
|
t.Fatal("expected error: put should not be available in read-only mode")
|
|
}
|
|
if !strings.Contains(err.Error(), "no .put field") && !strings.Contains(err.Error(), "has no .put") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|