Files
2026-07-30 12:23:05 +08:00

218 lines
7.3 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"oci-portal/internal/crypto"
"oci-portal/internal/model"
"oci-portal/internal/oci"
"oci-portal/internal/service"
)
func newIconTestRouter(t *testing.T) (*gin.Engine, string, uint, *service.SystemLogService) {
t.Helper()
router, auth, logs, db := newTestRouterDB(t)
id := seedIconConfig(t, db)
token, _, err := auth.Login(context.Background(), "admin", "pass123", "", service.SessionMeta{ClientIP: "127.0.0.1"})
if err != nil {
t.Fatalf("login: %v", err)
}
return router, token, id, logs
}
func seedIconConfig(t *testing.T, db *gorm.DB) uint {
t.Helper()
cipher, err := crypto.NewCipher("test-key")
if err != nil {
t.Fatalf("new cipher: %v", err)
}
key, err := cipher.EncryptString("-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----")
if err != nil {
t.Fatalf("encrypt key: %v", err)
}
cfg := model.OciConfig{Alias: "icons", TenancyOCID: "ocid1.tenancy.oc1..t", UserOCID: "ocid1.user.oc1..u",
Fingerprint: "aa:bb", Region: "us-ashburn-1", HomeRegionKey: "IAD", PrivateKeyEnc: key}
if err := db.Create(&cfg).Error; err != nil {
t.Fatalf("create config: %v", err)
}
return cfg.ID
}
func iconSVG(t *testing.T, size int) []byte {
t.Helper()
data := []byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`)
if len(data) > size {
t.Fatalf("svg size %d exceeds target %d", len(data), size)
}
return append(data, bytes.Repeat([]byte(" "), size-len(data))...)
}
func sendIconUpload(t *testing.T, router *gin.Engine, token, path, fileName string, data []byte) *httptest.ResponseRecorder {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", fileName)
if err != nil {
t.Fatalf("create form file: %v", err)
}
_, _ = part.Write(data)
_ = writer.Close()
req := httptest.NewRequest(http.MethodPost, path, &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func TestIdpIconMultipartSizeBoundary(t *testing.T) {
router, token, id, logs := newIconTestRouter(t)
t.Cleanup(logs.Wait)
path := "/api/v1/oci-configs/" + strconv.FormatUint(uint64(id), 10) + "/idp-icons"
cases := []struct {
name, fileName string
data []byte
want int
}{
{"exactly 1MiB", "icon.svg", iconSVG(t, 1<<20), http.StatusOK},
{"one byte over", "icon.svg", iconSVG(t, 1<<20+1), http.StatusRequestEntityTooLarge},
{"empty", "icon.png", nil, http.StatusBadRequest},
{"spoofed png", "icon.png", []byte("not an image"), http.StatusUnsupportedMediaType},
}
for _, tc := range cases {
rec := sendIconUpload(t, router, token, path, tc.fileName, tc.data)
if rec.Code != tc.want {
t.Errorf("%s status = %d, want %d, body %s", tc.name, rec.Code, tc.want, rec.Body.String())
}
}
}
func TestDeleteIdpIconRoute(t *testing.T) {
router, token, id, logs := newIconTestRouter(t)
t.Cleanup(logs.Wait)
base := "/api/v1/oci-configs/" + strconv.FormatUint(uint64(id), 10) + "/idp-icons"
fileName := "images/idp-icon-" + strings.Repeat("ab", 16) + ".png"
query := url.Values{"fileName": {fileName}}
rec := doRequest(t, router, http.MethodDelete, base+"?"+query.Encode(), token, "")
if rec.Code != http.StatusNoContent {
t.Errorf("delete status = %d, want 204, body %s", rec.Code, rec.Body.String())
}
rec = doRequest(t, router, http.MethodDelete, base+"?fileName=images/company-brand.png", token, "")
if rec.Code != http.StatusBadRequest {
t.Errorf("invalid delete status = %d, want 400", rec.Code)
}
}
func assertResponderStatus(t *testing.T, name string, err error, want int, responder func(*gin.Context, error)) {
t.Helper()
rec := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(rec)
ctx.Request = httptest.NewRequest(http.MethodPost, "/", nil)
responder(ctx, err)
if rec.Code != want {
t.Errorf("%s status = %d, want %d", name, rec.Code, want)
}
}
func TestIdpIconErrorResponder(t *testing.T) {
cases := []struct {
name string
err error
want int
}{
{"empty icon", service.ErrIdpIconEmpty, 400},
{"bad icon name", service.ErrIdpIconBadName, 400},
{"large icon", service.ErrIdpIconTooLarge, 413},
{"bad icon type", service.ErrIdpIconBadType, 415},
}
for _, tc := range cases {
assertResponderStatus(t, tc.name, tc.err, tc.want, respondIdpIconErr)
}
}
func TestCreateIdpOrdinaryErrorHasNoPartialSemantics(t *testing.T) {
rec, body := callCreateIdpResponder(t, oci.IdentityProviderInfo{}, errors.New("create failed"))
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500; body %s", rec.Code, rec.Body.String())
}
if _, ok := body["setupWarning"]; ok || bytes.Contains(rec.Body.Bytes(), []byte("resourceCreated")) {
t.Errorf("ordinary error unexpectedly carries partial semantics: %s", rec.Body.String())
}
}
func TestCreateIdpPartialErrorReturnsStableWarning(t *testing.T) {
partial := &oci.PartialIdentityProviderCreateError{
IdentityProvider: oci.IdentityProviderInfo{ID: "idp-1", Name: "test-idp"},
}
rec, body := callCreateIdpResponder(t, oci.IdentityProviderInfo{}, errors.Join(errors.New("raw secret"), partial))
warning, ok := body["setupWarning"].(map[string]interface{})
if rec.Code != http.StatusCreated || !ok {
t.Fatalf("status = %d, warning = %#v; body %s", rec.Code, body["setupWarning"], rec.Body.String())
}
requestID, _ := warning["requestId"].(string)
if warning["code"] != oci.IdpSetupWarningCode || warning["resourceCreated"] != true || requestID == "" {
t.Errorf("warning = %#v, want stable partial-create contract", warning)
}
if bytes.Contains(rec.Body.Bytes(), []byte("secret")) {
t.Errorf("partial response leaks internal cause: %s", rec.Body.String())
}
}
func callCreateIdpResponder(t *testing.T, idp oci.IdentityProviderInfo, err error) (*httptest.ResponseRecorder, map[string]interface{}) {
t.Helper()
rec := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(rec)
ctx.Request = httptest.NewRequest(http.MethodPost, "/identity-providers", nil)
respondCreateIdpResult(ctx, idp, err)
body := map[string]interface{}{}
if decodeErr := json.Unmarshal(rec.Body.Bytes(), &body); decodeErr != nil {
t.Fatalf("decode response: %v", decodeErr)
}
return rec, body
}
func TestPARErrorResponder(t *testing.T) {
cases := []struct {
name string
err error
}{
{"bad PAR type", service.ErrPARInvalidAccessType},
{"bad PAR expiry", service.ErrPARInvalidExpiration},
}
for _, tc := range cases {
assertResponderStatus(t, tc.name, tc.err, http.StatusBadRequest, respondPARError)
}
}
func TestCreatePARValidationReturns400(t *testing.T) {
router, token, id, logs := newIconTestRouter(t)
t.Cleanup(logs.Wait)
path := "/api/v1/oci-configs/" + strconv.FormatUint(uint64(id), 10) + "/buckets/b/pars"
cases := []struct {
name, body string
}{
{"bad access type", `{"accessType":"BucketRead","expiresHours":24}`},
{"zero expiration", `{"accessType":"ObjectRead","expiresHours":0}`},
{"excessive expiration", `{"accessType":"ObjectRead","expiresHours":876001}`},
}
for _, tc := range cases {
rec := doRequest(t, router, http.MethodPost, path, token, tc.body)
if rec.Code != http.StatusBadRequest {
t.Errorf("%s status = %d, want 400, body %s", tc.name, rec.Code, rec.Body.String())
}
}
}