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

171 lines
6.3 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 documents
// 本文件锁定**音频副本落盘分支**(design/24 §10.9-2 的 `SaveAudio`)的四条口径:
// ① 内容寻址 + 去重 + 0600 落盘(与文档共用同一套写入核心);
// ② 类型裁决走**音频白名单**(10 项),与文档的 23 项白名单**互不越界**;
// ③ 空内容 / 超上限 / 未知扩展名都如实拒绝(不落半截文件、不写元数据行);
// ④ 元数据行照写(uploader 只来自构造方,绝不从文件名或内容猜身份)。
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"runtime"
"testing"
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
)
// audioMetaRecorder 记录交给元数据面的行。它按 MetadataWriter 的契约模拟
// `ON CONFLICT(name) DO NOTHING`(首次的 uploader/created_at 是权威),因此
// 「重复落盘只留下一行」在本用例里是**契约级**断言,而不是替身的记账细节。
type audioMetaRecorder struct {
seen map[string]bool
recs []Record
}
func (m *audioMetaRecorder) WriteDocument(_ context.Context, rec Record) error {
if m.seen == nil {
m.seen = make(map[string]bool)
}
if m.seen[rec.Name] {
return nil
}
m.seen[rec.Name] = true
m.recs = append(m.recs, rec)
return nil
}
// audioSample 是一份「像那么回事」的可播音频字节(RIFF/WAVE 头 + 载荷)。
func audioSample() []byte {
return append([]byte("RIFF\x24\x00\x00\x00WAVEfmt "), []byte("voice-copy-payload")...)
}
func TestSaveAudioStoresContentAddressedCopy(t *testing.T) {
t.Parallel()
dir := filepath.Join(t.TempDir(), uploadsSubDir, documentSubDir)
meta := &audioMetaRecorder{}
store := newTestStore(t, StoreConfig{UploadsDir: dir, Uploader: "im:qq-obv11:10001", Meta: meta})
ctx := context.Background()
data := audioSample()
ref, err := store.SaveAudio(ctx, "voice.wav", data)
if err != nil {
t.Fatalf("SaveAudio: %v", err)
}
wantName, wantSHA := expectedName(t, ".wav", data)
if ref.Name != wantName {
t.Fatalf("Name = %q, want %q(内容寻址)", ref.Name, wantName)
}
if ref.SHA256 != wantSHA {
t.Fatalf("SHA256 = %q, want %q", ref.SHA256, wantSHA)
}
if ref.MIME != "audio/wav" {
t.Fatalf("MIME = %q, want audio/wav(按扩展名裁决,与回放端点同一份表)", ref.MIME)
}
if ref.Size != int64(len(data)) || ref.Deduped {
t.Fatalf("Size/Deduped = %d/%v, want %d/false", ref.Size, ref.Deduped, len(data))
}
if ref.Path == "" {
t.Fatal("Path 为空:落盘路径没有回给调用方")
}
// 写进共享目录(与文档同一个目录),绝不是另一个地方。
if filepath.Dir(ref.Path) != filepath.Clean(dir) {
t.Fatalf("落盘目录 = %q, want %q", filepath.Dir(ref.Path), filepath.Clean(dir))
}
got, err := os.ReadFile(ref.Path)
if err != nil {
t.Fatalf("读回副本: %v", err)
}
if !bytes.Equal(got, data) {
t.Fatal("落盘字节与输入不一致")
}
if runtime.GOOS != "windows" {
info, err := os.Stat(ref.Path)
if err != nil {
t.Fatalf("Stat 副本: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Fatalf("副本权限 = %o, want 600(继承临时文件)", perm)
}
}
if len(meta.recs) != 1 {
t.Fatalf("元数据行数 = %d, want 1", len(meta.recs))
}
if rec := meta.recs[0]; rec.Uploader != "im:qq-obv11:10001" || rec.MIME != "audio/wav" || rec.Size != int64(len(data)) {
t.Fatalf("元数据行 = %+v", rec)
}
// 同字节同扩展名第二次落盘 ⇒ 去重(不新建文件、不写第二行元数据)。
again, err := store.SaveAudio(ctx, "voice.wav", data)
if err != nil {
t.Fatalf("重复 SaveAudio: %v", err)
}
if !again.Deduped || again.Name != ref.Name {
t.Fatalf("重复落盘 = {name:%q deduped:%v}, want {name:%q deduped:true}", again.Name, again.Deduped, ref.Name)
}
if len(meta.recs) != 1 {
t.Fatalf("去重后元数据行数 = %d, want 1", len(meta.recs))
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("读目录: %v", err)
}
if len(entries) != 1 {
t.Fatalf("目录里文件数 = %d, want 1(盘上只留一份)", len(entries))
}
}
func TestSaveAudioRejectsBadInputs(t *testing.T) {
t.Parallel()
ctx := context.Background()
data := audioSample()
store := newTestStore(t, StoreConfig{UploadsDir: filepath.Join(t.TempDir(), "docs")})
for _, name := range []string{"voice.txt", "voice.pdf", "voice", "voice.silk", "voice.mp4"} {
if _, err := store.SaveAudio(ctx, name, data); !errors.Is(err, ErrExtNotAllowed) {
t.Fatalf("SaveAudio(%q) err = %v, want ErrExtNotAllowed", name, err)
}
}
if _, err := store.SaveAudio(ctx, "voice.wav", nil); !errors.Is(err, ErrEmpty) {
t.Fatalf("空内容 err = %v, want ErrEmpty", err)
}
small := newTestStore(t, StoreConfig{UploadsDir: filepath.Join(t.TempDir(), "docs"), MaxBytes: 4})
if _, err := small.SaveAudio(ctx, "voice.wav", data); !errors.Is(err, ErrTooLarge) {
t.Fatalf("超上限 err = %v, want ErrTooLarge", err)
}
// 未配置目录 ⇒ 明确失败(绝不猜一个目录出来)。
noDir := NewStore(StoreConfig{Log: nil})
if _, err := noDir.SaveAudio(ctx, "voice.wav", data); !errors.Is(err, ErrNoDir) {
t.Fatalf("未配置目录 err = %v, want ErrNoDir", err)
}
}
// TestSaveAudioUsesAudioWhitelistNotDocumentWhitelist 钉住「两套类型裁决」:音频白名单
// 与文档白名单互不越界 —— 文档拒绝的音频扩展名在音频分支放行,音频分支拒绝的文档
// 扩展名在文档白名单里放行。变异:把 SaveAudio 改走 ExtAllowed ⇒ 本用例红。
func TestSaveAudioUsesAudioWhitelistNotDocumentWhitelist(t *testing.T) {
t.Parallel()
ctx := context.Background()
data := audioSample()
store := newTestStore(t, StoreConfig{UploadsDir: filepath.Join(t.TempDir(), "docs")})
if ExtAllowed(".wav") {
t.Fatal("文档白名单不该包含 .wav")
}
if voiceref.MediaExtAllowed(".txt") {
t.Fatal("音频白名单不该包含 .txt")
}
if _, err := store.Save(ctx, "doc.wav", data); !errors.Is(err, ErrExtNotAllowed) {
t.Fatalf("文档分支收 .wav 的 err = %v, want ErrExtNotAllowed", err)
}
if _, err := store.SaveAudio(ctx, "doc.txt", []byte("昔涟的文本\n")); !errors.Is(err, ErrExtNotAllowed) {
t.Fatalf("音频分支收 .txt 的 err = %v, want ErrExtNotAllowed", err)
}
if _, err := store.SaveAudio(ctx, "voice.ogg", data); err != nil {
t.Fatalf("音频分支拒了白名单内的 .ogg: %v", err)
}
}