1package admin23import (4 "archive/tar"5 "bytes"6 "compress/gzip"7 "context"8 "encoding/json"9 "errors"10 "io"11 "maps"12 "os"13 "path/filepath"14 "slices"15 "strings"16 "testing"17 "time"18)1920// backupHarness is a harness that keeps backups, making one delay after21// changes through the API.22func backupHarness(t *testing.T, delay time.Duration) *harness {23 return newHarness(t, func(s *Server) {24 s.Backups = filepath.Join(t.TempDir(), "backups")25 s.Version = "9.9.9"26 s.bk.delay = delay27 })28}2930// write writes a file under root, making its folder.31func write(t *testing.T, root, rel, data string) {32 t.Helper()33 p := filepath.Join(root, filepath.FromSlash(rel))34 if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {35 t.Fatal(err)36 }37 if err := os.WriteFile(p, []byte(data), 0o644); err != nil {38 t.Fatal(err)39 }40}4142func read(t *testing.T, path string) string {43 t.Helper()44 raw, err := os.ReadFile(path)45 if err != nil {46 t.Fatal(err)47 }48 return string(raw)49}5051func exists(path string) bool {52 _, err := os.Lstat(path)53 return err == nil54}5556// entry is a file in a test archive.57type entry struct {58 name string59 body string60 typ byte // tar.TypeReg when 061 link string62}6364var legacyTime = time.Date(2026, 1, 1, 12, 0, 0, 0, time.Local)6566// tarball makes a tar.gz of entries.67func tarball(t *testing.T, entries ...entry) []byte {68 t.Helper()69 var buf bytes.Buffer70 zw := gzip.NewWriter(&buf)71 tw := tar.NewWriter(zw)72 for _, e := range entries {73 hdr := &tar.Header{Name: e.name, Typeflag: e.typ, Mode: 0o644, Size: int64(len(e.body)), Linkname: e.link, ModTime: legacyTime}74 if hdr.Typeflag == 0 {75 hdr.Typeflag = tar.TypeReg76 }77 if hdr.Typeflag != tar.TypeReg {78 hdr.Size = 079 }80 if err := tw.WriteHeader(hdr); err != nil {81 t.Fatal(err)82 }83 if hdr.Size > 0 {84 if _, err := tw.Write([]byte(e.body)); err != nil {85 t.Fatal(err)86 }87 }88 }89 if err := tw.Close(); err != nil {90 t.Fatal(err)91 }92 if err := zw.Close(); err != nil {93 t.Fatal(err)94 }95 return buf.Bytes()96}9798type unpacked struct {99 mode int64100 typ byte101 body string102}103104// untar lists a tar.gz's entries by name.105func untar(t *testing.T, data []byte) (map[string]unpacked, []string) {106 t.Helper()107 zr, err := gzip.NewReader(bytes.NewReader(data))108 if err != nil {109 t.Fatal(err)110 }111 tr := tar.NewReader(zr)112 out := map[string]unpacked{}113 var order []string114 for {115 hdr, err := tr.Next()116 if err == io.EOF {117 break118 }119 if err != nil {120 t.Fatal(err)121 }122 body, _ := io.ReadAll(tr)123 out[hdr.Name] = unpacked{hdr.Mode, hdr.Typeflag, string(body)}124 order = append(order, hdr.Name)125 }126 return out, order127}128129// backups lists the harness's backups.130func (h *harness) backups() []map[string]any {131 h.t.Helper()132 code, res, raw := h.do("GET", "/admin/api/backups", nil)133 if code != 200 {134 h.t.Fatalf("backups: %d %s", code, raw)135 }136 var out []map[string]any137 for _, b := range res["backups"].([]any) {138 out = append(out, b.(map[string]any))139 }140 return out141}142143// restore asks to restore a backup.144func (h *harness) restore(file string, req map[string]any) (int, restorePlan, string) {145 h.t.Helper()146 code, _, raw := h.do("POST", "/admin/api/backups/"+file+"/restore", req)147 var p restorePlan148 _ = json.Unmarshal([]byte(raw), &p)149 return code, p, raw150}151152func item(t *testing.T, p restorePlan, key string) *restoreItem {153 t.Helper()154 for _, x := range p.Items {155 if x.Key == key {156 return x157 }158 }159 t.Fatalf("no %q in %+v", key, p.Items)160 return nil161}162163func TestBackupContents(t *testing.T) {164 h := backupHarness(t, time.Hour)165 root := h.root166 write(t, root, "1.2 Cat Calming/Birds [abc].mp4", "video")167 write(t, root, "1.2 Cat Calming/Birds [abc].info.json", `{"title": "Birds"}`)168 write(t, root, "1.2 Cat Calming/channel.json", `{"callSign": "CATS"}`)169 write(t, root, "1.2 Cat Calming/logo.png", string(pngLogo))170 write(t, root, "1.3 Fireplace/fire.mkv", "video")171 write(t, root, "1.4 Cartoons/jellyfin.json", `{"series": ["Futurama (1999)"], "order": "shuffle"}`)172 write(t, root, "1.8 Northernlion/youtube.json", `{"channels": ["https://www.youtube.com/@Northernlion"]}`)173 write(t, root, "1.8 Northernlion/.catalog.json", `{"sources": []}`)174 write(t, root, "1.8 Northernlion/.playout.json", `{"airings": []}`)175 write(t, root, "1.8 Northernlion/.playout.json.tmp", `{}`)176 write(t, root, "1.8 Northernlion/.tmp-42", `{}`)177 write(t, root, "1.8 Northernlion/logo.svg", `<svg xmlns="http://www.w3.org/2000/svg"/>`)178 write(t, root, ".trash/jellyfin.json", `{}`)179 write(t, root, ".jellyfin.json", `{"server": "http://127.0.0.1:1", "user": "viewer", "password": "pw"}`)180 write(t, root, ".weather.json", `{"number": "1.1", "callSign": "WX"}`)181 write(t, root, ".weather-logo.png", string(pngLogo))182 write(t, root, "notes.txt", "hi")183 write(t, root, "extra.json", "{}")184185 code, b, raw := h.do("POST", "/admin/api/backups", map[string]string{"label": " before\tthe move "})186 if code != 201 || b["reason"] != "manual" || b["label"] != "before the move" || b["auto"] != false || b["airwavesd"] != "9.9.9" ||187 b["account"] != true || b["weather"] != true || b["schedules"] != true {188 t.Fatalf("backup: %d %s", code, raw)189 }190 file := b["file"].(string)191 if !strings.HasPrefix(file, "airwaves-") || !strings.HasSuffix(file, "-manual.tar.gz") {192 t.Errorf("file: %s", file)193 }194 if info, err := os.Stat(h.admin.Backups); err != nil || info.Mode().Perm() != 0o700 {195 t.Errorf("backups folder: %v %v", info.Mode(), err)196 }197 path := filepath.Join(h.admin.Backups, file)198 if info, err := os.Stat(path); err != nil || info.Mode().Perm() != 0o600 {199 t.Errorf("backup file: %v %v", info.Mode(), err)200 }201 if strings.Contains(raw, "pw") {202 t.Error("the listing gave the password away")203 }204205 data, _ := os.ReadFile(path)206 got, order := untar(t, data)207 want := []string{208 manifestFile, ".jellyfin.json", ".weather-logo.png", ".weather.json",209 "1.2 Cat Calming/", "1.2 Cat Calming/channel.json", "1.2 Cat Calming/logo.png",210 "1.3 Fireplace/",211 "1.4 Cartoons/", "1.4 Cartoons/jellyfin.json",212 "1.8 Northernlion/", "1.8 Northernlion/.playout.json", "1.8 Northernlion/logo.svg", "1.8 Northernlion/youtube.json",213 }214 if !slices.Equal(order, want) {215 t.Errorf("entries:\n%q\nwant\n%q", order, want)216 }217 for name, mode := range map[string]int64{".jellyfin.json": 0o600, "1.4 Cartoons/jellyfin.json": 0o600, "1.8 Northernlion/youtube.json": 0o644, "1.2 Cat Calming/": 0o755} {218 if got[name].mode != mode {219 t.Errorf("%s: mode %o", name, got[name].mode)220 }221 }222 if got["1.4 Cartoons/jellyfin.json"].body != `{"series": ["Futurama (1999)"], "order": "shuffle"}` || got["1.3 Fireplace/"].typ != tar.TypeDir {223 t.Errorf("files: %+v", got)224 }225 var m manifest226 if err := json.Unmarshal([]byte(got[manifestFile].body), &m); err != nil {227 t.Fatal(err)228 }229 kinds := map[string]string{}230 for _, f := range m.Folders {231 kinds[f.Folder] = f.Kind232 }233 if m.Version != 1 || m.Airwavesd != "9.9.9" || m.Reason != "manual" || m.Label != "before the move" || time.Since(m.Created) > time.Minute ||234 !m.Account || !m.Weather || !m.Schedules ||235 !maps.Equal(kinds, map[string]string{"1.2 Cat Calming": "folder", "1.3 Fireplace": "folder", "1.4 Cartoons": "jellyfin", "1.8 Northernlion": "youtube"}) {236 t.Errorf("manifest: %s", got[manifestFile].body)237 }238239 // The download is the file.240 resp, body := h.get("/admin/api/backups/" + file)241 if resp.StatusCode != 200 || resp.Header.Get("Content-Type") != "application/gzip" || body != string(data) ||242 !strings.Contains(resp.Header.Get("Content-Disposition"), `filename=`+file) {243 t.Errorf("download: %d %v", resp.StatusCode, resp.Header)244 }245246 // A second backup by hand is made even with nothing changed; an247 // automatic one isn't.248 if code, b2, _ := h.do("POST", "/admin/api/backups", nil); code != 201 || b2["file"] == file {249 t.Errorf("second: %d %v", code, b2)250 }251 if b, made, err := h.admin.backUp(daily, ""); err != nil || made || b.Reason != "manual" {252 t.Errorf("daily with nothing changed: %v %v %+v", made, err, b)253 }254 // A schedule moving on isn't a change either.255 write(t, root, "1.8 Northernlion/.playout.json", `{"airings": [], "basis": "later"}`)256 if _, made, _ := h.admin.backUp(daily, ""); made {257 t.Error("a schedule moving on made a backup")258 }259 write(t, root, "1.5 New/video.mp4", "video")260 if b, made, err := h.admin.backUp(daily, ""); err != nil || !made || b.Reason != "daily" || !b.Auto || len(b.Folders) != 5 {261 t.Errorf("daily after a change by hand: %v %v %+v", made, err, b)262 }263 list := h.backups()264 if len(list) != 3 || list[0]["reason"] != "daily" {265 t.Errorf("list: %v", list)266 }267}268269func TestBackupEndpoints(t *testing.T) {270 off := newHarness(t)271 if code, _, raw := off.do("GET", "/admin/api/backups", nil); code != 503 || !strings.Contains(raw, "backups are off") {272 t.Errorf("off: %d %s", code, raw)273 }274275 h := backupHarness(t, time.Hour)276 if list := h.backups(); len(list) != 0 {277 t.Errorf("none yet: %v", list)278 }279 if code, _, raw := h.do("POST", "/admin/api/backups", map[string]string{"label": strings.Repeat("x", 61)}); code != 400 || !strings.Contains(raw, "up to 60") {280 t.Errorf("long label: %d %s", code, raw)281 }282 _, b, _ := h.do("POST", "/admin/api/backups", map[string]string{"label": "one"})283 file := b["file"].(string)284 _, res, _ := h.do("GET", "/admin/api/backups", nil)285 if res["folder"] != h.admin.Backups || res["pending"] != false {286 t.Errorf("list: %v", res)287 }288 for _, bad := range []string{"..%2F..%2Fetc%2Fpasswd", "nope.tar.gz", ".hidden.tar.gz", "x.zip"} {289 if resp, _ := h.get("/admin/api/backups/" + bad); resp.StatusCode != 404 {290 t.Errorf("GET %s: %d", bad, resp.StatusCode)291 }292 if code, _, _ := h.do("DELETE", "/admin/api/backups/"+bad, nil); code != 404 {293 t.Errorf("DELETE %s: %d", bad, code)294 }295 if code, _, _ := h.restore(bad, map[string]any{"preview": true}); code != 404 {296 t.Errorf("restore %s: %d", bad, code)297 }298 }299 if code, _, raw := h.restore(file, map[string]any{"preview": true, "channels": []string{"9.9 Nothing"}}); code != 400 || !strings.Contains(raw, "9.9 Nothing") {300 t.Errorf("unknown channel: %d %s", code, raw)301 }302 if code, _, raw := h.send("POST", "/admin/api/backups/upload?name=junk.tar.gz", "application/gzip", []byte("not a backup")); code != 400 ||303 !strings.Contains(raw, "isn't an Airwaves backup") {304 t.Errorf("junk: %d %s", code, raw)305 }306 if code, _, raw := h.send("POST", "/admin/api/backups/upload", "application/gzip", nil); code != 400 {307 t.Errorf("nothing: %d %s", code, raw)308 }309 if code, _, _ := h.do("DELETE", "/admin/api/backups/"+file, nil); code != 204 {310 t.Errorf("delete: %d", code)311 }312 if code, _, _ := h.do("DELETE", "/admin/api/backups/"+file, nil); code != 404 {313 t.Errorf("delete again: %d", code)314 }315 if list := h.backups(); len(list) != 0 {316 t.Errorf("deleted: %v", list)317 }318 // A file that isn't a backup is listed, so it can be deleted, but not319 // restored or pruned.320 write(t, h.admin.Backups, "broken.tar.gz", "garbage")321 list := h.backups()322 if len(list) != 1 || list[0]["error"] == nil || list[0]["auto"] != false {323 t.Errorf("broken: %v", list)324 }325 if code, _, _ := h.restore("broken.tar.gz", map[string]any{"preview": true}); code != 422 {326 t.Errorf("restore broken: %d", code)327 }328}329330func TestBackupAfterChanges(t *testing.T) {331 h := backupHarness(t, 150*time.Millisecond)332 cat := func(callSign string) (int, string) {333 code, _, raw := h.do("PUT", "/admin/api/channels/1.2", map[string]any{"kind": "folder", "number": "1.2", "name": "Cat Calming", "callSign": callSign})334 return code, raw335 }336 pending := func() bool {337 _, res, _ := h.do("GET", "/admin/api/backups", nil)338 return res["pending"] == true339 }340 wait := func(n int) []map[string]any {341 t.Helper()342 for range 100 {343 if list := h.backups(); len(list) >= n && !pending() {344 time.Sleep(300 * time.Millisecond) // and no more come345 return h.backups()346 }347 time.Sleep(30 * time.Millisecond)348 }349 t.Fatalf("no backup %d: %v", n, h.backups())350 return nil351 }352353 // Reading changes nothing.354 h.do("GET", "/admin/api/state", nil)355 if pending() {356 t.Error("a GET made a backup wait")357 }358 // A failed change changes nothing.359 if code, _ := cat("NOT A CALL SIGN AT ALL"); code != 400 || pending() {360 t.Errorf("failed change: %d %v", code, pending())361 }362 // A burst of changes makes one backup.363 for _, cs := range []string{"C1", "C2", "C3"} {364 if code, raw := cat(cs); code != 200 {365 t.Fatalf("%s: %s", cs, raw)366 }367 }368 if !pending() {369 t.Error("nothing waiting after changes")370 }371 list := wait(1)372 if len(list) != 1 || list[0]["reason"] != "change" || list[0]["auto"] != true {373 t.Fatalf("after a burst: %v", list)374 }375 data, _ := os.ReadFile(filepath.Join(h.admin.Backups, list[0]["file"].(string)))376 if got, _ := untar(t, data); !strings.Contains(got["1.2 Cat Calming/channel.json"].body, `"C3"`) {377 t.Errorf("backed up: %+v", got)378 }379 // Saving the same again makes none.380 cat("C3")381 if list := wait(1); len(list) != 1 {382 t.Errorf("unchanged: %v", list)383 }384 // Nor do the backups' own requests.385 h.do("POST", "/admin/api/backups", nil)386 if pending() {387 t.Error("a backup by hand made another wait")388 }389 // An agent's change is backed up too.390 ok(t, h.agent(), "set_channel_details", map[string]any{"number": "1.2", "callSign": "C9"})391 if list := wait(3); len(list) != 3 || list[0]["reason"] != "change" {392 t.Errorf("after an agent's change: %v", list)393 }394}395396func TestBackupWhenStopping(t *testing.T) {397 h := backupHarness(t, time.Hour)398 h.do("PUT", "/admin/api/channels/1.2", map[string]any{"kind": "folder", "number": "1.2", "name": "Cat Calming", "callSign": "CATS"})399 if !h.admin.backupWaiting() {400 t.Fatal("nothing waiting")401 }402 ctx, cancel := context.WithCancel(context.Background())403 done := make(chan struct{})404 go func() {405 h.admin.Run(ctx)406 close(done)407 }()408 cancel()409 <-done410 if list := h.backups(); len(list) != 1 || list[0]["reason"] != "change" {411 t.Errorf("the waiting change wasn't backed up: %v", list)412 }413 // Then no more are made by themselves.414 h.do("PUT", "/admin/api/channels/1.2", map[string]any{"kind": "folder", "number": "1.2", "name": "Cat Calming", "callSign": "MEOW"})415 if h.admin.backupWaiting() {416 t.Error("a backup waits after stopping")417 }418}419420func TestBackupRetention(t *testing.T) {421 now := time.Date(2026, 10, 20, 12, 0, 0, 0, time.Local)422 var list []backupJSON423 // Five automatic backups a day for 20 days, newest first, a backup by424 // hand each day, and one that can't be read.425 for d := range 20 {426 for i := range 5 {427 at := now.AddDate(0, 0, -d).Add(-time.Duration(i) * time.Hour)428 list = append(list, backupJSON{File: at.Format("0102-15"), Created: at, Reason: afterChange, Auto: true})429 }430 at := now.AddDate(0, 0, -d).Add(-30 * time.Minute)431 list = append(list, backupJSON{File: at.Format("0102-1504") + "-manual", Created: at, Reason: byHand})432 }433 list = append(list, backupJSON{File: "broken", Created: now.AddDate(-1, 0, 0), Reason: daily, Auto: true, Error: "damaged"})434 slices.SortStableFunc(list, func(a, b backupJSON) int { return b.Created.Compare(a.Created) })435436 drop := map[string]bool{}437 for _, b := range prune(list, now) {438 if !b.Auto {439 t.Errorf("dropped %s, made by hand", b.File)440 }441 drop[b.File] = true442 }443 // Days 0 to 5 are the newest 30; days 6 to 13 keep their newest; the444 // rest go.445 if len(drop) != 100-30-8 {446 t.Errorf("dropped %d", len(drop))447 }448 for file, gone := range map[string]bool{449 "1020-12": false, "1015-08": false, // the 30th newest450 "1014-12": false, "1014-11": true, // day 6: its newest stays451 "1007-12": false, "1007-11": true, // day 13, the last452 "1006-12": true, "1001-12": true, // older453 "broken": false,454 } {455 if drop[file] != gone {456 t.Errorf("%s: dropped %v", file, drop[file])457 }458 }459460 // On disk: the newest 30 stay, and those made by hand.461 h := backupHarness(t, time.Hour)462 st, _ := readSetup(h.root)463 start := time.Now().Add(-40 * time.Second)464 for i := range 35 {465 if _, err := h.admin.saveBackup(st.manifest(afterChange, "", "", start.Add(time.Duration(i)*time.Second)), st); err != nil {466 t.Fatal(err)467 }468 }469 h.admin.saveBackup(st.manifest(byHand, "keep", "", start.Add(-time.Hour)), st)470 h.admin.pruneBackups()471 left := h.backups()472 if len(left) != 31 || left[30]["reason"] != "manual" || left[0]["created"].(string) < left[29]["created"].(string) {473 t.Errorf("left %d: %v", len(left), left)474 }475}476477// seedSetup gives the harness a setup to back up: the account, a478// Jellyfin, a YouTube, a folder and a video-only channel, and the weather479// channel.480func seedSetup(t *testing.T, h *harness) {481 t.Helper()482 h.withWeather()483 write(t, h.root, ".weather.json", `{"number": "1.1", "name": "Airwaves Weather", "callSign": "WX"}`)484 if code, _, raw := h.do("PUT", "/admin/api/account", map[string]string{"server": h.jf.URL, "user": "viewer", "password": "s3cret-pw"}); code != 200 {485 t.Fatal(raw)486 }487 if code, _, raw := h.do("PUT", "/admin/api/channels/new", map[string]any{"number": "1.4", "name": "Cartoons", "series": []string{"Futurama (1999)"}, "callSign": "TOON"}); code != 200 {488 t.Fatal(raw)489 }490 write(t, h.root, "1.8 Northernlion/youtube.json", `{"channels": ["https://www.youtube.com/@Northernlion", "https://www.youtube.com/@TheLibraryofLetourneau"]}`)491 write(t, h.root, "1.8 Northernlion/.playout.json", `{"basis": "backed up", "airings": []}`)492 write(t, h.root, "1.8 Northernlion/.catalog.json", `{"sources": []}`)493 write(t, h.root, "1.2 Cat Calming/channel.json", `{"callSign": "CATS", "category": "Pets"}`)494 write(t, h.root, "1.2 Cat Calming/birds.mp4", "video")495 write(t, h.root, "1.3 Fireplace/fire.mkv", "video")496}497498func TestRestore(t *testing.T) {499 h := backupHarness(t, time.Hour)500 seedSetup(t, h)501 root := h.root502 account := read(t, filepath.Join(root, accountFile))503 cartoons := read(t, filepath.Join(root, "1.4 Cartoons", channelFile))504 _, b, _ := h.do("POST", "/admin/api/backups", map[string]string{"label": "good"})505 file := b["file"].(string)506507 // Then things go wrong: a channel lost, one renamed, one's details508 // gone and a logo added, a video-only folder lost, the weather509 // channel moved, the account changed, and a channel made.510 os.RemoveAll(filepath.Join(root, "1.4 Cartoons"))511 if err := os.Rename(filepath.Join(root, "1.8 Northernlion"), filepath.Join(root, "1.9 NL")); err != nil {512 t.Fatal(err)513 }514 write(t, root, "1.9 NL/.playout.json", `{"basis": "mine", "airings": []}`)515 os.Remove(filepath.Join(root, "1.2 Cat Calming", "channel.json"))516 write(t, root, "1.2 Cat Calming/logo.png", string(pngLogo))517 os.RemoveAll(filepath.Join(root, "1.3 Fireplace"))518 write(t, root, ".weather.json", `{"number": "1.7", "name": "Weather", "callSign": "WTHR"}`)519 write(t, root, accountFile, `{"server": "http://127.0.0.1:1", "user": "someone", "password": "x"}`)520 write(t, root, "1.6 New/youtube.json", `{"channels": ["https://www.youtube.com/@Someone"]}`)521 h.lib.Rescan()522523 code, p, raw := h.restore(file, map[string]any{"preview": true})524 if code != 200 || !p.Preview || !p.Schedules || p.Errors != 0 || p.Before != "" {525 t.Fatalf("preview: %d %s", code, raw)526 }527 if strings.Contains(raw, "s3cret-pw") {528 t.Error("the preview gave the password away")529 }530 cat, fire, cart, nl, wx := item(t, p, "1.2 Cat Calming"), item(t, p, "1.3 Fireplace"), item(t, p, "1.4 Cartoons"), item(t, p, "1.8 Northernlion"), item(t, p, weatherKey)531 if cat.Action != "change" || !slices.Equal(cat.Changes, []string{"details", "logo"}) || cat.Match != "name" || cat.Now.Number != "1.2" {532 t.Errorf("cat: %+v", cat)533 }534 if fire.Action != "add" || !fire.Empty || fire.Kind != "folder" || fire.Now != nil {535 t.Errorf("fireplace: %+v", fire)536 }537 if cart.Action != "add" || cart.Kind != "jellyfin" || !slices.Equal(cart.Changes, []string{"settings", "details"}) {538 t.Errorf("cartoons: %+v", cart)539 }540 // Found by its YouTube channels, under its new name; its schedule is541 // running, so it stays.542 if nl.Action != "change" || nl.Match != "source" || nl.Now.Folder != "1.9 NL" || nl.Now.Number != "1.9" || !slices.Equal(nl.Changes, []string{"rename"}) {543 t.Errorf("northernlion: %+v %+v", nl, nl.Now)544 }545 if wx.Action != "change" || wx.Number != "1.1" || wx.Now.Number != "1.7" || !slices.Equal(wx.Changes, []string{"details"}) {546 t.Errorf("weather: %+v %+v", wx, wx.Now)547 }548 if len(p.Others) != 1 || p.Others[0].Folder != "1.6 New" {549 t.Errorf("others: %+v", p.Others)550 }551 if a := p.Account; !a.InBackup || a.Same || a.Restore || a.Server != h.jf.URL || a.User != "viewer" {552 t.Errorf("account: %+v", a)553 }554 if exists(filepath.Join(root, "1.4 Cartoons")) || len(h.backups()) != 1 {555 t.Error("the preview changed something")556 }557558 code, p, raw = h.restore(file, map[string]any{})559 if code != 200 || p.Preview || p.Errors != 0 || !strings.HasSuffix(p.Before, "-restore.tar.gz") {560 t.Fatalf("restore: %d %s", code, raw)561 }562 for _, x := range p.Items {563 if x.Done != (x.Action != "same") {564 t.Errorf("%s: %+v", x.Key, x)565 }566 }567 // Made again.568 if got := read(t, filepath.Join(root, "1.4 Cartoons", channelFile)); got != cartoons {569 t.Errorf("cartoons: %s", got)570 }571 if info, _ := os.Stat(filepath.Join(root, "1.4 Cartoons", channelFile)); info.Mode().Perm() != 0o600 {572 t.Errorf("cartoons' settings: %v", info.Mode())573 }574 if entries, err := os.ReadDir(filepath.Join(root, "1.3 Fireplace")); err != nil || len(entries) != 0 {575 t.Errorf("fireplace: %v %v", entries, err)576 }577 // Renamed back, with its catalog and its running schedule.578 if exists(filepath.Join(root, "1.9 NL")) || read(t, filepath.Join(root, "1.8 Northernlion", playoutFile)) != `{"basis": "mine", "airings": []}` ||579 !exists(filepath.Join(root, "1.8 Northernlion", ".catalog.json")) {580 t.Error("northernlion wasn't renamed back as it was")581 }582 // Details back, the logo added since gone, the videos still there.583 if record(t, filepath.Join(root, "1.2 Cat Calming", "channel.json"))["callSign"] != "CATS" || exists(filepath.Join(root, "1.2 Cat Calming", "logo.png")) ||584 read(t, filepath.Join(root, "1.2 Cat Calming", "birds.mp4")) != "video" {585 t.Error("cat calming wasn't put back as it was")586 }587 if record(t, filepath.Join(root, ".weather.json"))["number"] != "1.1" {588 t.Error("the weather channel wasn't put back")589 }590 // Left alone: the account (not asked for) and the new channel.591 if !strings.Contains(read(t, filepath.Join(root, accountFile)), "someone") || !exists(filepath.Join(root, "1.6 New", "youtube.json")) {592 t.Error("the restore touched what it shouldn't")593 }594 numbers := map[string]string{}595 for _, c := range h.do2State() {596 numbers[c["number"].(string)] = c["name"].(string)597 }598 if !maps.Equal(numbers, map[string]string{"1.1": "Airwaves Weather", "1.2": "Cat Calming", "1.3": "Fireplace", "1.4": "Cartoons", "1.6": "New", "1.8": "Northernlion"}) {599 t.Errorf("channels after: %v", numbers)600 }601602 // The setup before was backed up first, the account and all.603 _, pre, _ := h.restore(p.Before, map[string]any{"preview": true})604 if pre.Backup.Reason != "restore" || item(t, pre, "1.9 NL").Match != "source" || pre.Account.Server != "http://127.0.0.1:1" {605 t.Errorf("before: %+v", pre)606 }607608 // The account, on its own: no channels chosen.609 _, p, raw = h.restore(file, map[string]any{"channels": []string{}, "account": true})610 if !p.Account.Done || read(t, filepath.Join(root, accountFile)) != account {611 t.Errorf("account: %s", raw)612 }613 for _, x := range p.Items {614 if x.Chosen || x.Done {615 t.Errorf("%s restored, not chosen", x.Key)616 }617 }618 if info, _ := os.Stat(filepath.Join(root, accountFile)); info.Mode().Perm() != 0o600 {619 t.Errorf("account file: %v", info.Mode())620 }621622 // A YouTube channel made again gets its schedule back, unless asked623 // not to.624 h.settle("1.8")625 os.RemoveAll(filepath.Join(root, "1.8 Northernlion"))626 h.lib.Rescan()627 _, p, raw = h.restore(file, map[string]any{"channels": []string{"1.8 Northernlion"}, "schedules": false})628 if x := item(t, p, "1.8 Northernlion"); !x.Done || x.Action != "add" || exists(filepath.Join(root, "1.8 Northernlion", playoutFile)) {629 t.Errorf("without schedules: %s", raw)630 }631 h.settle("1.8")632 os.RemoveAll(filepath.Join(root, "1.8 Northernlion"))633 h.lib.Rescan()634 _, p, raw = h.restore(file, map[string]any{"channels": []string{"1.8 Northernlion"}})635 if x := item(t, p, "1.8 Northernlion"); !x.Done || !slices.Contains(x.Changes, "schedule") ||636 read(t, filepath.Join(root, "1.8 Northernlion", playoutFile)) != `{"basis": "backed up", "airings": []}` {637 t.Errorf("with schedules: %s", raw)638 }639640 // Restoring what's there already changes nothing, and backs nothing up.641 before := len(h.backups())642 _, p, _ = h.restore(file, map[string]any{"channels": []string{"1.2 Cat Calming"}})643 if x := item(t, p, "1.2 Cat Calming"); x.Action != "same" || x.Done || p.Before != "" || len(h.backups()) != before {644 t.Errorf("same: %+v %s", x, p.Before)645 }646}647648// do2State lists the channels in the state.649func (h *harness) do2State() []map[string]any {650 h.t.Helper()651 _, st, _ := h.do("GET", "/admin/api/state", nil)652 var out []map[string]any653 for _, c := range st["channels"].([]any) {654 out = append(out, c.(map[string]any))655 }656 return out657}658659func TestRestoreNumberTaken(t *testing.T) {660 h := backupHarness(t, time.Hour)661 wx := h.withWeather()662 write(t, h.root, ".weather.json", `{"number": "1.5", "name": "Weather"}`)663 write(t, h.root, "2.0 Foo/youtube.json", `{"channels": ["@Foo"]}`)664 write(t, h.root, "3.0 Kept/youtube.json", `{"channels": ["@Kept"]}`)665 _, b, _ := h.do("POST", "/admin/api/backups", nil)666 file := b["file"].(string)667668 // Foo is lost and Bar takes its number; the weather channel moves669 // and a folder takes its old number; Kept moves to 4.0, and Other to670 // its old number.671 os.RemoveAll(filepath.Join(h.root, "2.0 Foo"))672 write(t, h.root, "2.0 Bar/youtube.json", `{"channels": ["@Bar"]}`)673 write(t, h.root, ".weather.json", `{"number": "1.1", "name": "Weather"}`)674 write(t, h.root, "1.5 Kids/video.mp4", "video")675 os.Rename(filepath.Join(h.root, "3.0 Kept"), filepath.Join(h.root, "4.0 Kept"))676 write(t, h.root, "3.0 Other/video.mp4", "video")677 h.lib.Rescan()678 if wx.Number() != "1.1" {679 t.Fatalf("weather at %s", wx.Number())680 }681682 _, p, raw := h.restore(file, map[string]any{"preview": true})683 foo, w, kept := item(t, p, "2.0 Foo"), item(t, p, weatherKey), item(t, p, "3.0 Kept")684 if foo.Action != "skip" || foo.Reason != "2.0 is taken by 2.0 Bar now: give that channel another number first, or leave this one out" {685 t.Errorf("foo: %+v", foo)686 }687 if w.Action != "skip" || !strings.HasPrefix(w.Reason, "1.5 is taken by 1.5 Kids") {688 t.Errorf("weather: %+v", w)689 }690 if kept.Action != "skip" || kept.Match != "source" || !strings.HasPrefix(kept.Reason, "3.0 is taken by 3.0 Other") {691 t.Errorf("kept: %+v", kept)692 }693 _, p, raw = h.restore(file, map[string]any{})694 if p.Before != "" || exists(filepath.Join(h.root, "2.0 Foo")) || !exists(filepath.Join(h.root, "4.0 Kept")) {695 t.Errorf("restored what's taken: %s", raw)696 }697698 // Once the other channel moves, it's restored.699 os.Rename(filepath.Join(h.root, "3.0 Other"), filepath.Join(h.root, "3.1 Other"))700 h.lib.Rescan()701 _, p, raw = h.restore(file, map[string]any{"channels": []string{"3.0 Kept"}})702 if x := item(t, p, "3.0 Kept"); !x.Done || !exists(filepath.Join(h.root, "3.0 Kept", "youtube.json")) || exists(filepath.Join(h.root, "4.0 Kept")) {703 t.Errorf("kept: %s", raw)704 }705}706707func TestRestoreRefusesBadBackups(t *testing.T) {708 h := backupHarness(t, time.Hour)709 outside := filepath.Dir(h.root)710 zeros := strings.Repeat("\x00", 65<<20)711 many := make([]entry, 0, maxBackupFiles+1)712 for i := range maxBackupFiles + 1 {713 many = append(many, entry{name: "1.2 Cat/" + strings.Repeat("v", i%50) + ".mp4", body: "x"})714 }715 for name, c := range map[string]struct {716 data []byte717 want string718 }{719 "dot dot": {tarball(t, entry{name: "../evil.json", body: "{}"}), "outside the channels folder"},720 "deep dot": {tarball(t, entry{name: "1.2 Cat/../../evil.json", body: "{}"}), "outside the channels folder"},721 "absolute": {tarball(t, entry{name: "/etc/jellyfin.json", body: "{}"}), "outside the channels folder"},722 "backslash": {tarball(t, entry{name: `..\evil.json`, body: "{}"}), "outside the channels folder"},723 "symlink": {tarball(t, entry{name: "1.2 Cat/logo.png", typ: tar.TypeSymlink, link: "/etc/passwd"}), "link or device"},724 "hard link": {tarball(t, entry{name: "1.2 Cat/channel.json", typ: tar.TypeLink, link: "../../x"}), "link or device"},725 "device": {tarball(t, entry{name: "1.2 Cat/jellyfin.json", typ: tar.TypeChar}), "link or device"},726 "bad JSON": {tarball(t, entry{name: "1.2 Cat/jellyfin.json", body: "{nope"}), "1.2 Cat/jellyfin.json in the backup is damaged"},727 "not a logo": {tarball(t, entry{name: "1.2 Cat/logo.png", body: "#!/bin/sh"}), "isn't an image"},728 "newer": {tarball(t, entry{name: manifestFile, body: `{"version": 99}`}, entry{name: "1.2 Cat/", typ: tar.TypeDir}), "newer Airwaves"},729 "empty": {tarball(t, entry{name: "notes.txt", body: "hi"}), "no channels"},730 "too big": {tarball(t, entry{name: "1.2 Cat/video.mp4", body: zeros}), "unpacks to over 64 MB"},731 "many": {tarball(t, many...), "more than 10000 files"},732 "not gzipped": {[]byte("PK\x03\x04 a zip"), "isn't an Airwaves backup"},733 } {734 code, _, raw := h.send("POST", "/admin/api/backups/upload?name=x.tar.gz", "application/gzip", c.data)735 if code != 400 || !strings.Contains(raw, c.want) {736 t.Errorf("%s: %d %s", name, code, raw)737 }738 }739 if exists(filepath.Join(outside, "evil.json")) || exists(filepath.Join(h.root, "1.2 Cat")) || len(h.backups()) != 0 {740 t.Error("a bad backup left something behind")741 }742743 // The same dropped into the backups folder by hand can't be restored.744 write(t, h.admin.Backups, "evil.tar.gz", string(tarball(t, entry{name: "../evil.json", body: "{}"})))745 if code, _, raw := h.restore("evil.tar.gz", map[string]any{}); code != 422 || exists(filepath.Join(outside, "evil.json")) {746 t.Errorf("evil: %d %s", code, raw)747 }748}749750func TestRestoreOlderBackups(t *testing.T) {751 h := backupHarness(t, time.Hour)752 // As made by hand on a server before Airwaves made its own: the channels753 // folder's *.json but catalogs and yt-dlp's, with no manifest.754 legacy := tarball(t,755 entry{name: "./", typ: tar.TypeDir},756 entry{name: "./.jellyfin.json", body: `{"server": "http://127.0.0.1:1", "user": "viewer", "password": "pw"}`},757 entry{name: "./105.0 Gameshows/jellyfin.json", body: `{"series": ["Family Feud (1976)"]}`},758 entry{name: "./202.0 Boiler Room/.playout.json", body: `{"airings": []}`},759 entry{name: "./202.0 Boiler Room/youtube.json", body: `{"channels": ["https://www.youtube.com/@boilerroom"]}`},760 entry{name: "./1.2 Cat Calming/Birds [x].info.json", body: `{}`},761 entry{name: "./notes.json", body: `{}`},762 )763 code, res, raw := h.send("POST", "/admin/api/backups/upload?name=channels-20260101-1200.tar.gz", "application/gzip", legacy)764 if code != 201 {765 t.Fatalf("upload: %d %s", code, raw)766 }767 b := res["backup"].(map[string]any)768 created, _ := time.Parse(time.RFC3339, b["created"].(string))769 if b["reason"] != "uploaded" || b["label"] != "channels-20260101-1200" || !created.Equal(legacyTime) || b["auto"] != false || b["account"] != true ||770 b["schedules"] != true || len(b["folders"].([]any)) != 2 || b["file"] != "airwaves-20260101-120000-uploaded.tar.gz" {771 t.Errorf("uploaded: %s", raw)772 }773 if skipped := res["skipped"].([]any); len(skipped) != 2 || skipped[0] != "1.2 Cat Calming/Birds [x].info.json" {774 t.Errorf("skipped: %v", skipped)775 }776 // It's kept as an Airwaves backup, with a manifest.777 data, _ := os.ReadFile(filepath.Join(h.admin.Backups, b["file"].(string)))778 if got, order := untar(t, data); order[0] != manifestFile || got["202.0 Boiler Room/youtube.json"].body == "" {779 t.Errorf("kept as %q", order)780 }781 // Uploading it again doesn't make another.782 if code, res, _ := h.send("POST", "/admin/api/backups/upload?name=again.tar.gz", "application/gzip", legacy); code != 200 || res["existing"] != true || len(h.backups()) != 1 {783 t.Errorf("again: %d %v", code, res)784 }785786 _, p, raw := h.restore(b["file"].(string), map[string]any{"account": true})787 if p.Errors != 0 || item(t, p, "105.0 Gameshows").Action != "add" || !item(t, p, "202.0 Boiler Room").Done || !p.Account.Done {788 t.Fatalf("restore: %s", raw)789 }790 if read(t, filepath.Join(h.root, "202.0 Boiler Room", playoutFile)) != `{"airings": []}` ||791 !exists(filepath.Join(h.root, "105.0 Gameshows", channelFile)) || !strings.Contains(read(t, filepath.Join(h.root, accountFile)), "127.0.0.1:1") {792 t.Error("not restored")793 }794 if len(p.Others) != 1 || p.Others[0].Folder != "1.2 Cat Calming" || exists(filepath.Join(h.root, "notes.json")) {795 t.Errorf("others: %+v", p.Others)796 }797798 // One copied into the backups folder as it is is listed, and restores.799 write(t, h.admin.Backups, "channels-20260101-1200.tar.gz", string(legacy))800 list := h.backups()801 var older map[string]any802 for _, b := range list {803 if b["file"] == "channels-20260101-1200.tar.gz" {804 older = b805 }806 }807 if older == nil || older["older"] != true || older["reason"] != "manual" || older["auto"] != false {808 t.Fatalf("listed: %v", list)809 }810 if code, p, raw := h.restore("channels-20260101-1200.tar.gz", map[string]any{"preview": true}); code != 200 || item(t, p, "105.0 Gameshows").Action != "same" {811 t.Errorf("older: %d %s", code, raw)812 }813}814815func TestRestoreKindAndLogos(t *testing.T) {816 h := backupHarness(t, time.Hour)817 write(t, h.root, "5.0 Mixed/youtube.json", `{"channels": ["@Mixed"]}`)818 write(t, h.root, "5.0 Mixed/logo.svg", `<svg xmlns="http://www.w3.org/2000/svg"/>`)819 write(t, h.root, "5.0 Mixed/channel.json", `{"logo": "logo.svg"}`)820 _, b, _ := h.do("POST", "/admin/api/backups", nil)821 // It became a Jellyfin channel by hand, with another logo.822 os.Remove(filepath.Join(h.root, "5.0 Mixed", "youtube.json"))823 os.Remove(filepath.Join(h.root, "5.0 Mixed", "logo.svg"))824 write(t, h.root, "5.0 Mixed/jellyfin.json", `{"series": ["Futurama (1999)"]}`)825 write(t, h.root, "5.0 Mixed/logo.png", string(pngLogo))826 write(t, h.root, "5.0 Mixed/channel.json", `{"logo": "logo.png"}`)827 h.lib.Rescan()828 _, p, raw := h.restore(b["file"].(string), map[string]any{})829 x := item(t, p, "5.0 Mixed")830 if !x.Done || !slices.Equal(x.Changes, []string{"kind", "settings", "details", "logo"}) {831 t.Fatalf("%s", raw)832 }833 entries, _ := os.ReadDir(filepath.Join(h.root, "5.0 Mixed"))834 var names []string835 for _, e := range entries {836 names = append(names, e.Name())837 }838 if !slices.Equal(names, []string{"channel.json", "logo.svg", "youtube.json"}) {839 t.Errorf("files: %v", names)840 }841}842843func TestReadArchiveSkipsAndFolders(t *testing.T) {844 a, err := readArchive(bytes.NewReader(tarball(t,845 entry{name: manifestFile, body: `{"version": 1, "folders": [{"folder": "1.3 Fireplace", "kind": "folder"}, {"folder": "../x", "kind": "folder"}]}`},846 entry{name: "1.2 Cat/", typ: tar.TypeDir},847 entry{name: "1.2 Cat/sub/", typ: tar.TypeDir},848 entry{name: "1.2 Cat/sub/jellyfin.json", body: "{}"},849 entry{name: ".hidden/jellyfin.json", body: "{}"},850 entry{name: "1.2 Cat/.catalog.json", body: "{}"},851 )))852 if err != nil {853 t.Fatal(err)854 }855 if !slices.Equal(a.setup.folders, []string{"1.2 Cat", "1.3 Fireplace"}) || len(a.setup.files) != 0 ||856 !slices.Equal(a.skipped, []string{"1.2 Cat/sub/", "1.2 Cat/sub/jellyfin.json", ".hidden/jellyfin.json", "1.2 Cat/.catalog.json"}) {857 t.Errorf("folders %q, files %v, skipped %q", a.setup.folders, a.setup.files, a.skipped)858 }859 if _, err := readArchive(bytes.NewReader(nil)); !errors.Is(err, errNotBackup) {860 t.Errorf("nothing: %v", err)861 }862}863864func TestAgentBackups(t *testing.T) {865 h := backupHarness(t, time.Hour)866 s := h.agent()867 if l := list(ok(t, s, "list_backups", nil)["backups"]); len(l) != 0 {868 t.Errorf("none yet: %v", l)869 }870 got := ok(t, s, "create_backup", map[string]any{"label": "before the move"})871 b := got["backup"].(map[string]any)872 if b["reason"] != "manual" || b["label"] != "before the move" || b["kept"] != "until deleted" {873 t.Errorf("created: %v", got)874 }875 l := list(ok(t, s, "list_backups", nil)["backups"])876 if len(l) != 1 || list(l[0].(map[string]any)["channels"])[0] != "1.2 Cat Calming (folder)" {877 t.Errorf("listed: %v", l)878 }879 fails(t, s, "create_backup", map[string]any{"label": strings.Repeat("x", 61)}, "the label is up to 60 characters")880 fails(t, newHarness(t).agent(), "list_backups", nil, "backups are off")881}