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) } }