358 lines
11 KiB
Go
358 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestWriteJSONFileCreatesParentDirectory(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "workflow", "publication-result.json")
|
|
if err := writeJSONFile(path, map[string]bool{"published": true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
contents, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(contents) != "{\"published\":true}\n" {
|
|
t.Fatalf("unexpected contents %q", contents)
|
|
}
|
|
}
|
|
|
|
func TestObjectURL(t *testing.T) {
|
|
result, err := objectURL("https://objectstorage.example.invalid", "namespace", "bucket", "admission/v1/current.json.gz")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result != "https://objectstorage.example.invalid/n/namespace/b/bucket/o/admission%2Fv1%2Fcurrent.json.gz" {
|
|
t.Fatalf("unexpected object URL %q", result)
|
|
}
|
|
}
|
|
|
|
func TestPublicationPrefixes(t *testing.T) {
|
|
prefixes, err := publicationPrefixes("admission/v1", []string{"web-allow/v1"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Join(prefixes, ",") != "admission/v1,web-allow/v1" {
|
|
t.Fatalf("unexpected prefixes %q", prefixes)
|
|
}
|
|
version, current := publicationObjects(prefixes[1], strings.Repeat("a", 64))
|
|
if version != "web-allow/v1/versions/"+strings.Repeat("a", 64)+".json.gz" {
|
|
t.Fatalf("unexpected version object %q", version)
|
|
}
|
|
if current != "web-allow/v1/current.json.gz" {
|
|
t.Fatalf("unexpected current object %q", current)
|
|
}
|
|
}
|
|
|
|
func TestPublicationPrefixesRejectInvalidOrDuplicateValues(t *testing.T) {
|
|
for name, values := range map[string][]string{
|
|
"empty": {""},
|
|
"traversal": {"../web-allow/v1"},
|
|
"nested": {"mail/smtp-deny/v1"},
|
|
"duplicate": {"web-allow/v1", "web-allow/v1"},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
if _, err := publicationPrefixes(values[0], values[1:]); err == nil {
|
|
t.Fatal("expected prefix validation failure")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPublishBundleUsesIdenticalContentForBothPrefixes(t *testing.T) {
|
|
type uploadRequest struct {
|
|
body string
|
|
immutable bool
|
|
escapedPath string
|
|
}
|
|
requests := make([]uploadRequest, 0, 4)
|
|
objects := map[string][]byte{}
|
|
server := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
|
if request.Method == http.MethodGet {
|
|
body, exists := objects[request.URL.EscapedPath()]
|
|
if !exists {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
_, _ = response.Write(body)
|
|
return
|
|
}
|
|
body, err := io.ReadAll(request.Body)
|
|
if err != nil {
|
|
t.Error(err)
|
|
}
|
|
objects[request.URL.EscapedPath()] = body
|
|
requests = append(requests, uploadRequest{
|
|
body: string(body),
|
|
immutable: request.Header.Get("If-None-Match") == "*",
|
|
escapedPath: request.URL.EscapedPath(),
|
|
})
|
|
response.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body := []byte("validated bundle")
|
|
digest, publications, err := publishBundle(
|
|
context.Background(),
|
|
server.Client(),
|
|
server.URL,
|
|
"namespace",
|
|
"bucket",
|
|
[]string{"admission/v1", "web-allow/v1"},
|
|
body,
|
|
credentials{tenancy: "tenancy", user: "user", fingerprint: "fingerprint", privateKey: key},
|
|
true,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(publications) != 2 || len(requests) != 4 {
|
|
t.Fatalf("publications=%d requests=%d", len(publications), len(requests))
|
|
}
|
|
expectedPaths := []string{
|
|
"/n/namespace/b/bucket/o/admission%2Fv1%2Fversions%2F" + digest + ".json.gz",
|
|
"/n/namespace/b/bucket/o/admission%2Fv1%2Fcurrent.json.gz",
|
|
"/n/namespace/b/bucket/o/web-allow%2Fv1%2Fversions%2F" + digest + ".json.gz",
|
|
"/n/namespace/b/bucket/o/web-allow%2Fv1%2Fcurrent.json.gz",
|
|
}
|
|
for index, request := range requests {
|
|
if request.body != string(body) {
|
|
t.Errorf("request %d changed bundle content", index)
|
|
}
|
|
if request.escapedPath != expectedPaths[index] {
|
|
t.Errorf("request %d path %q", index, request.escapedPath)
|
|
}
|
|
if request.immutable != (index%2 == 0) {
|
|
t.Errorf("request %d immutable=%t", index, request.immutable)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPublishBundleVerifiesExistingImmutableObject(t *testing.T) {
|
|
body := []byte("validated bundle")
|
|
digest := sha256.Sum256(body)
|
|
versionPath := "/n/namespace/b/bucket/o/smtp-deny%2Fv1%2Fversions%2F" + fmt.Sprintf("%x", digest) + ".json.gz"
|
|
server := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
|
if request.Method == http.MethodPut {
|
|
response.WriteHeader(http.StatusPreconditionFailed)
|
|
return
|
|
}
|
|
if request.URL.EscapedPath() != versionPath {
|
|
t.Errorf("unexpected path %q", request.URL.EscapedPath())
|
|
}
|
|
_, _ = response.Write(body)
|
|
}))
|
|
defer server.Close()
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, publications, err := publishBundle(
|
|
context.Background(),
|
|
server.Client(),
|
|
server.URL,
|
|
"namespace",
|
|
"bucket",
|
|
[]string{"smtp-deny/v1"},
|
|
body,
|
|
credentials{tenancy: "tenancy", user: "user", fingerprint: "fingerprint", privateKey: key},
|
|
false,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(publications) != 1 || publications[0].VersionCreated {
|
|
t.Fatalf("unexpected publications %#v", publications)
|
|
}
|
|
}
|
|
|
|
func TestPublishBundleRejectsMismatchedExistingImmutableObject(t *testing.T) {
|
|
server := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
|
if request.Method == http.MethodPut {
|
|
response.WriteHeader(http.StatusPreconditionFailed)
|
|
return
|
|
}
|
|
_, _ = response.Write([]byte("different bundle"))
|
|
}))
|
|
defer server.Close()
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, _, err = publishBundle(
|
|
context.Background(),
|
|
server.Client(),
|
|
server.URL,
|
|
"namespace",
|
|
"bucket",
|
|
[]string{"smtp-deny/v1"},
|
|
[]byte("validated bundle"),
|
|
credentials{tenancy: "tenancy", user: "user", fingerprint: "fingerprint", privateKey: key},
|
|
false,
|
|
)
|
|
if err == nil || !strings.Contains(err.Error(), "SHA-256 mismatch") {
|
|
t.Fatalf("unexpected error %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPublishBundleRejectsMismatchedCurrentObject(t *testing.T) {
|
|
objects := map[string][]byte{}
|
|
server := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
|
path := request.URL.EscapedPath()
|
|
if request.Method == http.MethodPut {
|
|
body, err := io.ReadAll(request.Body)
|
|
if err != nil {
|
|
t.Error(err)
|
|
}
|
|
objects[path] = body
|
|
response.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
if strings.HasSuffix(path, "current.json.gz") {
|
|
_, _ = response.Write([]byte("different bundle"))
|
|
return
|
|
}
|
|
_, _ = response.Write(objects[path])
|
|
}))
|
|
defer server.Close()
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, _, err = publishBundle(
|
|
context.Background(),
|
|
server.Client(),
|
|
server.URL,
|
|
"namespace",
|
|
"bucket",
|
|
[]string{"smtp-deny/v1"},
|
|
[]byte("validated bundle"),
|
|
credentials{tenancy: "tenancy", user: "user", fingerprint: "fingerprint", privateKey: key},
|
|
true,
|
|
)
|
|
if err == nil || !strings.Contains(err.Error(), "current.json.gz") || !strings.Contains(err.Error(), "SHA-256 mismatch") {
|
|
t.Fatalf("unexpected error %v", err)
|
|
}
|
|
}
|
|
|
|
func TestReadSMTPDenySummary(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "summary.json")
|
|
contents := `{"generated_at":"2026-07-26T00:00:00Z","expires_at":"2026-07-27T00:00:00Z","denied_interval_count":2,"denied_address_count":17,"removed_interval_count":1}`
|
|
if err := os.WriteFile(path, []byte(contents), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
summary, err := readSMTPDenySummary(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if summary.DeniedIntervalCount != 2 || summary.DeniedAddressCount != 17 || summary.RemovedIntervalCount != 1 {
|
|
t.Fatalf("unexpected summary %#v", summary)
|
|
}
|
|
}
|
|
|
|
func TestReadSMTPDenySummaryRejectsContentEvidence(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "summary.json")
|
|
contents := `{"generated_at":"2026-07-26T00:00:00Z","expires_at":"2026-07-27T00:00:00Z","denied_interval_count":1,"denied_address_count":1,"removed_interval_count":0,"recipient":"[email protected]"}`
|
|
if err := os.WriteFile(path, []byte(contents), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := readSMTPDenySummary(path); err == nil {
|
|
t.Fatal("expected content-bearing summary rejection")
|
|
}
|
|
}
|
|
|
|
func TestSignRequest(t *testing.T) {
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body := []byte("bundle")
|
|
request, err := http.NewRequest(http.MethodPut, "https://objectstorage.example.invalid/n/ns/b/b/o/current.json.gz", bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
identity := credentials{tenancy: "tenancy", user: "user", fingerprint: "fingerprint", privateKey: key}
|
|
if err := signRequest(request, body, identity, time.Unix(0, 0)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
authorization := request.Header.Get("Authorization")
|
|
marker := `signature="`
|
|
start := strings.Index(authorization, marker)
|
|
if start < 0 {
|
|
t.Fatal("missing signature")
|
|
}
|
|
encoded := strings.TrimSuffix(authorization[start+len(marker):], `"`)
|
|
signature, err := base64.StdEncoding.DecodeString(encoded)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
signingString := strings.Join([]string{
|
|
"(request-target): put /n/ns/b/b/o/current.json.gz",
|
|
"host: objectstorage.example.invalid",
|
|
"date: " + request.Header.Get("Date"),
|
|
"x-content-sha256: " + request.Header.Get("X-Content-SHA256"),
|
|
"content-type: application/gzip",
|
|
"content-length: 6",
|
|
}, "\n")
|
|
digest := sha256.Sum256([]byte(signingString))
|
|
if err := rsa.VerifyPKCS1v15(&key.PublicKey, 0, digest[:], signature); err == nil {
|
|
t.Fatal("signature unexpectedly verified without SHA-256")
|
|
}
|
|
if err := rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, digest[:], signature); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestSignDownloadRequest(t *testing.T) {
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request, err := http.NewRequest(http.MethodGet, "https://objectstorage.example.invalid/n/ns/b/b/o/admission%2Fv1%2Fversions%2Fbundle.json.gz", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
identity := credentials{tenancy: "tenancy", user: "user", fingerprint: "fingerprint", privateKey: key}
|
|
if err := signDownloadRequest(request, identity, time.Unix(0, 0)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
authorization := request.Header.Get("Authorization")
|
|
marker := `signature="`
|
|
start := strings.Index(authorization, marker)
|
|
if start < 0 {
|
|
t.Fatal("missing signature")
|
|
}
|
|
encoded := strings.TrimSuffix(authorization[start+len(marker):], `"`)
|
|
signature, err := base64.StdEncoding.DecodeString(encoded)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
signingString := strings.Join([]string{
|
|
"(request-target): get /n/ns/b/b/o/admission%2Fv1%2Fversions%2Fbundle.json.gz",
|
|
"host: objectstorage.example.invalid",
|
|
"date: " + request.Header.Get("Date"),
|
|
}, "\n")
|
|
digest := sha256.Sum256([]byte(signingString))
|
|
if err := rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, digest[:], signature); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|