Files
rond-with-you/internal/media/s3_list_test.go
T
tamakyi 770e752a7a 图片恢复支持对象存储(R2);专题也改成归人不归数据集
R2 上的图怎么救:库里的行没了就只剩桶里有那些键,不列桶无从得知它们叫什么。
给 internal/media 加了 ListObjects(ListObjectsV2 + SigV4 + 自动翻页 + XML 解析):
- 路径式访问、请求只到桶;查询串必须**先百分号编码再签名**(canonicalQuery 按原样取
  RawQuery,两边得是同一份字节,prefix 里的 / 要写成 %2F)。
- ⚠️ R2 的 API Token 默认只给对象读写,没有 s3:ListBucket 就 403。列桶失败**不让整个
  恢复失败**:本地那半照救,原因进 Warnings、页面用红色提示条显示(否则站长会以为恢复完了)。
- 候选对象 = 本地目录 ∪ 桶里 .webp(同键以本地为准);行的 storage 按「对象实际在哪」写,
  bytes 用桶里报的 Size。另加一条 warning:挂回了却没配 s3_public_base ⇒ 行有了页面空白,
  这比「没恢复」更难懂。

专题(用户拍板「一并做」):topics.dataset_id 同样带 CASCADE,删数据集带走专题,而专题
只存在这张表里、没有恢复来源。外键摘掉,所有查询 dataset_id → user_id(loadTopics、
topicPage、保存/删除/开关、pageBase.HasTopics);**内容仍照当前生效数据集算**
(topicContent 不动)——变的是「这份定义归谁」。Dataset 补 UserID 供前台按属主定位
用户级数据;管理专题不再要求有生效数据集。

导出链路未改动:place_images / topics / Dataset.UserID 都不参与 .rondbackup 导出,
全量验收(含导出保真、ZRAW 夹取、编号对齐那批)PASS=331 FAIL=0 SKIP=0。
2026-09-30 23:31:17 +08:00

135 lines
4.6 KiB
Go
Raw 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 media
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"rond-with-you/internal/config"
)
// ---------- 列桶(ListObjectsV2)----------
//
// 为什么要它:「图片挂载恢复」要找回**库里已经没有记录**的图片,而那些对象的键只剩下
// 桶里有。列不出来就只救得回本地磁盘那一半。
// 这几条用例守的是请求形状、翻页、以及「没权限时要说清楚该怎么办」。
func testStore(t *testing.T, endpoint string) *Store {
t.Helper()
return New(&config.Config{
S3Endpoint: endpoint, S3Region: "auto", S3Bucket: "rond",
S3AccessKey: "ak", S3SecretKey: "sk",
})
}
// listXML 拼一份最小的 ListObjectsV2 响应。
func listXML(keys []string, truncated bool, token string) string {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?><ListBucketResult><Name>rond</Name>`)
for _, k := range keys {
b.WriteString("<Contents><Key>" + k + "</Key><Size>1234</Size></Contents>")
}
fmt.Fprintf(&b, "<IsTruncated>%v</IsTruncated><KeyCount>%d</KeyCount>", truncated, len(keys))
if token != "" {
b.WriteString("<NextContinuationToken>" + token + "</NextContinuationToken>")
}
b.WriteString(`</ListBucketResult>`)
return b.String()
}
func TestListObjectsSignsAndParses(t *testing.T) {
var gotPath, gotQuery, gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotQuery, gotAuth = r.URL.Path, r.URL.RawQuery, r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, listXML([]string{"9/202609/309-abc.webp"}, false, ""))
}))
defer srv.Close()
s := testStore(t, srv.URL)
objs, err := s.ListObjects(context.Background(), "9/")
if err != nil {
t.Fatalf("列出对象失败: %v", err)
}
if len(objs) != 1 || objs[0].Key != "9/202609/309-abc.webp" || objs[0].Size != 1234 {
t.Fatalf("解析结果不对: %+v", objs)
}
// 路径式访问:只到桶,不带对象键
if gotPath != "/rond" {
t.Errorf("请求路径应为 /rond,实际 %q", gotPath)
}
// 查询串必须**先编码再签**:prefix 里的 / 要写成 %2F,签名与发送必须是同一份字节
if gotQuery != "list-type=2&prefix=9%2F" {
t.Errorf("查询串不对: %q", gotQuery)
}
if !strings.HasPrefix(gotAuth, "AWS4-HMAC-SHA256 Credential=ak/") || !strings.Contains(gotAuth, "/auto/s3/aws4_request") {
t.Errorf("签名头不对: %q", gotAuth)
}
}
func TestListObjectsFollowsContinuationToken(t *testing.T) {
var pages int
var seen []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
pages++
q := r.URL.Query()
seen = append(seen, q.Get("continuation-token"))
w.Header().Set("Content-Type", "application/xml")
if pages == 1 {
fmt.Fprint(w, listXML([]string{"9/a.webp"}, true, "tok-1"))
return
}
fmt.Fprint(w, listXML([]string{"9/b.webp"}, false, ""))
}))
defer srv.Close()
s := testStore(t, srv.URL)
objs, err := s.ListObjects(context.Background(), "9/")
if err != nil {
t.Fatalf("列出对象失败: %v", err)
}
if pages != 2 {
t.Errorf("应当翻两页,实际 %d", pages)
}
if len(objs) != 2 || objs[0].Key != "9/a.webp" || objs[1].Key != "9/b.webp" {
t.Errorf("两页结果没合并: %+v", objs)
}
// 第二页要把 token 带上,且同样要编码
if seen[0] != "" || seen[1] != "tok-1" {
t.Errorf("continuation-token 没带上(或顺序不对): %v", seen)
}
}
// TestListObjectsReportsPermission 没权限(403)时要把「需要 s3:ListBucket」说清楚——
// R2 的 API Token 默认只给对象读写,第一次点恢复多半就撞在这里。
func TestListObjectsReportsPermission(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
fmt.Fprint(w, `<Error><Code>AccessDenied</Code></Error>`)
}))
defer srv.Close()
s := testStore(t, srv.URL)
_, err := s.ListObjects(context.Background(), "")
if err == nil {
t.Fatal("403 应当报错")
}
if !strings.Contains(err.Error(), "403") || !strings.Contains(err.Error(), "s3:ListBucket") {
t.Errorf("错误里没说清该怎么办: %v", err)
}
if !strings.Contains(err.Error(), "AccessDenied") {
t.Errorf("错误里没带服务端的响应体(不好排查): %v", err)
}
}
// TestListObjectsRejectsUnconfigured 没配齐凭据时不该发出请求。
func TestListObjectsRejectsUnconfigured(t *testing.T) {
s := New(&config.Config{})
if _, err := s.ListObjects(context.Background(), ""); err == nil {
t.Fatal("没配齐应当报错")
}
}