package backupjob import ( "testing" "time" corev1 "k8s.io/api/core/v1" ) func sampleJobParams() JobParams { return JobParams{ Server: "survival", FormerOwner: "usr-abc", WorldPVC: "world-survival-0", BackupPVC: "felis-backups", Namespace: defaultNamespace, ServiceAccount: defaultServiceAccount, Image: "registry.felis.svc:5000/felis:1.0", ConfigSecret: defaultConfigSecret, ConfigMount: defaultConfigMount, BackupRoot: "/backups", WorldsRoot: "/world", Deadline: 30 * time.Minute, CPULimit: "1", MemLimit: "1Gi", RunAsUser: 0, RunAsGroup: 0, FSGroup: 0, TTLAfterFinished: 10 * time.Minute, } } // The backup Pod runs under the weak felis-restore SA — never the felis-api // identity — with its token un-mounted, so it cannot reach the K8s API. Its DB // access comes from the mounted config Secret, not from any SA permission. func TestBackupJobRunsUnderWeakSAWithNoAPIToken(t *testing.T) { job, err := BackupJob(sampleJobParams()) if err != nil { t.Fatalf("BackupJob: %v", err) } sa := job.Spec.Template.Spec.ServiceAccountName if sa != defaultServiceAccount { t.Errorf("service account = %q, want %q", sa, defaultServiceAccount) } if sa == "felis-api" { t.Fatal("backup Pod must NOT run as the felis-api SA") } if amt := job.Spec.Template.Spec.AutomountServiceAccountToken; amt == nil || *amt { t.Error("AutomountServiceAccountToken must be explicitly false") } } // The deliberate departure from restore's zero-secret isolation: a backup Pod // mounts EXACTLY the two PVCs (world read-only, backup read-write) PLUS the config // Secret read-only (so it can self-record its world_backups row like the reaper) — // and nothing else. This test freezes that exact volume set so a future edit that // widens it (e.g. a second Secret, or a writable world mount) fails loudly. func TestBackupJobMountsTwoPVCsPlusConfigSecretOnly(t *testing.T) { job, err := BackupJob(sampleJobParams()) if err != nil { t.Fatalf("BackupJob: %v", err) } spec := job.Spec.Template.Spec var secretVols, pvcVols int for _, v := range spec.Volumes { switch { case v.Secret != nil: secretVols++ if v.Secret.SecretName != defaultConfigSecret { t.Errorf("secret volume = %q, want the config secret %q", v.Secret.SecretName, defaultConfigSecret) } case v.PersistentVolumeClaim != nil: pvcVols++ case v.EmptyDir != nil: // the /tmp scratch dir under the read-only root fs — allowed default: t.Errorf("unexpected volume %q: a backup Pod mounts only the two PVCs, the config Secret, and a /tmp emptyDir", v.Name) } } if secretVols != 1 { t.Errorf("secret volumes = %d, want exactly 1 (the config Secret)", secretVols) } if pvcVols != 2 { t.Errorf("PVC volumes = %d, want exactly 2 (world + backup)", pvcVols) } // World read-only (backup never mutates the world), backup read-write (the // archive is written into it) — the mirror image of the restore Job. world := mountByName(t, spec.Containers[0].VolumeMounts, worldVolume) if !world.ReadOnly { t.Error("world mount must be read-only — a backup only reads the world") } back := mountByName(t, spec.Containers[0].VolumeMounts, backupVolume) if back.ReadOnly { t.Error("backup mount must be read-write — the archive is written into it") } cfg := mountByName(t, spec.Containers[0].VolumeMounts, configVolume) if !cfg.ReadOnly { t.Error("config Secret mount must be read-only") } // The world PVC volume itself is also declared read-only so the RWO claim is // requested read-only (defense in depth beyond the mount flag). for _, v := range spec.Volumes { if v.PersistentVolumeClaim != nil && v.PersistentVolumeClaim.ClaimName == "world-survival-0" && !v.PersistentVolumeClaim.ReadOnly { t.Error("world PVC volume source must be read-only") } } } // The backup container is hardened like the restore/build Job containers: no // privilege, no escalation, read-only root fs, ALL capabilities dropped — plus // DAC_OVERRIDE, because the Pod runs as root and the world may have been written // by a game image with a different UID (verified live: a uid-1000 executor cannot // read Paper's mode-0600 level.dat). func TestBackupJobContainerIsHardened(t *testing.T) { job, err := BackupJob(sampleJobParams()) if err != nil { t.Fatalf("BackupJob: %v", err) } pod := job.Spec.Template.Spec if pod.SecurityContext == nil { t.Fatal("pod SecurityContext is nil") } if pod.SecurityContext.RunAsNonRoot == nil || *pod.SecurityContext.RunAsNonRoot { t.Error("pod must NOT require non-root: root is the owner-matching default for game-image worlds") } if pod.SecurityContext.RunAsUser == nil || *pod.SecurityContext.RunAsUser != 0 || pod.SecurityContext.RunAsGroup == nil || *pod.SecurityContext.RunAsGroup != 0 { t.Errorf("pod must run as 0:0 by default, got %+v", pod.SecurityContext) } if pod.SecurityContext.FSGroup != nil { t.Error("fsGroup must stay unset when zero (a root executor must not chgrp the world volume)") } sc := job.Spec.Template.Spec.Containers[0].SecurityContext if sc == nil { t.Fatal("container SecurityContext is nil") } if sc.Privileged == nil || *sc.Privileged { t.Error("Privileged must be false") } if sc.AllowPrivilegeEscalation == nil || *sc.AllowPrivilegeEscalation { t.Error("AllowPrivilegeEscalation must be false") } if sc.ReadOnlyRootFilesystem == nil || !*sc.ReadOnlyRootFilesystem { t.Error("ReadOnlyRootFilesystem must be true") } if sc.Capabilities == nil || len(sc.Capabilities.Drop) != 1 || sc.Capabilities.Drop[0] != "ALL" { t.Error("capabilities must drop ALL") } if len(sc.Capabilities.Add) != 1 || sc.Capabilities.Add[0] != "DAC_OVERRIDE" { t.Errorf("capabilities must add exactly DAC_OVERRIDE, got %v", sc.Capabilities.Add) } } // One-shot: a wedged archive must not loop, and a deadline caps it. func TestBackupJobIsOneShotWithDeadline(t *testing.T) { job, err := BackupJob(sampleJobParams()) if err != nil { t.Fatalf("BackupJob: %v", err) } if job.Spec.BackoffLimit == nil || *job.Spec.BackoffLimit != 0 { t.Error("BackoffLimit must be 0 (no retry loop)") } if job.Spec.ActiveDeadlineSeconds == nil || *job.Spec.ActiveDeadlineSeconds <= 0 { t.Error("ActiveDeadlineSeconds must be set") } if job.Spec.TTLSecondsAfterFinished == nil { t.Error("TTLSecondsAfterFinished must be set so the finished Job is GC'd") } } // The command carries the former owner so the recorded backup can be restored by // its owner; an empty former owner (admin backing up an unowned server) omits it. func TestBackupJobArgsCarryServerAndOwner(t *testing.T) { job, err := BackupJob(sampleJobParams()) if err != nil { t.Fatalf("BackupJob: %v", err) } args := job.Spec.Template.Spec.Containers[0].Args if !argsContain(args, "--server", "survival") { t.Errorf("args missing --server survival: %v", args) } if !argsContain(args, "--former-owner", "usr-abc") { t.Errorf("args missing --former-owner usr-abc: %v", args) } p := sampleJobParams() p.FormerOwner = "" unowned, err := BackupJob(p) if err != nil { t.Fatalf("BackupJob(unowned): %v", err) } for _, a := range unowned.Spec.Template.Spec.Containers[0].Args { if a == "--former-owner" { t.Error("--former-owner must be omitted when there is no former owner") } } } // A safety snapshot records itself as pre_restore, spares the backup the chained // restore extracts from its prune, and carries the chain on the Job (only there: // felis-api settles it by patching the Job's label). func TestBackupJobCarriesTheRestoreChain(t *testing.T) { p := sampleJobParams() p.RestoreRef, p.RestoreBackupID = "/backups/survival/a.tar.gz", "bk-1" job, err := BackupJob(p) if err != nil { t.Fatalf("BackupJob: %v", err) } args := job.Spec.Template.Spec.Containers[0].Args if !argsContain(args, "--reason", ReasonPreRestore) || !argsContain(args, "--protect", "bk-1") { t.Errorf("args = %v, want --reason %s --protect bk-1", args, ReasonPreRestore) } if job.Labels[labelThenRestore] != thenRestorePending { t.Errorf("job labels = %v, want %s=%s", job.Labels, labelThenRestore, thenRestorePending) } if _, ok := job.Spec.Template.Labels[labelThenRestore]; ok { t.Errorf("pod template carries the chain label: %v", job.Spec.Template.Labels) } if job.Annotations[annotationRestoreRef] != p.RestoreRef || job.Annotations[annotationRestoreBackupID] != "bk-1" { t.Errorf("job annotations = %v", job.Annotations) } plain, err := BackupJob(sampleJobParams()) if err != nil { t.Fatalf("BackupJob(plain): %v", err) } if _, ok := plain.Labels[labelThenRestore]; ok || len(plain.Annotations) != 0 { t.Errorf("a plain backup carries a chain: labels %v annotations %v", plain.Labels, plain.Annotations) } for _, a := range plain.Spec.Template.Spec.Containers[0].Args { if a == "--reason" || a == "--protect" { t.Errorf("a plain backup passes %s: %v", a, plain.Spec.Template.Spec.Containers[0].Args) } } } // A scheduled backup records itself as scheduled (so the Job prunes it to its // own keep, not the owner's manual one) and says so on the Job for the jobs // route; it protects nothing and carries no chain. func TestBackupJobRecordsAScheduledBackup(t *testing.T) { p := sampleJobParams() p.Scheduled = true job, err := BackupJob(p) if err != nil { t.Fatalf("BackupJob: %v", err) } args := job.Spec.Template.Spec.Containers[0].Args if !argsContain(args, "--reason", ReasonScheduled) { t.Errorf("args = %v, want --reason %s", args, ReasonScheduled) } for _, a := range args { if a == "--protect" { t.Errorf("a scheduled backup passes --protect: %v", args) } } if job.Labels[LabelReason] != ReasonScheduled { t.Errorf("job labels = %v, want %s=%s", job.Labels, LabelReason, ReasonScheduled) } if _, ok := job.Labels[labelThenRestore]; ok { t.Errorf("a scheduled backup carries a chain: %v", job.Labels) } plain, err := BackupJob(sampleJobParams()) if err != nil { t.Fatalf("BackupJob(plain): %v", err) } if _, ok := plain.Labels[LabelReason]; ok { t.Errorf("a plain backup is labelled scheduled: %v", plain.Labels) } } func TestBackupJobRejectsMissingInputs(t *testing.T) { for _, tc := range []struct { name string mut func(*JobParams) }{ {"no image", func(p *JobParams) { p.Image = "" }}, {"no world pvc", func(p *JobParams) { p.WorldPVC = "" }}, {"no backup pvc", func(p *JobParams) { p.BackupPVC = "" }}, {"no config secret", func(p *JobParams) { p.ConfigSecret = "" }}, {"chain without backup id", func(p *JobParams) { p.RestoreRef = "/backups/a.tar.gz" }}, {"scheduled with a chain", func(p *JobParams) { p.Scheduled, p.RestoreRef, p.RestoreBackupID = true, "/backups/a.tar.gz", "bk-1" }}, } { t.Run(tc.name, func(t *testing.T) { p := sampleJobParams() tc.mut(&p) if _, err := BackupJob(p); err == nil { t.Errorf("BackupJob(%s) = nil error, want a validation error", tc.name) } }) } } func mountByName(t *testing.T, mounts []corev1.VolumeMount, name string) corev1.VolumeMount { t.Helper() for _, m := range mounts { if m.Name == name { return m } } t.Fatalf("volume mount %q not found", name) return corev1.VolumeMount{} } func argsContain(args []string, flag, val string) bool { for i := 0; i+1 < len(args); i++ { if args[i] == flag && args[i+1] == val { return true } } return false }