Files
2026-09-16 10:27:58 -03:00

312 lines
8.8 KiB
Go

package syncer
import (
"context"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
)
func TestSyncDownloadsObjects(t *testing.T) {
modified := time.Unix(100, 0)
client := newFakeS3(map[string]fakeObject{
"config/app.yml": {body: "port: 8080\n", modified: modified},
})
dir := t.TempDir()
result, err := New(client, Config{Bucket: "bucket", Prefix: "config", Dir: dir}).Sync(context.Background())
if err != nil {
t.Fatalf("Sync() error = %v", err)
}
if result != (Result{Downloaded: 1}) {
t.Fatalf("result = %+v, want one download", result)
}
content, err := os.ReadFile(filepath.Join(dir, "app.yml"))
if err != nil {
t.Fatalf("reading downloaded file: %v", err)
}
if string(content) != "port: 8080\n" {
t.Fatalf("downloaded content = %q", content)
}
info, err := os.Stat(filepath.Join(dir, "app.yml"))
if err != nil {
t.Fatalf("stat downloaded file: %v", err)
}
if info.ModTime().Unix() != modified.Unix() {
t.Fatalf("mod time = %s, want %s", info.ModTime(), modified)
}
}
func TestSyncSkipsUnchangedObjects(t *testing.T) {
modified := time.Unix(200, 0)
client := newFakeS3(map[string]fakeObject{
"app.yml": {body: "same", modified: modified},
})
dir := t.TempDir()
service := New(client, Config{Bucket: "bucket", Dir: dir})
if _, err := service.Sync(context.Background()); err != nil {
t.Fatalf("first Sync() error = %v", err)
}
client.getCalls = 0
client.putCalls = 0
result, err := service.Sync(context.Background())
if err != nil {
t.Fatalf("second Sync() error = %v", err)
}
if result != (Result{Unchanged: 1}) {
t.Fatalf("result = %+v, want one unchanged", result)
}
if client.getCalls != 0 {
t.Fatalf("GetObject calls = %d, want 0", client.getCalls)
}
if client.putCalls != 0 {
t.Fatalf("PutObject calls = %d, want 0", client.putCalls)
}
}
func TestSyncDownloadsRemoteNewerObject(t *testing.T) {
firstModified := time.Unix(300, 0)
secondModified := time.Unix(301, 0)
client := newFakeS3(map[string]fakeObject{
"app.yml": {body: "old", modified: firstModified},
})
dir := t.TempDir()
service := New(client, Config{Bucket: "bucket", Dir: dir})
if _, err := service.Sync(context.Background()); err != nil {
t.Fatalf("first Sync() error = %v", err)
}
client.objects["app.yml"] = fakeObject{body: "new", modified: secondModified}
result, err := service.Sync(context.Background())
if err != nil {
t.Fatalf("second Sync() error = %v", err)
}
if result != (Result{Updated: 1}) {
t.Fatalf("result = %+v, want one update", result)
}
content, err := os.ReadFile(filepath.Join(dir, "app.yml"))
if err != nil {
t.Fatalf("reading updated file: %v", err)
}
if string(content) != "new" {
t.Fatalf("updated content = %q, want new", content)
}
}
func TestSyncUploadsLocalOnlyFile(t *testing.T) {
client := newFakeS3(map[string]fakeObject{})
dir := t.TempDir()
localPath := filepath.Join(dir, "local.txt")
writeFileAt(t, localPath, "local content", time.Unix(600, 0))
result, err := New(client, Config{Bucket: "bucket", Prefix: "config", Dir: dir}).Sync(context.Background())
if err != nil {
t.Fatalf("Sync() error = %v", err)
}
if result != (Result{Uploaded: 1}) {
t.Fatalf("result = %+v, want one upload", result)
}
if got := client.objects["config/local.txt"].body; got != "local content" {
t.Fatalf("uploaded body = %q, want local content", got)
}
}
func TestSyncUploadsLocalNewerObject(t *testing.T) {
client := newFakeS3(map[string]fakeObject{
"app.yml": {body: "remote", modified: time.Unix(700, 0)},
})
dir := t.TempDir()
service := New(client, Config{Bucket: "bucket", Dir: dir})
if _, err := service.Sync(context.Background()); err != nil {
t.Fatalf("first Sync() error = %v", err)
}
localPath := filepath.Join(dir, "app.yml")
writeFileAt(t, localPath, "local", time.Unix(800, 0))
result, err := service.Sync(context.Background())
if err != nil {
t.Fatalf("second Sync() error = %v", err)
}
if result != (Result{Updated: 1}) {
t.Fatalf("result = %+v, want one update", result)
}
if got := client.objects["app.yml"].body; got != "local" {
t.Fatalf("uploaded body = %q, want local", got)
}
}
func TestSyncPruneDeletesUnchangedLocalAfterRemoteDelete(t *testing.T) {
client := newFakeS3(map[string]fakeObject{
"stale.txt": {body: "remote", modified: time.Unix(400, 0)},
})
dir := t.TempDir()
service := New(client, Config{Bucket: "bucket", Dir: dir, Prune: true})
if _, err := service.Sync(context.Background()); err != nil {
t.Fatalf("first Sync() error = %v", err)
}
delete(client.objects, "stale.txt")
result, err := service.Sync(context.Background())
if err != nil {
t.Fatalf("second Sync() error = %v", err)
}
if result != (Result{Deleted: 1}) {
t.Fatalf("result = %+v, want one delete", result)
}
if _, err := os.Stat(filepath.Join(dir, "stale.txt")); !os.IsNotExist(err) {
t.Fatalf("stale file still exists or unexpected error: %v", err)
}
}
func TestSyncPruneDeletesUnchangedRemoteAfterLocalDelete(t *testing.T) {
client := newFakeS3(map[string]fakeObject{
"stale.txt": {body: "remote", modified: time.Unix(450, 0)},
})
dir := t.TempDir()
service := New(client, Config{Bucket: "bucket", Dir: dir, Prune: true})
if _, err := service.Sync(context.Background()); err != nil {
t.Fatalf("first Sync() error = %v", err)
}
if err := os.Remove(filepath.Join(dir, "stale.txt")); err != nil {
t.Fatalf("removing local file: %v", err)
}
result, err := service.Sync(context.Background())
if err != nil {
t.Fatalf("second Sync() error = %v", err)
}
if result != (Result{Deleted: 1}) {
t.Fatalf("result = %+v, want one delete", result)
}
if _, ok := client.objects["stale.txt"]; ok {
t.Fatal("remote object still exists")
}
}
func TestSyncRejectsUnsafeKeys(t *testing.T) {
client := newFakeS3(map[string]fakeObject{
"../secret.txt": {body: "secret", modified: time.Unix(500, 0)},
})
dir := t.TempDir()
_, err := New(client, Config{Bucket: "bucket", Dir: dir}).Sync(context.Background())
if err == nil {
t.Fatal("Sync() error = nil, want unsafe key error")
}
if !strings.Contains(err.Error(), "unsafe") {
t.Fatalf("Sync() error = %v, want unsafe key error", err)
}
if _, err := os.Stat(filepath.Join(filepath.Dir(dir), "secret.txt")); !os.IsNotExist(err) {
t.Fatalf("unsafe file was written or unexpected error: %v", err)
}
}
type fakeObject struct {
body string
modified time.Time
}
type fakeS3 struct {
objects map[string]fakeObject
getCalls int
putCalls int
delCalls int
now time.Time
}
func newFakeS3(objects map[string]fakeObject) *fakeS3 {
return &fakeS3{
objects: objects,
now: time.Unix(900, 0),
}
}
func (f *fakeS3) ListObjectsV2(_ context.Context, input *s3.ListObjectsV2Input, _ ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) {
var contents []types.Object
prefix := aws.ToString(input.Prefix)
for key, object := range f.objects {
if !strings.HasPrefix(key, prefix) {
continue
}
size := int64(len(object.body))
modified := object.modified
contents = append(contents, types.Object{
Key: aws.String(key),
LastModified: &modified,
Size: &size,
})
}
truncated := false
return &s3.ListObjectsV2Output{
Contents: contents,
IsTruncated: &truncated,
}, nil
}
func (f *fakeS3) GetObject(_ context.Context, input *s3.GetObjectInput, _ ...func(*s3.Options)) (*s3.GetObjectOutput, error) {
f.getCalls++
object := f.objects[aws.ToString(input.Key)]
return &s3.GetObjectOutput{
Body: io.NopCloser(strings.NewReader(object.body)),
}, nil
}
func (f *fakeS3) PutObject(_ context.Context, input *s3.PutObjectInput, _ ...func(*s3.Options)) (*s3.PutObjectOutput, error) {
f.putCalls++
body, err := io.ReadAll(input.Body)
if err != nil {
return nil, err
}
f.objects[aws.ToString(input.Key)] = fakeObject{
body: string(body),
modified: f.now,
}
return &s3.PutObjectOutput{}, nil
}
func (f *fakeS3) HeadObject(_ context.Context, input *s3.HeadObjectInput, _ ...func(*s3.Options)) (*s3.HeadObjectOutput, error) {
object := f.objects[aws.ToString(input.Key)]
size := int64(len(object.body))
modified := object.modified
return &s3.HeadObjectOutput{
ContentLength: &size,
LastModified: &modified,
}, nil
}
func (f *fakeS3) DeleteObject(_ context.Context, input *s3.DeleteObjectInput, _ ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) {
f.delCalls++
delete(f.objects, aws.ToString(input.Key))
return &s3.DeleteObjectOutput{}, nil
}
func writeFileAt(t *testing.T, path string, content string, modified time.Time) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("creating parent dir: %v", err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("writing file: %v", err)
}
if err := os.Chtimes(path, modified, modified); err != nil {
t.Fatalf("setting mtime: %v", err)
}
}