Files
storage-minio/compliance_test.go

379 lines
9.9 KiB
Go
Raw Normal View History

package minio
import (
"context"
"errors"
"go/ast"
"go/parser"
"go/token"
"io"
"net/http"
"os"
"strings"
"testing"
"time"
miniogo "github.com/minio/minio-go/v7"
"code.nochebuena.dev/einherjar/contracts/lifecycle"
"code.nochebuena.dev/einherjar/contracts/logging"
"code.nochebuena.dev/einherjar/contracts/observability"
"code.nochebuena.dev/einherjar/core/xerrors"
)
// Compile-time interface checks (CT-5 / I-8).
var _ lifecycle.Component = (Component)(nil)
var _ observability.Checkable = (Component)(nil)
var _ Provider = (Component)(nil)
// --- CT-6: at most one exported TypeSpec per non-test file ---
func TestAtMostOneExportedTypePerFile(t *testing.T) {
fset := token.NewFileSet()
entries, err := os.ReadDir(".")
if err != nil {
t.Fatalf("ReadDir: %v", err)
}
for _, entry := range entries {
name := entry.Name()
if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
f, err := parser.ParseFile(fset, name, nil, 0)
if err != nil {
t.Fatalf("parse %s: %v", name, err)
}
count := 0
for _, decl := range f.Decls {
gd, ok := decl.(*ast.GenDecl)
if !ok {
continue
}
for _, spec := range gd.Specs {
ts, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
if ts.Name.IsExported() {
count++
}
}
}
if count > 1 {
t.Errorf("%s: has %d exported TypeSpecs, want ≤1", name, count)
}
}
}
// --- Config defaults (S-4) ---
func TestDefaultConfig_OptionalFields(t *testing.T) {
cfg := DefaultConfig()
if cfg.Region == "" {
t.Error("Region must have a default")
}
}
// --- HandleError ---
func assertXCode(t *testing.T, err error, want xerrors.Code) {
t.Helper()
var xe *xerrors.Err
if !errors.As(err, &xe) {
t.Fatalf("expected *xerrors.Err, got %T: %v", err, err)
}
if xe.Code() != want {
t.Errorf("want code %s, got %s", want, xe.Code())
}
}
func TestHandleError_Nil(t *testing.T) {
if err := HandleError(nil); err != nil {
t.Errorf("want nil, got %v", err)
}
}
func TestHandleError_NoSuchBucket(t *testing.T) {
assertXCode(t, HandleError(miniogo.ErrorResponse{Code: miniogo.NoSuchBucket}), xerrors.ErrNotFound)
}
func TestHandleError_NoSuchKey(t *testing.T) {
assertXCode(t, HandleError(miniogo.ErrorResponse{Code: miniogo.NoSuchKey}), xerrors.ErrNotFound)
}
func TestHandleError_AccessDenied(t *testing.T) {
assertXCode(t, HandleError(miniogo.ErrorResponse{Code: miniogo.AccessDenied}), xerrors.ErrPermissionDenied)
}
func TestHandleError_InvalidAccessKeyID(t *testing.T) {
assertXCode(t, HandleError(miniogo.ErrorResponse{Code: miniogo.InvalidAccessKeyID}), xerrors.ErrPermissionDenied)
}
func TestHandleError_BucketAlreadyExists(t *testing.T) {
assertXCode(t, HandleError(miniogo.ErrorResponse{Code: miniogo.BucketAlreadyExists}), xerrors.ErrAlreadyExists)
}
func TestHandleError_BucketAlreadyOwnedByYou(t *testing.T) {
assertXCode(t, HandleError(miniogo.ErrorResponse{Code: miniogo.BucketAlreadyOwnedByYou}), xerrors.ErrAlreadyExists)
}
func TestHandleError_Unknown(t *testing.T) {
assertXCode(t, HandleError(miniogo.ErrorResponse{Code: "SomeUnknownCode"}), xerrors.ErrInternal)
}
// --- New / name / priority ---
func TestNew_NotNil(t *testing.T) {
if New(newLogger(), Config{}) == nil {
t.Fatal("New returned nil")
}
}
func TestComponent_Name(t *testing.T) {
c := New(newLogger(), Config{})
if c.Name() != "minio" {
t.Errorf("Name() = %q, want %q", c.Name(), "minio")
}
}
func TestComponent_Priority(t *testing.T) {
c := New(newLogger(), Config{})
if c.Priority() != observability.LevelCritical {
t.Error("Priority() != LevelCritical")
}
}
// --- nil client guards ---
func TestComponent_OnStop_NilClient(t *testing.T) {
c := &minioImpl{logger: newLogger()}
if err := c.OnStop(); err != nil {
t.Errorf("OnStop with nil client: %v", err)
}
}
func TestComponent_HealthCheck_NilClient(t *testing.T) {
c := &minioImpl{logger: newLogger()}
if err := c.HealthCheck(context.Background()); err == nil {
t.Error("HealthCheck with nil client should return error")
}
}
func TestComponent_HandleError_Passthrough(t *testing.T) {
c := New(newLogger(), Config{})
if c.HandleError(nil) != nil {
t.Error("HandleError(nil) should return nil")
}
}
// --- OnInit and Native ---
func TestComponent_OnInit_And_Native(t *testing.T) {
cfg := testConfig(alwaysOK())
c := New(newLogger(), cfg)
if err := c.OnInit(); err != nil {
t.Fatalf("OnInit: %v", err)
}
if c.Native() == nil {
t.Error("Native() returned nil after OnInit")
}
}
// --- OnStart ---
func TestComponent_OnStart_BucketExists(t *testing.T) {
putCalled := false
cfg := testConfig(&mockTransport{fn: func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodPut {
putCalled = true
}
return makeResp(http.StatusOK), nil
}})
c := New(newLogger(), cfg)
if err := c.OnInit(); err != nil {
t.Fatalf("OnInit: %v", err)
}
if err := c.OnStart(); err != nil {
t.Fatalf("OnStart: %v", err)
}
if putCalled {
t.Error("MakeBucket should not be called when bucket already exists")
}
}
func TestComponent_OnStart_BucketMissing(t *testing.T) {
putCalled := false
cfg := testConfig(&mockTransport{fn: func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodHead {
return makeResp(http.StatusNotFound), nil
}
if r.Method == http.MethodPut {
putCalled = true
return makeResp(http.StatusOK), nil
}
return makeResp(http.StatusOK), nil
}})
c := New(newLogger(), cfg)
if err := c.OnInit(); err != nil {
t.Fatalf("OnInit: %v", err)
}
if err := c.OnStart(); err != nil {
t.Fatalf("OnStart: %v", err)
}
if !putCalled {
t.Error("MakeBucket should be called when bucket does not exist")
}
}
func TestComponent_OnStart_BucketError(t *testing.T) {
cfg := testConfig(&mockTransport{fn: func(r *http.Request) (*http.Response, error) {
return makeResp(http.StatusInternalServerError), nil
}})
c := New(newLogger(), cfg)
if err := c.OnInit(); err != nil {
t.Fatalf("OnInit: %v", err)
}
if err := c.OnStart(); err == nil {
t.Error("OnStart should return error on 500 response")
}
}
// --- HealthCheck ---
func TestComponent_HealthCheck_Unreachable(t *testing.T) {
cfg := testConfig(&mockTransport{fn: func(r *http.Request) (*http.Response, error) {
return nil, errors.New("connection refused")
}})
c := New(newLogger(), cfg)
if err := c.OnInit(); err != nil {
t.Fatalf("OnInit: %v", err)
}
if err := c.HealthCheck(context.Background()); err == nil {
t.Error("HealthCheck should return error when endpoint is unreachable")
}
}
// --- Provider operations ---
func startedComponent(t *testing.T, transport http.RoundTripper) Component {
t.Helper()
cfg := testConfig(transport)
c := New(newLogger(), cfg)
if err := c.OnInit(); err != nil {
t.Fatalf("OnInit: %v", err)
}
return c
}
func TestComponent_PutObject_Success(t *testing.T) {
c := startedComponent(t, &mockTransport{fn: func(r *http.Request) (*http.Response, error) {
resp := makeResp(http.StatusOK)
resp.Header.Set("ETag", `"abc123"`)
return resp, nil
}})
_, err := c.PutObject(context.Background(), "test-bucket", "products/1/img.jpg",
strings.NewReader("data"), 4, miniogo.PutObjectOptions{})
if err != nil {
t.Errorf("PutObject: %v", err)
}
}
func TestComponent_RemoveObject_Success(t *testing.T) {
c := startedComponent(t, &mockTransport{fn: func(r *http.Request) (*http.Response, error) {
return makeResp(http.StatusNoContent), nil
}})
if err := c.RemoveObject(context.Background(), "test-bucket", "products/1/img.jpg",
miniogo.RemoveObjectOptions{}); err != nil {
t.Errorf("RemoveObject: %v", err)
}
}
func TestComponent_GetObject_Success(t *testing.T) {
c := startedComponent(t, &mockTransport{fn: func(r *http.Request) (*http.Response, error) {
resp := makeResp(http.StatusOK)
resp.Body = io.NopCloser(strings.NewReader("image-bytes"))
resp.ContentLength = 11
return resp, nil
}})
obj, err := c.GetObject(context.Background(), "test-bucket", "products/1/img.jpg",
miniogo.GetObjectOptions{})
if err != nil {
t.Fatalf("GetObject: %v", err)
}
if obj == nil {
t.Error("GetObject returned nil object")
}
obj.Close()
}
func TestComponent_PresignedGetObject_Success(t *testing.T) {
c := startedComponent(t, nil)
u, err := c.PresignedGetObject(context.Background(), "test-bucket", "products/1/img.jpg",
time.Hour, nil)
if err != nil {
t.Errorf("PresignedGetObject: %v", err)
}
if u == nil {
t.Error("PresignedGetObject returned nil URL")
}
}
func TestComponent_OnStop_NilsClient(t *testing.T) {
c := startedComponent(t, alwaysOK())
if err := c.OnStop(); err != nil {
t.Errorf("OnStop: %v", err)
}
if c.Native() != nil {
t.Error("Native() should return nil after OnStop")
}
}
// --- stubs and helpers ---
type stubLogger struct{}
func newLogger() *stubLogger { return &stubLogger{} }
func (s *stubLogger) Debug(msg string, args ...any) {}
func (s *stubLogger) Info(msg string, args ...any) {}
func (s *stubLogger) Warn(msg string, args ...any) {}
func (s *stubLogger) Error(msg string, err error, args ...any) {}
func (s *stubLogger) With(args ...any) logging.Logger { return s }
func (s *stubLogger) WithContext(ctx context.Context) logging.Logger { return s }
type mockTransport struct {
fn func(*http.Request) (*http.Response, error)
}
func (m *mockTransport) RoundTrip(r *http.Request) (*http.Response, error) {
return m.fn(r)
}
func makeResp(status int) *http.Response {
return &http.Response{
StatusCode: status,
Body: io.NopCloser(strings.NewReader("")),
Header: make(http.Header),
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
}
}
func alwaysOK() *mockTransport {
return &mockTransport{fn: func(r *http.Request) (*http.Response, error) {
return makeResp(http.StatusOK), nil
}}
}
func testConfig(transport http.RoundTripper) Config {
return Config{
Endpoint: "localhost:9000",
AccessKey: "minioadmin",
SecretKey: "minioadmin",
Bucket: "test-bucket",
Region: "us-east-1",
Transport: transport,
}
}