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(`rond`) for _, k := range keys { b.WriteString("" + k + "1234") } fmt.Fprintf(&b, "%v%d", truncated, len(keys)) if token != "" { b.WriteString("" + token + "") } b.WriteString(``) 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, `AccessDenied`) })) 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("没配齐应当报错") } }