Files
AskaEth 1420e8aaa6
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): D3 审查读侧/revert 端点 + 语音用户可见面(L45)
(a) 记忆入库前审查的两条 admin 端点(主人判「有必要就做」):
  GET /api/v1/admin/memories/reviews?limit= (留痕读侧,不含记忆正文)
  POST /api/v1/admin/memories/{id}/revert (回滚软合并,落审计 memory.review.revert)
  两条 writeAuth(裁定 DECISIONS #10:留痕属私有面,不给 viewer)
(b) 语音用户可见面:DTO voice(含服务端派生 url)+ GET /api/v1/media/{name}
  (200/206/304/416/HEAD、Accept-Ranges、非法名/穿越统一 404 media_not_found)
  + documents.Store.SaveAudio(内容寻址、$DATA_DIR 0600)+ history voice 列读写与引用计数差动

缺口(如实):message_persisted/握手回放仍不带 voice(文件面外,需给 history.Message 加字段);
AMR 需一行 VoiceTranscoder 注入(DECISIONS #11 已排)。4 个变异自检(含一条自认的等价变异)。
2026-10-06 14:12:27 +08:00

244 lines
9.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}