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) } }