ci / vet-and-test (pull_request) Successful in 46s
Why: plan U5 remainder (KTD5/KTD9 + exit criteria) - commands must commit atomically with business writes, artifacts need a shared object store for multi-instance, and the two-real-process acceptance matrix must run in CI. Evidence: full suite green on SQLite and real PostgreSQL 18.6 (35 packages each tier) - outbox tx test (rollback removes the command, commit makes both visible, idempotent re-enqueue), S3 store tests (SigV4-signed round trip, 404 sentinel, idempotent delete, presigned GET, env fallback), real-process acceptance (testbinary self-spawn of two serve processes with per-process identity and command consumers against one schema: same-conversation contention, cross-instance cancel command claimed exactly once, stale-owner settle fenced with run unchanged). Impact: EnqueueCommandInTx for in-transaction command outbox and AppendEvents now notifies in the same transaction (KTD6 front half); blobstore.S3 (SigV4, presign, prefix isolation) injected from BLOBSTORE_S3_* env when configured, local fallback otherwise; realproc_test provides the process-level multi-instance acceptance gate (PG-env gated, runs in CI test-pg job). Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U5 KTD5/KTD9, section 5 exit criteria; R4/R7/R10.
122 lines
3.3 KiB
Go
122 lines
3.3 KiB
Go
package blobstore
|
||
|
||
import (
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// U5/KTD9:S3 兼容对象存储实现——针对嵌入式测试服务器验证
|
||
// 签名请求、Put/Get/Delete 语义与预签名 URL 生成。
|
||
// (真实 S3 兼容服务的端到端验收随 U7 压测环境执行;此处锁定协议正确性。)
|
||
|
||
func newTestS3(t *testing.T, base string) *S3 {
|
||
t.Helper()
|
||
s, err := NewS3(base, "us-east-1", "AKIDEXAMPLE", "secret", "art/")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return s
|
||
}
|
||
|
||
func TestS3PutGetDeleteRoundTrip(t *testing.T) {
|
||
var gotPath, gotMethod, gotAuth string
|
||
var gotBody []byte
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
gotPath, gotMethod, gotAuth = r.URL.Path, r.Method, r.Header.Get("Authorization")
|
||
buf := make([]byte, 4096)
|
||
n, _ := r.Body.Read(buf)
|
||
gotBody = buf[:n]
|
||
switch r.Method {
|
||
case http.MethodPut:
|
||
w.WriteHeader(200)
|
||
case http.MethodGet:
|
||
if strings.Contains(gotPath, "obj404") {
|
||
w.WriteHeader(404)
|
||
return
|
||
}
|
||
w.Write([]byte("payload"))
|
||
case http.MethodDelete:
|
||
w.WriteHeader(204)
|
||
}
|
||
}))
|
||
defer srv.Close()
|
||
s := newTestS3(t, srv.URL+"/bucket")
|
||
|
||
ref, err := s.Put("doc1", []byte("payload"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if ref != "art/doc1" {
|
||
t.Fatalf("ref=%s(对象键应带前缀)", ref)
|
||
}
|
||
if gotPath != "/bucket/art/doc1" || gotMethod != http.MethodPut || string(gotBody) != "payload" {
|
||
t.Fatalf("put 请求:%s %s body=%q", gotMethod, gotPath, gotBody)
|
||
}
|
||
if len(gotAuth) == 0 || gotAuth[:17] != "AWS4-HMAC-SHA256 " {
|
||
t.Fatalf("Authorization 头:%q", gotAuth)
|
||
}
|
||
got, err := s.Get(ref)
|
||
if err != nil || string(got) != "payload" {
|
||
t.Fatalf("get: %q err=%v", got, err)
|
||
}
|
||
if err := s.Delete(ref); err != nil {
|
||
t.Fatalf("delete: %v", err)
|
||
}
|
||
// 404 → 哨兵错误(服务端对 art/obj404 的 GET 返回 404)。
|
||
if _, err := s.Get("art/obj404"); err == nil || err.Error() != ErrObjectNotFound.Error() {
|
||
t.Fatalf("404 应 ErrObjectNotFound:%v", err)
|
||
}
|
||
// Delete 幂等(404 视为成功)。
|
||
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.WriteHeader(404)
|
||
}))
|
||
defer srv2.Close()
|
||
s2 := newTestS3(t, srv2.URL+"/b")
|
||
if err := s2.Delete("art/none"); err != nil {
|
||
t.Fatalf("delete 404 应幂等:%v", err)
|
||
}
|
||
}
|
||
|
||
func TestS3PresignGet(t *testing.T) {
|
||
s := newTestS3(t, "https://s3.example.com/bucket")
|
||
u, err := s.PresignGet("art/doc1", 15*time.Minute)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, want := range []string{"X-Amz-Algorithm=AWS4-HMAC-SHA256", "X-Amz-Signature=", "X-Amz-Expires=900", "/bucket/art/doc1"} {
|
||
if !contains(u, want) {
|
||
t.Fatalf("预签名 URL 缺 %s:%s", want, u)
|
||
}
|
||
}
|
||
}
|
||
|
||
func contains(s, sub string) bool {
|
||
return len(s) >= len(sub) && (func() bool {
|
||
for i := 0; i+len(sub) <= len(s); i++ {
|
||
if s[i:i+len(sub)] == sub {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
})()
|
||
}
|
||
|
||
func TestNewS3FromEnvFallback(t *testing.T) {
|
||
os.Unsetenv("BLOBSTORE_S3_ENDPOINT")
|
||
s, err := NewS3FromEnv()
|
||
if s != nil || err != nil {
|
||
t.Fatalf("未配置端点应回落 (nil,nil):s=%v err=%v", s, err)
|
||
}
|
||
t.Setenv("BLOBSTORE_S3_ENDPOINT", "http://x/b")
|
||
t.Setenv("BLOBSTORE_S3_ACCESS_KEY", "a")
|
||
t.Setenv("BLOBSTORE_S3_SECRET_KEY", "b")
|
||
s, err = NewS3FromEnv()
|
||
if err != nil || s == nil {
|
||
t.Fatalf("已配置应构造:s=%v err=%v", s, err)
|
||
}
|
||
}
|