package storageagent import ( "bytes" "crypto/sha256" "encoding/hex" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const srcID = "0191d3a4-7c1e-7a51-9a3b-2f6f5c3e9b10" type writeEnv struct { h http.Handler paths Paths mounter *fakeMounter } func newWriteEnv(t *testing.T) writeEnv { base := t.TempDir() p := Paths{ Library: filepath.Join(base, "library"), LibraryRW: filepath.Join(base, "library-rw"), HostRW: filepath.Join(base, "host"), Uploads: filepath.Join(base, "uploads"), FileUID: -1, FileGID: -1, } for _, d := range []string{p.Library, p.LibraryRW, p.HostRW, filepath.Join(p.LibraryRW, srcID), filepath.Join(p.Uploads, "u1"), filepath.Join(p.HostRW, "music")} { require.NoError(t, os.MkdirAll(d, 0o755)) } m := &fakeMounter{mounted: map[string]NFSSpec{}} return writeEnv{h: NewRouterWithPaths(nil, "secret", p, m), paths: p, mounter: m} } func (e writeEnv) post(path string, body any) *httptest.ResponseRecorder { raw, _ := json.Marshal(body) req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw)) req.Header.Set("Authorization", "Bearer secret") rec := httptest.NewRecorder() e.h.ServeHTTP(rec, req) return rec } func (e writeEnv) staging(t *testing.T, name, content string) string { require.NoError(t, os.WriteFile(filepath.Join(e.paths.Uploads, "u1", name), []byte(content), 0o644)) sum := sha256.Sum256([]byte(content)) return hex.EncodeToString(sum[:]) } func TestWriteFile(t *testing.T) { e := newWriteEnv(t) sum := e.staging(t, "f1", "audio-bytes") req := WriteRequest{SourceID: srcID, RelPath: "Кино/1988 - Группа крови/01 - Группа крови.flac", StagingPath: "u1/f1", SHA256: sum} rec := e.post("/internal/files", req) require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) var res WriteResult require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &res)) assert.Equal(t, WriteResult{RelPath: req.RelPath, Size: 11}, res) dir := filepath.Join(e.paths.LibraryRW, srcID, "Кино", "1988 - Группа крови") got, err := os.ReadFile(filepath.Join(dir, "01 - Группа крови.flac")) require.NoError(t, err) assert.Equal(t, "audio-bytes", string(got)) // Существующий файл не перезаписывается — новое имя с суффиксом; временных файлов не остаётся. rec = e.post("/internal/files", req) require.Equal(t, http.StatusOK, rec.Code) require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &res)) assert.Equal(t, "Кино/1988 - Группа крови/01 - Группа крови (2).flac", res.RelPath) assert.True(t, res.Renamed) entries, _ := os.ReadDir(dir) assert.Len(t, entries, 2) assertOnlyMusic(t, filepath.Join(e.paths.LibraryRW, srcID)) // Local-источник пишется в LIBRARY_HOST. local := "music" rec = e.post("/internal/files", WriteRequest{SourceID: srcID, LocalPath: &local, RelPath: "A/t.mp3", StagingPath: "u1/f1", SHA256: sum}) require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) assert.FileExists(t, filepath.Join(e.paths.HostRW, "music", "A", "t.mp3")) } func TestWriteFileRejects(t *testing.T) { e := newWriteEnv(t) sum := e.staging(t, "f1", "audio-bytes") cases := map[string]WriteRequest{ "traversal": {SourceID: srcID, RelPath: "../escape.flac", StagingPath: "u1/f1", SHA256: sum}, "absolute": {SourceID: srcID, RelPath: "/etc/x.flac", StagingPath: "u1/f1", SHA256: sum}, "hidden": {SourceID: srcID, RelPath: "A/.x.flac", StagingPath: "u1/f1", SHA256: sum}, "staging escape": {SourceID: srcID, RelPath: "A/x.flac", StagingPath: "../library/x", SHA256: sum}, "not writable": {SourceID: "0191d3a4-7c1e-7a51-9a3b-2f6f5c3e9b11", RelPath: "A/x.flac", StagingPath: "u1/f1", SHA256: sum}, "bad sha256": {SourceID: srcID, RelPath: "A/x.flac", StagingPath: "u1/f1", SHA256: "zz"}, "bad local path": {SourceID: srcID, LocalPath: ptr("../x"), RelPath: "A/x.flac", StagingPath: "u1/f1", SHA256: sum}, } for name, req := range cases { rec := e.post("/internal/files", req) assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, name) } // Копия не совпала с ожидаемым хэшем — ошибка, ни итогового, ни временного файла. other := sha256.Sum256([]byte("other")) rec := e.post("/internal/files", WriteRequest{SourceID: srcID, RelPath: "A/x.flac", StagingPath: "u1/f1", SHA256: hex.EncodeToString(other[:])}) assert.Equal(t, http.StatusInternalServerError, rec.Code) // Созданная под файл папка тоже убрана: на диске не остаётся следов неудачной записи. assert.NoDirExists(t, filepath.Join(e.paths.LibraryRW, srcID, "A")) assertOnlyMusic(t, filepath.Join(e.paths.LibraryRW, srcID)) } // assertOnlyMusic — в дереве хранилища только аудиофайлы и каталоги: ни временных, ни служебных файлов. func assertOnlyMusic(t *testing.T, root string) { t.Helper() require.NoError(t, filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { if err != nil || d.IsDir() { return err } assert.True(t, audioExt[strings.ToLower(filepath.Ext(p))] && !strings.HasPrefix(d.Name(), "."), "unexpected file %s", p) return nil })) } func TestWritableMount(t *testing.T) { e := newWriteEnv(t) rec := e.post("/internal/mounts", MountRequest{SourceID: srcID, NFSSpec: NFSSpec{Server: "nas", Export: "/music"}, Writable: true}) require.Equal(t, http.StatusOK, rec.Code) rw := filepath.Join(e.paths.LibraryRW, srcID) ro := filepath.Join(e.paths.Library, srcID) assert.Contains(t, e.mounter.mounted[rw].Options, "rw") assert.Equal(t, "bind:"+rw, e.mounter.mounted[ro].Export) rec = e.post("/internal/files/test", WriteTestRequest{SourceID: srcID}) var res WriteTestResult require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &res)) assert.True(t, res.OK, res.Message) entries, _ := os.ReadDir(rw) assert.Empty(t, entries, "write check creates nothing") // Без accepts_uploads rw-точка снимается. e.post("/internal/mounts", MountRequest{SourceID: srcID, NFSSpec: NFSSpec{Server: "nas", Export: "/music"}}) assert.NotContains(t, e.mounter.mounted, rw) assert.Contains(t, e.mounter.mounted[ro].Options, "ro") } func ptr(s string) *string { return &s }