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", "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(``) 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()) } } }