package gateway // 本文件锁定**语音副本回放端点** `GET /api/v1/media/{name}`(design/24 §10.11;契约 // v0.67 登记、本片落地)的六条口径: // ① 200 + `audio/*` + 内联 + `nosniff` + `Accept-Ranges`(**不设** ETag); // ② `Range` ⇒ 206 + `Content-Range`;`If-Modified-Since` ⇒ 304;`HEAD` ⇒ 200 无正文 // —— 三者都由标准库的 `ServeContent` 给出; // ③ 不可满足的 `Range` ⇒ 416,且**不是** JSON 信封(标准库直写 `text/plain`); // ④ 名字形态非法 / 路径穿越 / 文件不存在 ⇒ **同一个** 404 `media_not_found`; // ⑤ 门与文档读侧同档(无凭据 401 / viewer 403 / owner|admin 200); // ⑥ 主名宽度与 documents 包的内容寻址常量同值(32 位小写十六进制)。 import ( "bytes" "crypto/sha256" "encoding/hex" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strconv" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents" voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice" ) // mediaFixture 是一份已落盘的语音副本 + 一个只带 DataDir 的网关。 type mediaFixture struct { ts *httptest.Server jwt *auth.JWTService name string body []byte dir string } // newMediaFixture 在 `$DATA_DIR/uploads/documents` 下写一份内容寻址命名的音频副本 // (与 `documents.SaveAudio` 的落盘口径逐字一致:sha256[:32] + ext、0600)。 func newMediaFixture(t *testing.T, body []byte, ext string) *mediaFixture { t.Helper() dataDir := t.TempDir() dir := filepath.Join(dataDir, "uploads", documentSubDir) if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatalf("建副本目录: %v", err) } sum := sha256.Sum256(body) name := hex.EncodeToString(sum[:])[:documents.HashHexLen] + ext if err := os.WriteFile(filepath.Join(dir, name), body, 0o600); err != nil { t.Fatalf("写副本: %v", err) } _, ts, jwt := buildEnv(t, cfgDefault(), Deps{DataDir: dataDir}) return &mediaFixture{ts: ts, jwt: jwt, name: name, body: body, dir: dir} } // doMediaReq 发一次可带额外请求头的媒体请求(doReq 不支持自定义头)。 func doMediaReq(t *testing.T, f *mediaFixture, method, path string, headers map[string]string, token string) (*http.Response, []byte) { t.Helper() req, err := http.NewRequest(method, f.ts.URL+path, nil) if err != nil { t.Fatalf("new request: %v", err) } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } for k, v := range headers { req.Header.Set(k, v) } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("do request: %v", err) } defer resp.Body.Close() buf := new(bytes.Buffer) if _, err := buf.ReadFrom(resp.Body); err != nil { t.Fatalf("read body: %v", err) } return resp, buf.Bytes() } func TestMediaEndpointServesInlineAudio(t *testing.T) { t.Parallel() body := []byte("RIFF\x24\x00\x00\x00WAVEfmt voice-bytes") f := newMediaFixture(t, body, ".ogg") tok := signAny(t, f.jwt, "acct", auth.RoleOwner) resp, got := doMediaReq(t, f, http.MethodGet, voiceref.MediaPathPrefix+f.name, nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, got) } if ct := resp.Header.Get("Content-Type"); ct != "audio/ogg" { t.Fatalf("Content-Type = %q, want audio/ogg(按扩展名裁决,不嗅探)", ct) } if cd := resp.Header.Get("Content-Disposition"); cd != `inline; filename="`+f.name+`"` { t.Fatalf("Content-Disposition = %q, want inline; filename=%q", cd, f.name) } if resp.Header.Get("X-Content-Type-Options") != "nosniff" { t.Fatalf("缺 nosniff: %v", resp.Header) } if resp.Header.Get("Accept-Ranges") != "bytes" { t.Fatalf("Accept-Ranges = %q, want bytes", resp.Header.Get("Accept-Ranges")) } if cc := resp.Header.Get("Cache-Control"); !strings.Contains(cc, "private") || !strings.Contains(cc, "immutable") { t.Fatalf("Cache-Control = %q, want private + immutable", cc) } if resp.Header.Get("ETag") != "" { t.Fatalf("不该自造 ETag,得到 %q", resp.Header.Get("ETag")) } if !bytes.Equal(got, body) { t.Fatalf("正文 = %q, want %q", got, body) } } func TestMediaEndpointRangeConditionalAndHead(t *testing.T) { t.Parallel() body := []byte("0123456789abcdef") f := newMediaFixture(t, body, ".wav") tok := signAny(t, f.jwt, "acct", auth.RoleOwner) path := voiceref.MediaPathPrefix + f.name size := strconv.Itoa(len(body)) // Range ⇒ 206 部分内容。 resp, got := doMediaReq(t, f, http.MethodGet, path, map[string]string{"Range": "bytes=0-3"}, tok) if resp.StatusCode != http.StatusPartialContent { t.Fatalf("Range status = %d, want 206 (body=%s)", resp.StatusCode, got) } if cr := resp.Header.Get("Content-Range"); cr != "bytes 0-3/"+size { t.Fatalf("Content-Range = %q, want bytes 0-3/%s", cr, size) } if !bytes.Equal(got, body[:4]) { t.Fatalf("Range 正文 = %q, want %q", got, body[:4]) } // If-Modified-Since(不早于 mtime)⇒ 304,无正文。 lastMod := resp.Header.Get("Last-Modified") if lastMod == "" { t.Fatal("缺 Last-Modified:条件请求无从判定") } resp, got = doMediaReq(t, f, http.MethodGet, path, map[string]string{"If-Modified-Since": lastMod}, tok) if resp.StatusCode != http.StatusNotModified { t.Fatalf("条件请求 status = %d, want 304 (body=%s)", resp.StatusCode, got) } if len(got) != 0 { t.Fatalf("304 不该带正文:%q", got) } // HEAD ⇒ 200 + 空正文 + 正确长度(GET 模式同时匹配 HEAD)。 resp, got = doMediaReq(t, f, http.MethodHead, path, nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("HEAD status = %d, want 200", resp.StatusCode) } if len(got) != 0 { t.Fatalf("HEAD 不该带正文:%q", got) } if cl := resp.Header.Get("Content-Length"); cl != size { t.Fatalf("HEAD Content-Length = %q, want %s", cl, size) } // 不可满足的 Range ⇒ 416,且**不是** JSON 信封(契约 v0.67 明写)。 resp, got = doMediaReq(t, f, http.MethodGet, path, map[string]string{"Range": "bytes=9999-10000"}, tok) if resp.StatusCode != http.StatusRequestedRangeNotSatisfiable { t.Fatalf("越界 Range status = %d, want 416 (body=%s)", resp.StatusCode, got) } if cr := resp.Header.Get("Content-Range"); cr != "bytes */"+size { t.Fatalf("416 Content-Range = %q, want bytes */%s", cr, size) } if len(got) > 0 && got[0] == '{' { t.Fatalf("416 不该是 JSON 信封:%q", got) } } func TestMediaEndpointNotFoundIsUniform(t *testing.T) { t.Parallel() f := newMediaFixture(t, []byte("audio-bytes"), ".mp3") tok := signAny(t, f.jwt, "acct", auth.RoleOwner) missing := hex.EncodeToString(bytes.Repeat([]byte{0xab}, 32)) + ".mp3" cases := []struct { desc string path string }{ {"名字形态非法(没有扩展名)", voiceref.MediaPathPrefix + "0123456789abcdef0123456789abcdef"}, {"扩展名不在音频白名单", voiceref.MediaPathPrefix + "0123456789abcdef0123456789abcdef.txt"}, {"主名不是小写十六进制", voiceref.MediaPathPrefix + "0123456789ABCDEF0123456789abcdef.mp3"}, {"路径穿越", "/api/v1/media/../cyrene.db"}, {"穿越到子目录", "/api/v1/media/%2e%2e/cyrene.db"}, {"文件不存在", voiceref.MediaPathPrefix + missing}, } for _, tc := range cases { resp, body := doMediaReq(t, f, http.MethodGet, tc.path, nil, tok) if resp.StatusCode != http.StatusNotFound { t.Fatalf("%s: status = %d, want 404 (body=%s)", tc.desc, resp.StatusCode, body) } if code := mediaErrorCode(t, body); code != mediaNotFoundCode { t.Fatalf("%s: code = %q, want %q (body=%s)", tc.desc, code, mediaNotFoundCode, body) } } } func TestMediaEndpointRequiresOwnerOrAdmin(t *testing.T) { t.Parallel() body := []byte("audio-bytes") f := newMediaFixture(t, body, ".flac") path := voiceref.MediaPathPrefix + f.name if resp, _ := doMediaReq(t, f, http.MethodGet, path, nil, ""); resp.StatusCode != http.StatusUnauthorized { t.Fatalf("匿名 status = %d, want 401", resp.StatusCode) } viewer := signAny(t, f.jwt, "acct", auth.RoleViewer) if resp, _ := doMediaReq(t, f, http.MethodGet, path, nil, viewer); resp.StatusCode != http.StatusForbidden { t.Fatalf("viewer status = %d, want 403", resp.StatusCode) } admin := signAny(t, f.jwt, "acct", auth.RoleAdmin) if resp, got := doMediaReq(t, f, http.MethodGet, path, nil, admin); resp.StatusCode != http.StatusOK { t.Fatalf("admin status = %d, want 200 (body=%s)", resp.StatusCode, got) } } // TestMediaStoredNameHexLenMatchesDocuments 钉住「内容寻址主名宽度」两处同值: // voice 包不 import documents(会成环),因此这条交叉断言就是它们的连接点。 func TestMediaStoredNameHexLenMatchesDocuments(t *testing.T) { t.Parallel() if voiceref.StoredNameHexLen != documents.HashHexLen { t.Fatalf("voice.StoredNameHexLen = %d, documents.HashHexLen = %d, want 同值", voiceref.StoredNameHexLen, documents.HashHexLen) } } // mediaErrorCode 从 `{error:{code}}` 信封里取出错误码(解析失败即终止用例)。 func mediaErrorCode(t *testing.T, body []byte) string { t.Helper() var env struct { Error struct { Code string `json:"code"` } `json:"error"` } if err := json.Unmarshal(body, &env); err != nil { t.Fatalf("解析错误信封失败: %v (body=%s)", err, body) } return env.Error.Code }