From 67e2e1ebbf9986dd6984755591b99c3d2edc43ac Mon Sep 17 00:00:00 2001 From: Catlaxy AI Date: Tue, 6 Oct 2026 09:24:00 +0800 Subject: [PATCH] =?UTF-8?q?test(storage):=20=E4=BD=BF=E7=94=A8=E8=BF=9B?= =?UTF-8?q?=E7=A8=8B=E5=86=85=20DNS=20=E5=A4=B9=E5=85=B7=E6=B6=88=E9=99=A4?= =?UTF-8?q?=E7=A6=BB=E7=BA=BF=E6=B5=8B=E8=AF=95=E4=BE=9D=E8=B5=96=20(#689)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 5.5 --- backend/internal/app/admin_channel_test.go | 2 + backend/internal/app/resource_test.go | 5 ++ backend/internal/assets/access_test.go | 10 ++- .../outbound/outboundtest/resolver.go | 69 +++++++++++++++++++ .../outbound/outboundtest/resolver_test.go | 27 ++++++++ 5 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 backend/internal/outbound/outboundtest/resolver.go create mode 100644 backend/internal/outbound/outboundtest/resolver_test.go diff --git a/backend/internal/app/admin_channel_test.go b/backend/internal/app/admin_channel_test.go index 31f9fa8dd..4cc37e2a2 100644 --- a/backend/internal/app/admin_channel_test.go +++ b/backend/internal/app/admin_channel_test.go @@ -10,6 +10,7 @@ import ( "testing" "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/outbound/outboundtest" "infinite-canvas/backend/internal/repository" "gorm.io/driver/sqlite" @@ -470,6 +471,7 @@ func TestNormalizeAdminChannelModelDeleteIDsRequiresBoundedSelection(t *testing. } func TestResolveProviderConfigMapsSKUToProviderModel(t *testing.T) { + outboundtest.PublicDNS(t, "ark.cn-beijing.volces.com") svc, db := newChannelModelTestService(t) svc.dataDir = t.TempDir() channel := model.ModelChannel{ diff --git a/backend/internal/app/resource_test.go b/backend/internal/app/resource_test.go index c056391f6..1fb64ad40 100644 --- a/backend/internal/app/resource_test.go +++ b/backend/internal/app/resource_test.go @@ -20,6 +20,7 @@ import ( "infinite-canvas/backend/internal/assets" "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/outbound/outboundtest" "infinite-canvas/backend/internal/repository" "infinite-canvas/backend/internal/storage" @@ -335,6 +336,7 @@ func TestAliyunOSSSettingKeepsCDNBaseURL(t *testing.T) { } func TestQiniuKodoSettingAllowsMissingCDNBaseURL(t *testing.T) { + outboundtest.PublicDNS(t, "up-z0.qiniup.com") next, err := ossSettingFromRequest(OSSSettingRequest{ Enabled: true, Provider: qiniuKodoProvider, Region: "z0", Endpoint: "https://up-z0.qiniup.com", Bucket: "private-bucket", AccessKeyID: "access-id", AccessKeySecret: "secret-value", @@ -415,6 +417,7 @@ func TestArchivedProviderCredentialsAreEncryptedAtRest(t *testing.T) { } func TestResourceAccessChecksOwnershipAndSignsOSSResource(t *testing.T) { + outboundtest.PublicDNS(t, "s3.amazonaws.com") svc := newResourceTestService(t) settingJSON, _ := json.Marshal(ossSettingValue{ Enabled: true, Provider: "aliyun", Endpoint: "https://s3.amazonaws.com", Bucket: "private-bucket", @@ -472,6 +475,7 @@ func TestPrepareResourceDeliveryPrefersConfiguredCDN(t *testing.T) { } func TestPrepareResourceDeliveryFallsBackToOriginWhenCDNAuthIsMissing(t *testing.T) { + outboundtest.PublicDNS(t, "s3.amazonaws.com") svc := newResourceTestService(t) settingJSON, _ := json.Marshal(ossSettingValue{ Enabled: true, Provider: aliyunOSSProvider, Endpoint: "https://s3.amazonaws.com", CDNBaseURL: "https://media.example.com", @@ -687,6 +691,7 @@ func TestHistoricalUserResourceWithoutStorageSettingIDKeepsItsProviderCDN(t *tes } func TestPrepareResourceDeliveryUsesSignedOriginWithoutCDN(t *testing.T) { + outboundtest.PublicDNS(t, "s3.amazonaws.com") svc := newResourceTestService(t) settingJSON, _ := json.Marshal(ossSettingValue{ Enabled: true, Provider: aliyunOSSProvider, Endpoint: "https://s3.amazonaws.com", Bucket: "private-bucket", diff --git a/backend/internal/assets/access_test.go b/backend/internal/assets/access_test.go index ba3b08711..ff6fb398d 100644 --- a/backend/internal/assets/access_test.go +++ b/backend/internal/assets/access_test.go @@ -8,6 +8,7 @@ import ( "infinite-canvas/backend/internal/kernel" "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/outbound/outboundtest" "infinite-canvas/backend/internal/storage" ) @@ -26,6 +27,7 @@ func testPlatformURL(variants *[]ResourceVariant) func(ResourceVariant, time.Tim } func TestResolveAccessPolicyMatrix(t *testing.T) { + outboundtest.PublicDNS(t, "s3.amazonaws.com") now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) tests := []struct { name string @@ -55,7 +57,7 @@ func TestResolveAccessPolicyMatrix(t *testing.T) { { name: "CDN without supported auth falls back to public origin", resource: testReadyResource("aliyun"), - setting: storage.Settings{Provider: "aliyun", Endpoint: "https://s3.amazonaws.com", CDNBaseURL: "https://media.example.com", AccessKeyID: "id", AccessKeySecret: "secret"}, + setting: storage.Settings{Provider: "aliyun", Endpoint: "https://s3.amazonaws.com", Bucket: "private-bucket", CDNBaseURL: "https://media.example.com", AccessKeyID: "id", AccessKeySecret: "secret"}, options: AccessOptions{Purpose: PurposeDisplay}, wantMode: DeliveryOrigin, wantReason: "cdn_auth_unconfigured", }, @@ -98,6 +100,12 @@ func TestResolveAccessPolicyMatrix(t *testing.T) { if access.Delivery != tt.wantMode || access.FallbackReason != tt.wantReason { t.Fatalf("ResolveAccess() = %#v, want mode=%q reason=%q", access, tt.wantMode, tt.wantReason) } + if tt.wantMode == DeliveryOrigin { + parsed, err := url.Parse(access.URL) + if err != nil || parsed.Host != "private-bucket.s3.amazonaws.com" || parsed.Query().Get("Signature") == "" { + t.Fatalf("invalid signed origin URL: %q (%v)", access.URL, err) + } + } if tt.wantURL != "" && access.URL != tt.wantURL { t.Fatalf("ResolveAccess().URL = %q, want %q", access.URL, tt.wantURL) } diff --git a/backend/internal/outbound/outboundtest/resolver.go b/backend/internal/outbound/outboundtest/resolver.go new file mode 100644 index 000000000..01b529655 --- /dev/null +++ b/backend/internal/outbound/outboundtest/resolver.go @@ -0,0 +1,69 @@ +// Package outboundtest provides socket-free DNS fixtures for outbound policy tests. +package outboundtest + +import ( + "context" + "encoding/binary" + "io" + "net" + "strings" + "testing" + + "golang.org/x/net/dns/dnsmessage" +) + +// PublicDNS maps only the supplied hostnames to a public A record. Other names +// return NXDOMAIN; AAAA queries return no records. No network socket is opened. +// It changes process-global state: callers and their parents must not use +// t.Parallel. Cleanup restores the previous resolver (including nested fixtures). +func PublicDNS(t *testing.T, hosts ...string) { + t.Helper() + DNS(t, [4]byte{1, 1, 1, 1}, hosts...) +} + +// DNS maps supplied names to a chosen IPv4 address without opening sockets. +func DNS(t *testing.T, address [4]byte, hosts ...string) { + t.Helper() + allowed := make(map[string]bool, len(hosts)) + for _, host := range hosts { + allowed[strings.ToLower(strings.TrimSuffix(host, "."))+"."] = true + } + previous := net.DefaultResolver + net.DefaultResolver = &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, resolverAddress string) (net.Conn, error) { + client, server := net.Pipe() + go func() { + defer server.Close() + for { + var size [2]byte + if _, err := io.ReadFull(server, size[:]); err != nil { + return + } + wire := make([]byte, binary.BigEndian.Uint16(size[:])) + if _, err := io.ReadFull(server, wire); err != nil { + return + } + var query dnsmessage.Message + if query.Unpack(wire) != nil || len(query.Questions) != 1 { + return + } + q := query.Questions[0] + response := dnsmessage.Message{Header: dnsmessage.Header{ID: query.ID, Response: true, Authoritative: true, RecursionDesired: query.RecursionDesired, RecursionAvailable: true}, Questions: query.Questions} + if !allowed[strings.ToLower(q.Name.String())] { + response.RCode = dnsmessage.RCodeNameError + } else if q.Type == dnsmessage.TypeA && q.Class == dnsmessage.ClassINET { + response.Answers = []dnsmessage.Resource{{Header: dnsmessage.ResourceHeader{Name: q.Name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET, TTL: 60}, Body: &dnsmessage.AResource{A: address}}} + } + answer, err := response.Pack() + if err != nil { + return + } + binary.BigEndian.PutUint16(size[:], uint16(len(answer))) + if _, err := server.Write(append(size[:], answer...)); err != nil { + return + } + } + }() + return client, nil + }} + t.Cleanup(func() { net.DefaultResolver = previous }) +} diff --git a/backend/internal/outbound/outboundtest/resolver_test.go b/backend/internal/outbound/outboundtest/resolver_test.go new file mode 100644 index 000000000..d56ae33c0 --- /dev/null +++ b/backend/internal/outbound/outboundtest/resolver_test.go @@ -0,0 +1,27 @@ +package outboundtest + +import ( + "context" + "net" + "testing" + "time" +) + +func TestPublicDNS(t *testing.T) { + previous := net.DefaultResolver + t.Run("fixture", func(t *testing.T) { + PublicDNS(t, "media.example.com") + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + ips, err := net.DefaultResolver.LookupIP(ctx, "ip", "media.example.com") + if err != nil || len(ips) != 1 || !ips[0].Equal(net.IPv4(1, 1, 1, 1)) { + t.Fatalf("A/AAAA lookup = %v, %v", ips, err) + } + if _, err := net.DefaultResolver.LookupIP(ctx, "ip", "unregistered.example.com"); err == nil { + t.Fatal("unknown host must fail closed") + } + }) + if net.DefaultResolver != previous { + t.Fatal("resolver was not restored") + } +}