| package main |
| |
| import ( |
| "bytes" |
| "encoding/json" |
| "errors" |
| "fmt" |
| "net/http" |
| "net/http/httptest" |
| "net/url" |
| "os" |
| "path/filepath" |
| "strings" |
| "testing" |
| |
| "github.com/KorAP/KoralPipe-TermMapper2/pkg/mapper" |
| "github.com/gofiber/fiber/v2" |
| "github.com/stretchr/testify/assert" |
| "github.com/stretchr/testify/require" |
| ) |
| |
| // FuzzInput represents the input data for the fuzzer |
| type FuzzInput struct { |
| MapID string |
| Direction string |
| FoundryA string |
| FoundryB string |
| LayerA string |
| LayerB string |
| Body []byte |
| } |
| |
| func FuzzTransformEndpoint(f *testing.F) { |
| // Create a temporary config file with valid mappings |
| tmpDir := f.TempDir() |
| configFile := filepath.Join(tmpDir, "test-config.yaml") |
| |
| configContent := `- id: test-mapper |
| foundryA: opennlp |
| layerA: p |
| foundryB: upos |
| layerB: p |
| mappings: |
| - "[PIDAT] <> [opennlp/p=PIDAT & opennlp/p=AdjType:Pdt]" |
| - "[DET] <> [opennlp/p=DET]"` |
| |
| err := os.WriteFile(configFile, []byte(configContent), 0644) |
| if err != nil { |
| f.Fatal(err) |
| } |
| |
| // Create mapper |
| m, err := mapper.NewMapper(configFile) |
| if err != nil { |
| f.Fatal(err) |
| } |
| |
| // Create fiber app |
| app := fiber.New(fiber.Config{ |
| DisableStartupMessage: true, |
| ErrorHandler: func(c *fiber.Ctx, err error) error { |
| // Ensure we always return a valid JSON response even for panic cases |
| return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ |
| "error": "internal server error", |
| }) |
| }, |
| BodyLimit: maxInputLength, |
| }) |
| setupRoutes(app, m) |
| |
| // Add seed corpus |
| f.Add("test-mapper", "atob", "", "", "", "", []byte(`{"@type": "koral:token"}`)) // Valid minimal input |
| f.Add("test-mapper", "btoa", "custom", "", "", "", []byte(`{"@type": "koral:token"}`)) // Valid with foundry override |
| f.Add("", "", "", "", "", "", []byte(`{}`)) // Empty parameters |
| f.Add("nonexistent", "invalid", "!@#$", "%^&*", "()", "[]", []byte(`invalid json`)) // Invalid everything |
| f.Add("test-mapper", "atob", "", "", "", "", []byte(`{"@type": "koral:token", "wrap": null}`)) // Valid JSON, invalid structure |
| f.Add("test-mapper", "atob", "", "", "", "", []byte(`{"@type": "koral:token", "wrap": {"@type": "unknown"}}`)) // Unknown type |
| f.Add("test-mapper", "atob", "", "", "", "", []byte(`{"@type": "koral:token", "wrap": {"@type": "koral:term"}}`)) // Missing required fields |
| f.Add("0", "0", strings.Repeat("\x83", 1000), "0", "Q", "", []byte("0")) // Failing fuzz test case |
| |
| f.Fuzz(func(t *testing.T, mapID, dir, foundryA, foundryB, layerA, layerB string, body []byte) { |
| |
| // Validate input first |
| if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, body); err != nil { |
| // Skip this test case as it's invalid |
| t.Skip(err) |
| } |
| |
| // Build URL with query parameters |
| params := url.Values{} |
| if dir != "" { |
| params.Set("dir", dir) |
| } |
| if foundryA != "" { |
| params.Set("foundryA", foundryA) |
| } |
| if foundryB != "" { |
| params.Set("foundryB", foundryB) |
| } |
| if layerA != "" { |
| params.Set("layerA", layerA) |
| } |
| if layerB != "" { |
| params.Set("layerB", layerB) |
| } |
| |
| url := fmt.Sprintf("/%s/query", url.PathEscape(mapID)) |
| if len(params) > 0 { |
| url += "?" + params.Encode() |
| } |
| |
| // Make request |
| req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body)) |
| req.Header.Set("Content-Type", "application/json") |
| resp, err := app.Test(req) |
| if err != nil { |
| t.Fatal(err) |
| } |
| defer resp.Body.Close() |
| |
| // Verify that we always get a valid response |
| if resp.StatusCode != http.StatusOK && |
| resp.StatusCode != http.StatusBadRequest && |
| resp.StatusCode != http.StatusInternalServerError { |
| t.Errorf("unexpected status code: %d", resp.StatusCode) |
| } |
| |
| // Verify that the response is valid JSON |
| var result map[string]interface{} |
| if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { |
| t.Errorf("invalid JSON response: %v", err) |
| } |
| |
| // For error responses, verify that we have an error message |
| if resp.StatusCode != http.StatusOK { |
| if errMsg, ok := result["error"].(string); !ok || errMsg == "" { |
| t.Error("error response missing error message") |
| } |
| } |
| }) |
| } |
| |
| func TestLargeInput(t *testing.T) { |
| // Create a temporary config file |
| tmpDir := t.TempDir() |
| configFile := filepath.Join(tmpDir, "test-config.yaml") |
| |
| configContent := `- id: test-mapper |
| mappings: |
| - "[A] <> [B]"` |
| |
| err := os.WriteFile(configFile, []byte(configContent), 0644) |
| require.NoError(t, err) |
| |
| // Create mapper |
| m, err := mapper.NewMapper(configFile) |
| require.NoError(t, err) |
| |
| // Create fiber app |
| app := fiber.New(fiber.Config{ |
| DisableStartupMessage: true, |
| ErrorHandler: func(c *fiber.Ctx, err error) error { |
| // For body limit errors, return 413 status code |
| if err.Error() == "body size exceeds the given limit" || errors.Is(err, fiber.ErrRequestEntityTooLarge) { |
| return c.Status(fiber.StatusRequestEntityTooLarge).JSON(fiber.Map{ |
| "error": fmt.Sprintf("request body too large (max %d bytes)", maxInputLength), |
| }) |
| } |
| // For other errors, return 500 status code |
| return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ |
| "error": err.Error(), |
| }) |
| }, |
| BodyLimit: maxInputLength, |
| }) |
| setupRoutes(app, m) |
| |
| tests := []struct { |
| name string |
| mapID string |
| direction string |
| foundryA string |
| foundryB string |
| layerA string |
| layerB string |
| input string |
| expectedCode int |
| expectedError string |
| }{ |
| { |
| name: "Large map ID", |
| mapID: strings.Repeat("a", maxParamLength+1), |
| direction: "atob", |
| input: "{}", |
| expectedCode: http.StatusBadRequest, |
| expectedError: "map ID too long (max 1024 bytes)", |
| }, |
| { |
| name: "Large direction", |
| mapID: "test-mapper", |
| direction: strings.Repeat("a", maxParamLength+1), |
| input: "{}", |
| expectedCode: http.StatusBadRequest, |
| expectedError: "direction too long (max 1024 bytes)", |
| }, |
| { |
| name: "Large foundryA", |
| mapID: "test-mapper", |
| direction: "atob", |
| foundryA: strings.Repeat("a", maxParamLength+1), |
| input: "{}", |
| expectedCode: http.StatusBadRequest, |
| expectedError: "foundryA too long (max 1024 bytes)", |
| }, |
| { |
| name: "Invalid characters in mapID", |
| mapID: "test<>mapper", |
| direction: "atob", |
| input: "{}", |
| expectedCode: http.StatusBadRequest, |
| expectedError: "mapID contains invalid characters", |
| }, |
| { |
| name: "Large request body", |
| mapID: "test-mapper", |
| direction: "atob", |
| input: strings.Repeat("a", maxInputLength+1), |
| expectedCode: http.StatusRequestEntityTooLarge, |
| expectedError: "body size exceeds the given limit", |
| }, |
| } |
| |
| for _, tt := range tests { |
| t.Run(tt.name, func(t *testing.T) { |
| // Build URL with query parameters |
| url := "/" + tt.mapID + "/query" |
| if tt.direction != "" { |
| url += "?dir=" + tt.direction |
| } |
| if tt.foundryA != "" { |
| url += "&foundryA=" + tt.foundryA |
| } |
| if tt.foundryB != "" { |
| url += "&foundryB=" + tt.foundryB |
| } |
| if tt.layerA != "" { |
| url += "&layerA=" + tt.layerA |
| } |
| if tt.layerB != "" { |
| url += "&layerB=" + tt.layerB |
| } |
| |
| // Make request |
| req := httptest.NewRequest(http.MethodPost, url, strings.NewReader(tt.input)) |
| req.Header.Set("Content-Type", "application/json") |
| resp, err := app.Test(req) |
| |
| if resp == nil { |
| assert.Equal(t, tt.expectedError, err.Error()) |
| return |
| } |
| |
| require.NoError(t, err) |
| defer resp.Body.Close() |
| |
| // Check status code |
| assert.Equal(t, tt.expectedCode, resp.StatusCode) |
| |
| // Check error message |
| var result map[string]interface{} |
| err = json.NewDecoder(resp.Body).Decode(&result) |
| require.NoError(t, err) |
| errMsg, ok := result["error"].(string) |
| require.True(t, ok) |
| assert.Equal(t, tt.expectedError, errMsg) |
| }) |
| } |
| } |