Files
pipu-music/services/api/internal/storageagent/files_test.go
T
mrleo1nidandClaude Opus 5.5 f66be2ba0a
ci / api / api (push) Successful in 33s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 43s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m11s
ci / ts / ts (packages + web) (push) Successful in 1m10s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 51s
feat: storage writes via storage-agent, accepts_uploads and write_template (M5.5)
- storage-agent: POST /internal/files copies a staged file into a source
  (temp file, fsync, SHA-256 check, hard link so nothing is overwritten,
  " (2)" suffix on conflict, owner PIPU_FILE_UID/GID) and
  POST /internal/files/test. NFS sources that accept uploads are mounted
  rw in an agent-only /library-rw and bind-mounted read-only for others;
  local sources are written through /library-host-rw.
- meta: write templates ({track:02}, {disc:1-}, [optional]) with name
  sanitising and length limits, suggestion from the source's regexes
  and a reversibility check against 50 catalogue tracks.
- API and admin UI: accepts_uploads / write_template on sources,
  write-template suggest and check endpoints, write test result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:46:56 +03:00

132 lines
5.6 KiB
Go

package storageagent
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"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)
// 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)
entries, _ := os.ReadDir(filepath.Join(e.paths.LibraryRW, srcID, "A"))
assert.Empty(t, entries)
}
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)
assert.NoFileExists(t, filepath.Join(rw, ".pipu-write-test"))
// Без 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 }