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
- 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>
148 lines
6.4 KiB
Go
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 }
|