gogcli/internal/cmd/backup_test.go
Peter Steinberger f26af3adba
feat(safety): add baked safety profiles (#536)
* feat(safety): add baked safety profiles

Co-authored-by: Drew Burchfield <1084679+drewburchfield@users.noreply.github.com>

* fix(safety): narrow readonly profile parent allows

* fix(safety): verify basename safe-build outputs

* fix(backup): promote Gmail checkpoints into final manifest

* docs(safety): explain baked safety profiles

* feat(safety): filter profiled help and schema

* fix(safety): avoid help filter shadow warnings

* fix(backup): make plaintext export resilient

* docs(changelog): mention safety help filtering

* fix(backup): satisfy export lint checks

---------

Co-authored-by: Drew Burchfield <1084679+drewburchfield@users.noreply.github.com>
2026-04-29 03:35:18 +01:00

904 lines
31 KiB
Go

package cmd
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"google.golang.org/api/drive/v3"
"google.golang.org/api/option"
"github.com/steipete/gogcli/internal/backup"
"github.com/steipete/gogcli/internal/ui"
)
func TestBackupAccountHashStableAndOpaque(t *testing.T) {
got := backupAccountHash(" User@Example.COM ")
want := backupAccountHash("user@example.com")
if got != want {
t.Fatalf("hash not normalized: got %s want %s", got, want)
}
if len(got) != 24 {
t.Fatalf("hash length = %d, want 24 hex chars", len(got))
}
if strings.Contains(got, "user") || strings.Contains(got, "example") {
t.Fatalf("hash leaks account text: %s", got)
}
}
func TestBackupReadFlagsOptionsSkipPull(t *testing.T) {
opts := backupReadFlags{NoPull: true}.options()
if !opts.SkipPull {
t.Fatal("SkipPull = false, want true")
}
if opts.Push {
t.Fatal("Push = true, want false")
}
}
func TestBuildGmailMessageShardsBucketsSortsAndChunks(t *testing.T) {
accountHash := "accthash"
messages := []gmailBackupMessage{
{ID: "march-new", InternalDate: mustUnixMilli(t, "2026-03-02T10:00:00Z"), Raw: "raw-3"},
{ID: "april-later", InternalDate: mustUnixMilli(t, "2026-04-02T10:00:00Z"), Raw: "raw-2"},
{ID: "april-earlier-b", InternalDate: mustUnixMilli(t, "2026-04-01T10:00:00Z"), Raw: "raw-1b"},
{ID: "april-earlier-a", InternalDate: mustUnixMilli(t, "2026-04-01T10:00:00Z"), Raw: "raw-1a"},
}
shards, err := buildGmailMessageShards(accountHash, messages, 2)
if err != nil {
t.Fatalf("buildGmailMessageShards: %v", err)
}
if len(shards) != 3 {
t.Fatalf("len(shards) = %d, want 3", len(shards))
}
wantPaths := []string{
"data/gmail/accthash/messages/2026/03/part-0001.jsonl.gz.age",
"data/gmail/accthash/messages/2026/04/part-0001.jsonl.gz.age",
"data/gmail/accthash/messages/2026/04/part-0002.jsonl.gz.age",
}
for i, want := range wantPaths {
if shards[i].Path != want {
t.Fatalf("shards[%d].Path = %q, want %q", i, shards[i].Path, want)
}
}
if shards[0].Rows != 1 || shards[1].Rows != 2 || shards[2].Rows != 1 {
t.Fatalf("unexpected row counts: %d %d %d", shards[0].Rows, shards[1].Rows, shards[2].Rows)
}
var aprilFirst []gmailBackupMessage
if err := backup.DecodeJSONL(shards[1].Plaintext, &aprilFirst); err != nil {
t.Fatalf("DecodeJSONL: %v", err)
}
gotIDs := []string{aprilFirst[0].ID, aprilFirst[1].ID}
wantIDs := []string{"april-earlier-a", "april-earlier-b"}
for i := range wantIDs {
if gotIDs[i] != wantIDs[i] {
t.Fatalf("april shard IDs = %v, want %v", gotIDs, wantIDs)
}
}
}
func TestBuildGmailMessageShardsSplitsByPlaintextSize(t *testing.T) {
accountHash := "accthash"
messages := []gmailBackupMessage{
{ID: "m1", InternalDate: mustUnixMilli(t, "2026-04-01T10:00:00Z"), Raw: strings.Repeat("raw-1", 8)},
{ID: "m2", InternalDate: mustUnixMilli(t, "2026-04-02T10:00:00Z"), Raw: strings.Repeat("raw-2", 8)},
{ID: "m3", InternalDate: mustUnixMilli(t, "2026-04-03T10:00:00Z"), Raw: strings.Repeat("raw-3", 8)},
}
shards, err := buildGmailMessageShardsWithLimit(accountHash, messages, 100, 1)
if err != nil {
t.Fatalf("buildGmailMessageShardsWithLimit: %v", err)
}
if len(shards) != 3 {
t.Fatalf("len(shards) = %d, want 3", len(shards))
}
for i, shard := range shards {
if shard.Rows != 1 {
t.Fatalf("shards[%d].Rows = %d, want 1", i, shard.Rows)
}
want := fmt.Sprintf("part-%04d.jsonl.gz.age", i+1)
if !strings.HasSuffix(shard.Path, want) {
t.Fatalf("shards[%d].Path = %q, want suffix %q", i, shard.Path, want)
}
}
}
func TestMergeBackupSnapshotsKeepsCountsAndShardOrder(t *testing.T) {
left := backup.Snapshot{
Services: []string{"gmail"},
Accounts: []string{"acct1"},
Counts: map[string]int{"gmail.messages": 2},
Shards: []backup.PlainShard{{Path: "data/gmail/acct1/messages/2026/04/part-0001.jsonl.gz.age"}},
}
right := backup.Snapshot{
Services: []string{"calendar"},
Accounts: []string{"acct1"},
Counts: map[string]int{"calendar.events": 3},
Shards: []backup.PlainShard{{Path: "data/calendar/acct1/events.jsonl.gz.age"}},
}
merged := mergeBackupSnapshots(left, right)
if merged.Counts["gmail.messages"] != 2 || merged.Counts["calendar.events"] != 3 {
t.Fatalf("unexpected counts: %+v", merged.Counts)
}
if len(merged.Shards) != 2 || merged.Shards[0].Path != left.Shards[0].Path || merged.Shards[1].Path != right.Shards[0].Path {
t.Fatalf("unexpected shard order: %+v", merged.Shards)
}
}
func TestExpandBackupServicesAllIncludesWorkspaceAdapters(t *testing.T) {
got := strings.Join(expandBackupServices([]string{"all"}), ",")
for _, want := range []string{
"appscript",
"calendar",
"chat",
"classroom",
"contacts",
"drive",
"gmail",
"gmail-settings",
"groups",
"admin",
"keep",
"tasks",
"workspace",
} {
if !strings.Contains(got, want) {
t.Fatalf("expanded all missing %q in %q", want, got)
}
}
}
func TestGmailBackupMessageCacheRoundTrips(t *testing.T) {
t.Setenv("HOME", t.TempDir())
message := gmailBackupMessage{
ID: "msg-one",
ThreadID: "thread-one",
InternalDate: mustUnixMilli(t, "2026-04-02T10:00:00Z"),
LabelIDs: []string{"INBOX"},
Raw: base64.RawURLEncoding.EncodeToString([]byte("Subject: Cached\r\n\r\nBody")),
}
if err := writeGmailBackupMessageCache("accthash", message); err != nil {
t.Fatalf("writeGmailBackupMessageCache: %v", err)
}
got, ok, err := readGmailBackupMessageCache("accthash", "msg-one")
if err != nil {
t.Fatalf("readGmailBackupMessageCache: %v", err)
}
if !ok {
t.Fatal("expected cache hit")
}
if got.ID != message.ID || got.ThreadID != message.ThreadID || got.Raw != message.Raw {
t.Fatalf("cache round trip mismatch: %#v", got)
}
path, ok := gmailBackupMessageCachePath("accthash", "msg-one")
if !ok {
t.Fatal("expected cache path")
}
if strings.Contains(path, "msg-one") {
t.Fatalf("cache path should hash message IDs, got %q", path)
}
}
func TestGmailBackupMessageCacheRejectsWrongID(t *testing.T) {
t.Setenv("HOME", t.TempDir())
message := gmailBackupMessage{ID: "msg-one", Raw: "raw"}
if err := writeGmailBackupMessageCache("accthash", message); err != nil {
t.Fatalf("writeGmailBackupMessageCache: %v", err)
}
path, ok := gmailBackupMessageCachePath("accthash", "msg-one")
if !ok {
t.Fatal("expected cache path")
}
data, err := json.Marshal(gmailBackupMessage{ID: "other", Raw: "raw"})
if err != nil {
t.Fatalf("Marshal: %v", err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if _, _, err := readGmailBackupMessageCache("accthash", "msg-one"); err == nil {
t.Fatal("expected wrong cache ID to fail")
}
}
func TestListGmailBackupMessageIDsResumesFromCheckpoint(t *testing.T) {
t.Setenv("HOME", t.TempDir())
opts := gmailBackupOptions{
AccountHash: "accthash",
IncludeSpamTrash: true,
CacheMessages: true,
}
path, ok := gmailBackupListStatePath(opts)
if !ok {
t.Fatal("expected list state path")
}
if err := writeGmailBackupListState(path, opts, []string{"m1"}, "p2", false); err != nil {
t.Fatalf("writeGmailBackupListState: %v", err)
}
requests := 0
svc, cleanup := newGmailServiceForTest(t, func(w http.ResponseWriter, r *http.Request) {
requests++
if got := r.URL.Query().Get("pageToken"); got != "p2" {
t.Fatalf("pageToken = %q, want p2", got)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"messages": []map[string]string{{"id": "m2"}},
})
})
defer cleanup()
var stderr bytes.Buffer
u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: &stderr, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ids, err := listGmailBackupMessageIDs(ui.WithUI(context.Background(), u), svc, opts)
if err != nil {
t.Fatalf("listGmailBackupMessageIDs: %v", err)
}
if strings.Join(ids, ",") != "m1,m2" {
t.Fatalf("ids = %v, want [m1 m2]", ids)
}
if requests != 1 {
t.Fatalf("requests = %d, want 1", requests)
}
if !strings.Contains(stderr.String(), "resume=partial") || !strings.Contains(stderr.String(), "messages=2") {
t.Fatalf("stderr missing progress: %s", stderr.String())
}
state, ok, err := readGmailBackupListState(path)
if err != nil {
t.Fatalf("readGmailBackupListState: %v", err)
}
if !ok || !state.Complete || strings.Join(state.IDs, ",") != "m1,m2" {
t.Fatalf("state = %#v ok=%t", state, ok)
}
}
func TestListGmailBackupMessageIDsReusesCompleteCheckpoint(t *testing.T) {
t.Setenv("HOME", t.TempDir())
opts := gmailBackupOptions{
AccountHash: "accthash",
IncludeSpamTrash: true,
CacheMessages: true,
}
path, ok := gmailBackupListStatePath(opts)
if !ok {
t.Fatal("expected list state path")
}
if err := writeGmailBackupListState(path, opts, []string{"m1", "m2"}, "", true); err != nil {
t.Fatalf("writeGmailBackupListState: %v", err)
}
requests := 0
svc, cleanup := newGmailServiceForTest(t, func(w http.ResponseWriter, r *http.Request) {
requests++
http.NotFound(w, r)
})
defer cleanup()
ids, err := listGmailBackupMessageIDs(context.Background(), svc, opts)
if err != nil {
t.Fatalf("listGmailBackupMessageIDs: %v", err)
}
if strings.Join(ids, ",") != "m1,m2" {
t.Fatalf("ids = %v, want [m1 m2]", ids)
}
if requests != 0 {
t.Fatalf("requests = %d, want 0", requests)
}
}
func TestListGmailBackupMessageIDsMarksMaxLimitedRunComplete(t *testing.T) {
t.Setenv("HOME", t.TempDir())
opts := gmailBackupOptions{
AccountHash: "accthash",
Max: 1,
IncludeSpamTrash: true,
CacheMessages: true,
}
svc, cleanup := newGmailServiceForTest(t, func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"messages": []map[string]string{{"id": "m1"}},
"nextPageToken": "p2",
})
})
defer cleanup()
ids, err := listGmailBackupMessageIDs(context.Background(), svc, opts)
if err != nil {
t.Fatalf("listGmailBackupMessageIDs: %v", err)
}
if strings.Join(ids, ",") != "m1" {
t.Fatalf("ids = %v, want [m1]", ids)
}
path, ok := gmailBackupListStatePath(opts)
if !ok {
t.Fatal("expected list state path")
}
state, ok, err := readGmailBackupListState(path)
if err != nil {
t.Fatalf("readGmailBackupListState: %v", err)
}
if !ok || !state.Complete || state.PageToken != "" {
t.Fatalf("state = %#v ok=%t", state, ok)
}
}
func TestEnsureGmailBackupMessageCacheStopsOnFirstFetchError(t *testing.T) {
t.Setenv("HOME", t.TempDir())
var requests atomic.Int32
svc, cleanup := newGmailServiceForTest(t, func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
http.Error(w, `{"error":{"code":401,"message":"invalid credentials"}}`, http.StatusUnauthorized)
})
defer cleanup()
ids := make([]string, 100)
for i := range ids {
ids[i] = fmt.Sprintf("msg-%03d", i)
}
err := ensureGmailBackupMessageCache(context.Background(), svc, gmailBackupOptions{
AccountHash: "accthash",
CacheMessages: true,
IncludeSpamTrash: true,
}, ids)
if err == nil || !strings.Contains(err.Error(), "gmail message msg-") {
t.Fatalf("expected message fetch error, got %v", err)
}
if got := requests.Load(); got > 4 {
t.Fatalf("requests = %d, want fail-fast bounded requests", got)
}
}
func TestEnsureGmailBackupMessageCacheWritesEncryptedCheckpoints(t *testing.T) {
t.Setenv("HOME", t.TempDir())
dir := t.TempDir()
repo := filepath.Join(dir, "repo")
identity := filepath.Join(dir, "age.key")
config := filepath.Join(dir, "backup.json")
recipient, err := backup.EnsureIdentity(identity)
if err != nil {
t.Fatalf("EnsureIdentity: %v", err)
}
if saveErr := backup.SaveConfig(config, backup.Config{
Repo: repo,
Identity: identity,
Recipients: []string{recipient},
}); saveErr != nil {
t.Fatalf("SaveConfig: %v", saveErr)
}
svc, cleanup := newGmailServiceForTest(t, func(w http.ResponseWriter, r *http.Request) {
id := filepath.Base(r.URL.Path)
_ = json.NewEncoder(w).Encode(map[string]any{
"id": id,
"threadId": "thread-" + id,
"historyId": "100",
"internalDate": fmt.Sprint(mustUnixMilli(t, "2026-04-02T10:00:00Z")),
"labelIds": []string{"INBOX"},
"sizeEstimate": 42,
"raw": base64.RawURLEncoding.EncodeToString([]byte("Subject: " + id + "\r\n\r\nBody")),
})
})
defer cleanup()
ids := []string{"m1", "m2", "m3", "m4", "m5"}
err = ensureGmailBackupMessageCache(context.Background(), svc, gmailBackupOptions{
AccountHash: "accthash",
CacheMessages: true,
IncludeSpamTrash: true,
Checkpoints: true,
CheckpointRows: 2,
CheckpointRunID: "run-test",
BackupOptions: backup.Options{ConfigPath: config, Push: false},
}, ids)
if err != nil {
t.Fatalf("ensureGmailBackupMessageCache: %v", err)
}
manifestPath := filepath.Join(repo, "checkpoints", "gmail", "accthash", "run-test", "manifest.json")
data, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatalf("read checkpoint manifest: %v", err)
}
var manifest backup.CheckpointManifest
if unmarshalErr := json.Unmarshal(data, &manifest); unmarshalErr != nil {
t.Fatalf("unmarshal checkpoint manifest: %v", unmarshalErr)
}
if !manifest.Incomplete || manifest.Done != 5 || manifest.Total != 5 || len(manifest.Shards) != 3 {
t.Fatalf("unexpected checkpoint manifest: %+v", manifest)
}
ciphertext, err := os.ReadFile(filepath.Join(repo, filepath.FromSlash(manifest.Shards[0].Path)))
if err != nil {
t.Fatalf("read checkpoint shard: %v", err)
}
if strings.Contains(string(ciphertext), "Subject:") {
t.Fatal("checkpoint shard contains plaintext")
}
}
func TestBuildGmailMessageShardsFromCheckpointPromotesCompleteRun(t *testing.T) {
t.Setenv("HOME", t.TempDir())
ctx := context.Background()
repo, config, recipients := newBackupConfigForCmdTest(t)
checkpointShard, err := backup.NewJSONLShard(backupServiceGmail, "messages", "accthash", "checkpoints/gmail/accthash/run-test/messages/part-000001.jsonl.gz.age", []gmailBackupMessage{
{ID: "m1", Raw: "raw-1"},
{ID: "m2", Raw: "raw-2"},
})
if err != nil {
t.Fatalf("NewJSONLShard: %v", err)
}
if _, pushErr := backup.PushCheckpoint(ctx, backup.Snapshot{
Services: []string{backupServiceGmail},
Accounts: []string{"accthash"},
Counts: map[string]int{"gmail.messages": 2},
Shards: []backup.PlainShard{checkpointShard},
}, backup.Checkpoint{
RunID: "run-test",
Service: backupServiceGmail,
Account: "accthash",
Done: 2,
Total: 2,
}, backup.Options{ConfigPath: config, Push: false}); pushErr != nil {
t.Fatalf("PushCheckpoint: %v", pushErr)
}
shards, promoted, err := buildGmailMessageShardsFromCheckpoint(ctx, gmailBackupOptions{
AccountHash: "accthash",
CacheMessages: true,
Checkpoints: true,
CheckpointRunID: "run-test",
BackupOptions: backup.Options{ConfigPath: config},
}, []string{"m1", "m2"})
if err != nil {
t.Fatalf("buildGmailMessageShardsFromCheckpoint: %v", err)
}
if !promoted || len(shards) != 1 || shards[0].Existing == nil {
t.Fatalf("expected promoted existing shard, promoted=%t shards=%+v", promoted, shards)
}
if shards[0].Path != "checkpoints/gmail/accthash/run-test/messages/part-000001.jsonl.gz.age" {
t.Fatalf("promoted path = %q", shards[0].Path)
}
if !sameBackupRecipients(shards[0].ExistingRecipients, recipients) {
t.Fatalf("promoted recipients = %v, want %v", shards[0].ExistingRecipients, recipients)
}
if _, err := os.Stat(filepath.Join(repo, filepath.FromSlash(shards[0].Path))); err != nil {
t.Fatalf("checkpoint shard missing: %v", err)
}
}
func TestGmailBackupResolvedCheckpointRunIDReusesSelectionRun(t *testing.T) {
ctx := context.Background()
_, config, _ := newBackupConfigForCmdTest(t)
ids := []string{"m1"}
opts := gmailBackupOptions{
AccountHash: "accthash",
CacheMessages: true,
Checkpoints: true,
IncludeSpamTrash: true,
BackupOptions: backup.Options{ConfigPath: config},
}
runID := "20260428T010203Z-" + gmailBackupCheckpointRunIDSuffix(opts, ids)
checkpointShard, err := backup.NewJSONLShard(backupServiceGmail, "messages", "accthash", fmt.Sprintf("checkpoints/gmail/accthash/%s/messages/part-000001.jsonl.gz.age", runID), []gmailBackupMessage{
{ID: "m1", Raw: "raw-1"},
})
if err != nil {
t.Fatalf("NewJSONLShard: %v", err)
}
if _, err := backup.PushCheckpoint(ctx, backup.Snapshot{
Services: []string{backupServiceGmail},
Accounts: []string{"accthash"},
Counts: map[string]int{"gmail.messages": 1},
Shards: []backup.PlainShard{checkpointShard},
}, backup.Checkpoint{
RunID: runID,
Service: backupServiceGmail,
Account: "accthash",
Done: 1,
Total: 1,
}, backup.Options{ConfigPath: config, Push: false}); err != nil {
t.Fatalf("PushCheckpoint: %v", err)
}
if got := gmailBackupResolvedCheckpointRunID(ctx, opts, ids); got != runID {
t.Fatalf("resolved run ID = %q, want %q", got, runID)
}
}
func TestBuildGmailCheckpointShardFromCacheWritesPlaintextPath(t *testing.T) {
t.Setenv("HOME", t.TempDir())
accountHash := "accthash"
for _, message := range []gmailBackupMessage{
{ID: "m1", InternalDate: mustUnixMilli(t, "2026-04-01T10:00:00Z"), Raw: "raw-1"},
{ID: "m2", InternalDate: mustUnixMilli(t, "2026-04-02T10:00:00Z"), Raw: "raw-2"},
} {
if err := writeGmailBackupMessageCache(accountHash, message); err != nil {
t.Fatalf("writeGmailBackupMessageCache: %v", err)
}
}
shard, err := buildGmailCheckpointShardFromCache(accountHash, "run-test", 3, []string{"m1", "m2"})
if err != nil {
t.Fatalf("buildGmailCheckpointShardFromCache: %v", err)
}
if shard.Path != "checkpoints/gmail/accthash/run-test/messages/part-000003.jsonl.gz.age" {
t.Fatalf("checkpoint shard path = %q", shard.Path)
}
if shard.Rows != 2 || shard.PlaintextPath == "" {
t.Fatalf("unexpected shard: %+v", shard)
}
data, err := os.ReadFile(shard.PlaintextPath)
if err != nil {
t.Fatalf("read checkpoint plaintext: %v", err)
}
var rows []gmailBackupMessage
if err := backup.DecodeJSONL(data, &rows); err != nil {
t.Fatalf("DecodeJSONL: %v", err)
}
if len(rows) != 2 || rows[0].ID != "m1" || rows[1].ID != "m2" {
t.Fatalf("rows = %+v", rows)
}
}
func TestBuildGmailCheckpointShardsFromCacheSplitsLargeChunks(t *testing.T) {
t.Setenv("HOME", t.TempDir())
accountHash := "accthash"
ids := make([]string, gmailCheckpointShardMaxRows+1)
for i := range ids {
id := fmt.Sprintf("m-%04d", i)
ids[i] = id
if err := writeGmailBackupMessageCache(accountHash, gmailBackupMessage{ID: id, Raw: "raw-" + id}); err != nil {
t.Fatalf("writeGmailBackupMessageCache: %v", err)
}
}
shards, err := buildGmailCheckpointShardsFromCache(accountHash, "run-test", 7, ids)
if err != nil {
t.Fatalf("buildGmailCheckpointShardsFromCache: %v", err)
}
if len(shards) != 2 {
t.Fatalf("len(shards) = %d, want 2", len(shards))
}
if shards[0].Rows != gmailCheckpointShardMaxRows || shards[1].Rows != 1 {
t.Fatalf("rows = %d,%d", shards[0].Rows, shards[1].Rows)
}
if !strings.HasSuffix(shards[0].Path, "part-000007.jsonl.gz.age") || !strings.HasSuffix(shards[1].Path, "part-000008.jsonl.gz.age") {
t.Fatalf("paths = %q %q", shards[0].Path, shards[1].Path)
}
}
func TestBuildGmailCheckpointShardsFromCacheSplitsByPlaintextSize(t *testing.T) {
t.Setenv("HOME", t.TempDir())
oldLimit := gmailCheckpointShardMaxPlaintextBytes
gmailCheckpointShardMaxPlaintextBytes = 1
t.Cleanup(func() { gmailCheckpointShardMaxPlaintextBytes = oldLimit })
accountHash := "accthash"
ids := []string{"m1", "m2", "m3"}
for _, id := range ids {
if err := writeGmailBackupMessageCache(accountHash, gmailBackupMessage{ID: id, Raw: strings.Repeat("raw-"+id, 8)}); err != nil {
t.Fatalf("writeGmailBackupMessageCache: %v", err)
}
}
shards, err := buildGmailCheckpointShardsFromCache(accountHash, "run-test", 11, ids)
if err != nil {
t.Fatalf("buildGmailCheckpointShardsFromCache: %v", err)
}
if len(shards) != 3 {
t.Fatalf("len(shards) = %d, want 3", len(shards))
}
for i, shard := range shards {
if shard.Rows != 1 {
t.Fatalf("shards[%d].Rows = %d, want 1", i, shard.Rows)
}
want := fmt.Sprintf("part-%06d.jsonl.gz.age", 11+i)
if !strings.HasSuffix(shard.Path, want) {
t.Fatalf("shards[%d].Path = %q, want suffix %q", i, shard.Path, want)
}
}
}
func TestBuildGmailMessageShardsFromCacheSplitsByPlaintextSize(t *testing.T) {
t.Setenv("HOME", t.TempDir())
accountHash := "accthash"
ids := []string{"m1", "m2", "m3"}
for _, id := range ids {
if err := writeGmailBackupMessageCache(accountHash, gmailBackupMessage{
ID: id,
InternalDate: mustUnixMilli(t, "2026-04-02T10:00:00Z"),
Raw: strings.Repeat("raw-"+id, 8),
}); err != nil {
t.Fatalf("writeGmailBackupMessageCache: %v", err)
}
}
shards, err := buildGmailMessageShardsFromCacheWithLimit(context.Background(), gmailBackupOptions{
AccountHash: accountHash,
ShardMaxRows: 100,
}, ids, 1)
if err != nil {
t.Fatalf("buildGmailMessageShardsFromCacheWithLimit: %v", err)
}
if len(shards) != 3 {
t.Fatalf("len(shards) = %d, want 3", len(shards))
}
for i, shard := range shards {
if shard.Rows != 1 {
t.Fatalf("shards[%d].Rows = %d, want 1", i, shard.Rows)
}
want := fmt.Sprintf("part-%04d.jsonl.gz.age", i+1)
if !strings.HasSuffix(shard.Path, want) {
t.Fatalf("shards[%d].Path = %q, want suffix %q", i, shard.Path, want)
}
}
}
func TestBuildGmailMessageShardsFromCacheWritesPlaintextPaths(t *testing.T) {
t.Setenv("HOME", t.TempDir())
accountHash := "accthash"
messages := []gmailBackupMessage{
{ID: "april-b", InternalDate: mustUnixMilli(t, "2026-04-02T10:00:00Z"), Raw: "raw-b"},
{ID: "april-a", InternalDate: mustUnixMilli(t, "2026-04-01T10:00:00Z"), Raw: "raw-a"},
{ID: "march-a", InternalDate: mustUnixMilli(t, "2026-03-01T10:00:00Z"), Raw: "raw-m"},
}
for _, message := range messages {
if err := writeGmailBackupMessageCache(accountHash, message); err != nil {
t.Fatalf("writeGmailBackupMessageCache: %v", err)
}
}
shards, err := buildGmailMessageShardsFromCache(context.Background(), gmailBackupOptions{
AccountHash: accountHash,
ShardMaxRows: 1,
}, []string{"april-b", "april-a", "march-a"})
if err != nil {
t.Fatalf("buildGmailMessageShardsFromCache: %v", err)
}
if len(shards) != 3 {
t.Fatalf("len(shards) = %d, want 3", len(shards))
}
wantPaths := []string{
"data/gmail/accthash/messages/2026/03/part-0001.jsonl.gz.age",
"data/gmail/accthash/messages/2026/04/part-0001.jsonl.gz.age",
"data/gmail/accthash/messages/2026/04/part-0002.jsonl.gz.age",
}
for i, want := range wantPaths {
if shards[i].Path != want {
t.Fatalf("shards[%d].Path = %q, want %q", i, shards[i].Path, want)
}
if shards[i].PlaintextPath == "" {
t.Fatalf("shards[%d] missing PlaintextPath", i)
}
data, err := os.ReadFile(shards[i].PlaintextPath)
if err != nil {
t.Fatalf("read plaintext shard: %v", err)
}
var rows []gmailBackupMessage
if err := backup.DecodeJSONL(data, &rows); err != nil {
t.Fatalf("DecodeJSONL: %v", err)
}
if len(rows) != 1 {
t.Fatalf("rows len = %d, want 1", len(rows))
}
}
}
func TestFetchBackupDriveCollaborationCollectsMetadataAndErrors(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/files/file1/permissions"):
_ = json.NewEncoder(w).Encode(map[string]any{
"permissions": []map[string]any{{"id": "perm1", "type": "user", "role": "reader", "emailAddress": "a@example.com"}},
})
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/files/file1/comments"):
_ = json.NewEncoder(w).Encode(map[string]any{
"comments": []map[string]any{{"id": "comment1", "content": "hello", "resolved": false}},
})
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/files/file1/revisions"):
_ = json.NewEncoder(w).Encode(map[string]any{
"revisions": []map[string]any{{"id": "rev1", "modifiedTime": "2026-04-02T10:00:00Z"}},
})
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/files/file2/permissions"):
http.Error(w, `{"error":{"message":"denied"}}`, http.StatusForbidden)
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/files/file2/comments"):
_ = json.NewEncoder(w).Encode(map[string]any{})
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/files/file2/revisions"):
_ = json.NewEncoder(w).Encode(map[string]any{})
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
svc, err := drive.NewService(t.Context(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
got, counts := fetchBackupDriveCollaboration(t.Context(), svc, []driveBackupFile{
{File: &drive.File{Id: "file1"}},
{File: &drive.File{Id: "file2"}},
})
if counts["drive.permissions"] != 2 || counts["drive.comments"] != 1 || counts["drive.revisions"] != 1 || counts["drive.collab.errors"] != 1 {
t.Fatalf("unexpected counts: %#v", counts)
}
if got.Permissions[0].FileID != "file1" || got.Permissions[0].Permission.Id != "perm1" {
t.Fatalf("unexpected permission row: %#v", got.Permissions[0])
}
if got.Permissions[1].FileID != "file2" || got.Permissions[1].Error == "" {
t.Fatalf("expected file2 permission error row: %#v", got.Permissions[1])
}
}
func TestDomainFromAccount(t *testing.T) {
if got := domainFromAccount("Admin@Example.COM"); got != "Example.COM" {
t.Fatalf("domainFromAccount = %q", got)
}
if got := domainFromAccount("example.com"); got != "example.com" {
t.Fatalf("domainFromAccount without @ = %q", got)
}
}
func TestDriveBackupContentPlansPreferReadableWorkspaceFormats(t *testing.T) {
docPlans := driveBackupContentPlans(&drive.File{Id: "doc1", Name: "Spec", MimeType: driveMimeGoogleDoc}, false)
if len(docPlans) != 2 || docPlans[0].Extension != ".docx" || docPlans[1].Extension != ".md" {
t.Fatalf("unexpected doc plans: %#v", docPlans)
}
sheetPlans := driveBackupContentPlans(&drive.File{Id: "sheet1", Name: "Budget", MimeType: driveMimeGoogleSheet}, false)
if len(sheetPlans) != 1 || sheetPlans[0].Extension != ".xlsx" {
t.Fatalf("unexpected sheet plans: %#v", sheetPlans)
}
folderPlans := driveBackupContentPlans(&drive.File{Id: "folder1", Name: "Folder", MimeType: driveMimeGoogleFolder}, false)
if len(folderPlans) != 0 {
t.Fatalf("folder should not have content plans: %#v", folderPlans)
}
binaryPlans := driveBackupContentPlans(&drive.File{Id: "bin1", Name: "Archive.zip", MimeType: "application/zip"}, false)
if len(binaryPlans) != 0 {
t.Fatalf("binary should be opt-in: %#v", binaryPlans)
}
binaryPlans = driveBackupContentPlans(&drive.File{Id: "bin1", Name: "Archive.zip", MimeType: "application/zip"}, true)
if len(binaryPlans) != 1 || binaryPlans[0].Source != "download" {
t.Fatalf("unexpected binary plans: %#v", binaryPlans)
}
}
func TestDownloadDriveBackupContentHonorsTimeout(t *testing.T) {
origExport := driveExportDownload
t.Cleanup(func() { driveExportDownload = origExport })
driveExportDownload = func(ctx context.Context, _ *drive.Service, _, _ string) (*http.Response, error) {
<-ctx.Done()
return nil, ctx.Err()
}
_, err := downloadDriveBackupContent(t.Context(), nil, &drive.File{Id: "doc1"}, driveBackupContentPlan{
MimeType: mimePDF,
Source: "export",
}, time.Millisecond)
if err == nil || !strings.Contains(err.Error(), "deadline exceeded") {
t.Fatalf("expected deadline exceeded, got %v", err)
}
}
func TestExportDriveContentsWritesReadableFilesAndIndex(t *testing.T) {
outDir := t.TempDir()
row := driveBackupContent{
FileID: "file/one",
Name: "Quarterly Plan",
MimeType: driveMimeGoogleDoc,
ExportName: "Quarterly_Plan.md",
ExportMime: mimeTextMarkdown,
Source: "export",
Size: 8,
DataBase64: base64.StdEncoding.EncodeToString([]byte("# Plan\n")),
}
shard, err := backup.NewJSONLShard("drive", "contents", "acct/hash", "data/drive/acct/contents/part-0001.jsonl.gz.age", []driveBackupContent{row})
if err != nil {
t.Fatalf("NewJSONLShard: %v", err)
}
files, count, err := exportDriveContents(outDir, shard)
if err != nil {
t.Fatalf("exportDriveContents: %v", err)
}
if files != 2 || count != 1 {
t.Fatalf("files,count = %d,%d want 2,1", files, count)
}
exported := readText(t, filepath.Join(outDir, "drive", "acct_hash", "files", "file_one", "Quarterly_Plan.md"))
if exported != "# Plan\n" {
t.Fatalf("exported = %q", exported)
}
index := readText(t, filepath.Join(outDir, "drive", "acct_hash", "files", "index.jsonl"))
if !strings.Contains(index, `"fileId":"file/one"`) || !strings.Contains(index, `"path":"drive/acct_hash/files/file_one/Quarterly_Plan.md"`) {
t.Fatalf("index missing expected fields: %s", index)
}
}
func TestEnsureExportOutsideRepoRejectsNestedPlaintext(t *testing.T) {
repo := filepath.Join(t.TempDir(), "repo")
if err := os.MkdirAll(filepath.Join(repo, "data"), 0o700); err != nil {
t.Fatalf("mkdir repo: %v", err)
}
if err := ensureExportOutsideRepo(filepath.Join(repo, "plaintext"), repo); err == nil {
t.Fatal("expected nested export dir to be rejected")
}
if err := ensureExportOutsideRepo(filepath.Join(filepath.Dir(repo), "export"), repo); err != nil {
t.Fatalf("outside export rejected: %v", err)
}
}
func TestResetExportTargetsKeepsGmailMessageFiles(t *testing.T) {
outDir := t.TempDir()
messagePath := filepath.Join(outDir, "gmail", "acct_hash", "messages", "2026", "04", "message.md")
indexPath := filepath.Join(outDir, "gmail", "acct_hash", "messages", "index.jsonl")
if err := os.MkdirAll(filepath.Dir(messagePath), 0o700); err != nil {
t.Fatalf("mkdir message dir: %v", err)
}
if err := os.WriteFile(messagePath, []byte("keep"), 0o600); err != nil {
t.Fatalf("write message: %v", err)
}
if err := os.WriteFile(indexPath, []byte("reset"), 0o600); err != nil {
t.Fatalf("write index: %v", err)
}
err := resetExportTargets(outDir, []backup.ShardEntry{{
Service: backupServiceGmail,
Kind: "messages",
Account: "acct/hash",
}})
if err != nil {
t.Fatalf("resetExportTargets: %v", err)
}
if got := readText(t, messagePath); got != "keep" {
t.Fatalf("message file = %q, want keep", got)
}
if _, err := os.Stat(indexPath); !os.IsNotExist(err) {
t.Fatalf("index still exists or stat failed: %v", err)
}
}
func newBackupConfigForCmdTest(t *testing.T) (string, string, []string) {
t.Helper()
dir := t.TempDir()
repo := filepath.Join(dir, "repo")
identity := filepath.Join(dir, "age.key")
config := filepath.Join(dir, "backup.json")
recipient, err := backup.EnsureIdentity(identity)
if err != nil {
t.Fatalf("EnsureIdentity: %v", err)
}
recipients := []string{recipient}
if err := backup.SaveConfig(config, backup.Config{Repo: repo, Identity: identity, Recipients: recipients}); err != nil {
t.Fatalf("SaveConfig: %v", err)
}
return repo, config, recipients
}
func mustUnixMilli(t *testing.T, value string) int64 {
t.Helper()
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
t.Fatalf("parse time %q: %v", value, err)
}
return parsed.UnixMilli()
}
func readText(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(data)
}