Files
pipu-music/services/api/internal/storageagent/files_test.go
T
mrleo1nidandClaude Opus 5.5 85fceaa686
ci / api / api (push) Successful in 50s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Failing after 10s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Failing after 31m43s
ci / db / db (push) Failing after 19s
fix(storage-agent): write nothing but the music files to the mounted storage
- Imported tracks are created directly under their final name with
  O_EXCL (never overwriting; " (2)" on conflict), fsync'ed and checked
  against SHA-256 — no hidden .pipu-tmp file next to them any more.
- On any failure the agent removes its own partial file and the empty
  directories it created for it.
- The write check uses access(W_OK) and the read-only mount flag instead
  of creating and deleting .pipu-write-test in the storage root.
- Tests assert the storage tree holds only audio files after successful
  and failed writes and after the write check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:22:49 +03:00

148 lines
6.4 KiB
Go

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 }