218 lines
7.3 KiB
Go
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", "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())
|
|
}
|
|
}
|
|
}
|