Files
2026-09-03 08:38:17 +08:00

181 lines
5.8 KiB
Go

package main
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"encoding/base64"
"image"
"image/png"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/example/xingyu/internal/testusers"
cos "github.com/tencentyun/cos-go-sdk-v5"
)
func TestEncryptedConfigurationCompatibility(t *testing.T) {
seal := func(key string) string {
digest := sha256.Sum256([]byte(key))
block, _ := aes.NewCipher(digest[:])
gcm, _ := cipher.NewGCM(block)
nonce := make([]byte, gcm.NonceSize())
return "enc:v1:" + base64.RawStdEncoding.EncodeToString(gcm.Seal(nonce, nonce, []byte("test-only-value"), nil))
}
for _, encoded := range []string{seal("config-key"), seal("jwt-key:integration-config")} {
plain, err := decrypt(encoded, "config-key", "jwt-key")
if err != nil || plain != "test-only-value" {
t.Fatal("decryption compatibility failed")
}
}
if _, err := decrypt(seal("unrelated-key"), "config-key", "jwt-key"); err == nil {
t.Fatal("accepted wrong key")
}
if _, err := decrypt("enc:v1:bad!", "config-key", "jwt-key"); err == nil {
t.Fatal("accepted malformed ciphertext")
}
}
func TestAssetsAreScopedAndContentAddressed(t *testing.T) {
dir := t.TempDir()
var buf bytes.Buffer
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 256, 256))); err != nil {
t.Fatal(err)
}
for _, name := range testusers.Avatars() {
if err := os.WriteFile(filepath.Join(dir, name), buf.Bytes(), 0600); err != nil {
t.Fatal(err)
}
}
assets, err := loadAssets(dir, "media", "https://example.cos.ap-guangzhou.myqcloud.com/")
if err != nil || len(assets) != 10 {
t.Fatalf("count=%d err=%v", len(assets), err)
}
seen := map[string]bool{}
for _, a := range assets {
if seen[a.Key] || !strings.HasPrefix(a.Key, "media/test-users/"+testusers.Batch+"/") || !strings.Contains(a.Key, a.SHA256[:20]) {
t.Fatal("unscoped or nonunique object key")
}
if !strings.HasSuffix(a.ThumbnailKey, "-thumb.jpg") || !strings.HasSuffix(a.ThumbnailURL, "-thumb.jpg") || len(a.ThumbnailData) == 0 {
t.Fatal("missing deterministic thumbnail object")
}
config, format, decodeErr := image.DecodeConfig(bytes.NewReader(a.ThumbnailData))
if decodeErr != nil || format != "jpeg" || config.Width > 256 || config.Height > 256 {
t.Fatal("invalid generated thumbnail", decodeErr)
}
seen[a.Key] = true
}
for _, prefix := range []string{"../existing", "/root", "media//test", "media/../test"} {
if _, err := loadAssets(dir, prefix, "https://example.com"); err == nil {
t.Errorf("accepted prefix %q", prefix)
}
}
for _, raw := range []string{"http://example.com", "https://user:pass@example.com", "https://example.com?signature=temporary", "/uploads"} {
if _, err := validHTTPS(raw); err == nil {
t.Errorf("accepted unsafe base %q", raw)
}
}
}
func TestUploadIsVerifiedAndRepeatableWithoutOverwriting(t *testing.T) {
data := []byte("test-png-content")
var stored []byte
puts, aclWrites := 0, 0
publicReadable := true
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/batch/image.png" {
t.Errorf("unexpected path %s", r.URL.Path)
w.WriteHeader(404)
return
}
if r.Method == "PUT" && r.URL.Query().Has("acl") {
aclWrites++
publicReadable = true
w.WriteHeader(200)
return
}
if r.Method == "PUT" {
puts++
if r.Header.Get("x-cos-forbid-overwrite") != "true" {
t.Error("missing overwrite protection")
}
stored, _ = io.ReadAll(r.Body)
w.WriteHeader(200)
return
}
if stored == nil {
w.WriteHeader(404)
_, _ = io.WriteString(w, "<Error><Code>NoSuchKey</Code></Error>")
return
}
if r.Header.Get("Authorization") == "" && !publicReadable {
w.WriteHeader(403)
return
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(stored)
}))
defer server.Close()
u, _ := url.Parse(server.URL)
c := cos.NewClient(&cos.BaseURL{BucketURL: u}, &http.Client{Transport: &cos.AuthorizationTransport{SecretID: "test-id", SecretKey: "test-key"}})
c.Conf.EnableCRC = false
makeAsset := func() *asset {
return &asset{File: "test.png", Key: "batch/image.png", URL: server.URL + "/batch/image.png", Data: data}
}
a := makeAsset()
if err := upload(context.Background(), c, server.Client(), a); err != nil {
t.Fatal(err)
}
if puts != 1 || !a.Uploaded || aclWrites != 0 {
t.Fatal("unexpected new upload state")
}
if err := upload(context.Background(), c, server.Client(), makeAsset()); err != nil {
t.Fatal(err)
}
if puts != 1 {
t.Fatal("repeat upload wrote the object again")
}
stored = []byte("unrelated-object")
if err := upload(context.Background(), c, server.Client(), makeAsset()); err == nil || puts != 1 {
t.Fatal("overwrote a conflicting object")
}
stored = nil
publicReadable = false
a = makeAsset()
if err := upload(context.Background(), c, server.Client(), a); err != nil {
t.Fatal(err)
}
if aclWrites != 1 || !a.PublicReadSet {
t.Fatal("new public avatar ACL was not scoped to the object")
}
}
func TestPublicCheckRejectsUnreachableOrChangedImage(t *testing.T) {
status := http.StatusForbidden
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "" || r.URL.RawQuery != "" {
t.Error("public check must be anonymous and unsigned")
}
w.Header().Set("Content-Type", "image/png")
w.WriteHeader(status)
_, _ = w.Write([]byte("different"))
}))
defer server.Close()
a := &asset{URL: server.URL, Data: []byte("original")}
if code, err := publicCheck(context.Background(), server.Client(), a); err == nil || code != 403 {
t.Fatal("accepted inaccessible image")
}
status = http.StatusOK
if _, err := publicCheck(context.Background(), server.Client(), a); err == nil {
t.Fatal("accepted different image content")
}
}