blob: 6ff68d2faf713d5df26c1bc22a8c6c52f1e573a6 [file] [log] [blame]
Akron2cbdab52025-05-23 17:57:10 +02001package main
2
3import (
4 "bytes"
5 "encoding/json"
Akron74e1c072025-05-26 14:38:25 +02006 "errors"
Akron2cbdab52025-05-23 17:57:10 +02007 "fmt"
8 "net/http"
9 "net/http/httptest"
10 "net/url"
Akron74e1c072025-05-26 14:38:25 +020011 "strings"
Akron2cbdab52025-05-23 17:57:10 +020012 "testing"
13
Akrona00d4752025-05-26 17:34:36 +020014 tmconfig "github.com/KorAP/KoralPipe-TermMapper/config"
Akronfa55bb22025-05-26 15:10:42 +020015 "github.com/KorAP/KoralPipe-TermMapper/mapper"
Akron2cbdab52025-05-23 17:57:10 +020016 "github.com/gofiber/fiber/v2"
Akron74e1c072025-05-26 14:38:25 +020017 "github.com/stretchr/testify/assert"
18 "github.com/stretchr/testify/require"
Akron2cbdab52025-05-23 17:57:10 +020019)
20
21// FuzzInput represents the input data for the fuzzer
22type FuzzInput struct {
23 MapID string
24 Direction string
25 FoundryA string
26 FoundryB string
27 LayerA string
28 LayerB string
29 Body []byte
30}
31
32func FuzzTransformEndpoint(f *testing.F) {
Akrona00d4752025-05-26 17:34:36 +020033 // Create test mapping list
34 mappingList := tmconfig.MappingList{
35 ID: "test-mapper",
36 FoundryA: "opennlp",
37 LayerA: "p",
38 FoundryB: "upos",
39 LayerB: "p",
40 Mappings: []tmconfig.MappingRule{
41 "[PIDAT] <> [opennlp/p=PIDAT & opennlp/p=AdjType:Pdt]",
42 "[DET] <> [opennlp/p=DET]",
43 },
Akron2cbdab52025-05-23 17:57:10 +020044 }
45
46 // Create mapper
Akrona00d4752025-05-26 17:34:36 +020047 m, err := mapper.NewMapper([]tmconfig.MappingList{mappingList})
Akron2cbdab52025-05-23 17:57:10 +020048 if err != nil {
49 f.Fatal(err)
50 }
51
52 // Create fiber app
53 app := fiber.New(fiber.Config{
54 DisableStartupMessage: true,
55 ErrorHandler: func(c *fiber.Ctx, err error) error {
56 // Ensure we always return a valid JSON response even for panic cases
57 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
58 "error": "internal server error",
59 })
60 },
Akron74e1c072025-05-26 14:38:25 +020061 BodyLimit: maxInputLength,
Akron2cbdab52025-05-23 17:57:10 +020062 })
63 setupRoutes(app, m)
64
65 // Add seed corpus
66 f.Add("test-mapper", "atob", "", "", "", "", []byte(`{"@type": "koral:token"}`)) // Valid minimal input
67 f.Add("test-mapper", "btoa", "custom", "", "", "", []byte(`{"@type": "koral:token"}`)) // Valid with foundry override
68 f.Add("", "", "", "", "", "", []byte(`{}`)) // Empty parameters
69 f.Add("nonexistent", "invalid", "!@#$", "%^&*", "()", "[]", []byte(`invalid json`)) // Invalid everything
70 f.Add("test-mapper", "atob", "", "", "", "", []byte(`{"@type": "koral:token", "wrap": null}`)) // Valid JSON, invalid structure
71 f.Add("test-mapper", "atob", "", "", "", "", []byte(`{"@type": "koral:token", "wrap": {"@type": "unknown"}}`)) // Unknown type
72 f.Add("test-mapper", "atob", "", "", "", "", []byte(`{"@type": "koral:token", "wrap": {"@type": "koral:term"}}`)) // Missing required fields
Akron74e1c072025-05-26 14:38:25 +020073 f.Add("0", "0", strings.Repeat("\x83", 1000), "0", "Q", "", []byte("0")) // Failing fuzz test case
Akron2cbdab52025-05-23 17:57:10 +020074
75 f.Fuzz(func(t *testing.T, mapID, dir, foundryA, foundryB, layerA, layerB string, body []byte) {
Akron74e1c072025-05-26 14:38:25 +020076
77 // Validate input first
78 if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, body); err != nil {
79 // Skip this test case as it's invalid
80 t.Skip(err)
81 }
82
Akron2cbdab52025-05-23 17:57:10 +020083 // Build URL with query parameters
84 params := url.Values{}
85 if dir != "" {
86 params.Set("dir", dir)
87 }
88 if foundryA != "" {
89 params.Set("foundryA", foundryA)
90 }
91 if foundryB != "" {
92 params.Set("foundryB", foundryB)
93 }
94 if layerA != "" {
95 params.Set("layerA", layerA)
96 }
97 if layerB != "" {
98 params.Set("layerB", layerB)
99 }
100
101 url := fmt.Sprintf("/%s/query", url.PathEscape(mapID))
102 if len(params) > 0 {
103 url += "?" + params.Encode()
104 }
105
106 // Make request
107 req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body))
108 req.Header.Set("Content-Type", "application/json")
109 resp, err := app.Test(req)
110 if err != nil {
111 t.Fatal(err)
112 }
113 defer resp.Body.Close()
114
115 // Verify that we always get a valid response
116 if resp.StatusCode != http.StatusOK &&
117 resp.StatusCode != http.StatusBadRequest &&
118 resp.StatusCode != http.StatusInternalServerError {
119 t.Errorf("unexpected status code: %d", resp.StatusCode)
120 }
121
122 // Verify that the response is valid JSON
Akrone5aaf0a2025-06-02 16:43:54 +0200123 var result any
Akron2cbdab52025-05-23 17:57:10 +0200124 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
125 t.Errorf("invalid JSON response: %v", err)
126 }
127
128 // For error responses, verify that we have an error message
129 if resp.StatusCode != http.StatusOK {
Akrone5aaf0a2025-06-02 16:43:54 +0200130 // For error responses, we expect a JSON object with an error field
131 if resultMap, ok := result.(map[string]any); ok {
132 if errMsg, ok := resultMap["error"].(string); !ok || errMsg == "" {
133 t.Error("error response missing error message")
134 }
135 } else {
136 t.Error("error response should be a JSON object")
Akron2cbdab52025-05-23 17:57:10 +0200137 }
138 }
139 })
140}
Akron74e1c072025-05-26 14:38:25 +0200141
142func TestLargeInput(t *testing.T) {
Akrona00d4752025-05-26 17:34:36 +0200143 // Create test mapping list
144 mappingList := tmconfig.MappingList{
145 ID: "test-mapper",
146 Mappings: []tmconfig.MappingRule{
147 "[A] <> [B]",
148 },
149 }
Akron74e1c072025-05-26 14:38:25 +0200150
151 // Create mapper
Akrona00d4752025-05-26 17:34:36 +0200152 m, err := mapper.NewMapper([]tmconfig.MappingList{mappingList})
Akron74e1c072025-05-26 14:38:25 +0200153 require.NoError(t, err)
154
155 // Create fiber app
156 app := fiber.New(fiber.Config{
157 DisableStartupMessage: true,
158 ErrorHandler: func(c *fiber.Ctx, err error) error {
159 // For body limit errors, return 413 status code
160 if err.Error() == "body size exceeds the given limit" || errors.Is(err, fiber.ErrRequestEntityTooLarge) {
161 return c.Status(fiber.StatusRequestEntityTooLarge).JSON(fiber.Map{
162 "error": fmt.Sprintf("request body too large (max %d bytes)", maxInputLength),
163 })
164 }
165 // For other errors, return 500 status code
166 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
167 "error": err.Error(),
168 })
169 },
170 BodyLimit: maxInputLength,
171 })
172 setupRoutes(app, m)
173
174 tests := []struct {
175 name string
176 mapID string
177 direction string
178 foundryA string
179 foundryB string
180 layerA string
181 layerB string
182 input string
183 expectedCode int
184 expectedError string
185 }{
186 {
187 name: "Large map ID",
188 mapID: strings.Repeat("a", maxParamLength+1),
189 direction: "atob",
190 input: "{}",
191 expectedCode: http.StatusBadRequest,
Akron69d43bf2025-05-26 17:09:00 +0200192 expectedError: "mapID too long (max 1024 bytes)",
Akron74e1c072025-05-26 14:38:25 +0200193 },
194 {
195 name: "Large direction",
196 mapID: "test-mapper",
197 direction: strings.Repeat("a", maxParamLength+1),
198 input: "{}",
199 expectedCode: http.StatusBadRequest,
Akron69d43bf2025-05-26 17:09:00 +0200200 expectedError: "dir too long (max 1024 bytes)",
Akron74e1c072025-05-26 14:38:25 +0200201 },
202 {
203 name: "Large foundryA",
204 mapID: "test-mapper",
205 direction: "atob",
206 foundryA: strings.Repeat("a", maxParamLength+1),
207 input: "{}",
208 expectedCode: http.StatusBadRequest,
209 expectedError: "foundryA too long (max 1024 bytes)",
210 },
211 {
212 name: "Invalid characters in mapID",
213 mapID: "test<>mapper",
214 direction: "atob",
215 input: "{}",
216 expectedCode: http.StatusBadRequest,
217 expectedError: "mapID contains invalid characters",
218 },
219 {
220 name: "Large request body",
221 mapID: "test-mapper",
222 direction: "atob",
223 input: strings.Repeat("a", maxInputLength+1),
224 expectedCode: http.StatusRequestEntityTooLarge,
225 expectedError: "body size exceeds the given limit",
226 },
227 }
228
229 for _, tt := range tests {
230 t.Run(tt.name, func(t *testing.T) {
231 // Build URL with query parameters
232 url := "/" + tt.mapID + "/query"
233 if tt.direction != "" {
234 url += "?dir=" + tt.direction
235 }
236 if tt.foundryA != "" {
237 url += "&foundryA=" + tt.foundryA
238 }
239 if tt.foundryB != "" {
240 url += "&foundryB=" + tt.foundryB
241 }
242 if tt.layerA != "" {
243 url += "&layerA=" + tt.layerA
244 }
245 if tt.layerB != "" {
246 url += "&layerB=" + tt.layerB
247 }
248
249 // Make request
250 req := httptest.NewRequest(http.MethodPost, url, strings.NewReader(tt.input))
251 req.Header.Set("Content-Type", "application/json")
252 resp, err := app.Test(req)
253
254 if resp == nil {
255 assert.Equal(t, tt.expectedError, err.Error())
256 return
257 }
258
259 require.NoError(t, err)
260 defer resp.Body.Close()
261
262 // Check status code
263 assert.Equal(t, tt.expectedCode, resp.StatusCode)
264
265 // Check error message
Akron121c66e2025-06-02 16:34:05 +0200266 var result map[string]any
Akron74e1c072025-05-26 14:38:25 +0200267 err = json.NewDecoder(resp.Body).Decode(&result)
268 require.NoError(t, err)
269 errMsg, ok := result["error"].(string)
270 require.True(t, ok)
271 assert.Equal(t, tt.expectedError, errMsg)
272 })
273 }
274}
Akron121c66e2025-06-02 16:34:05 +0200275
276// # Run fuzzing for 1 minute
277// go test -fuzz=FuzzTransformEndpoint -fuzztime=1m ./cmd/termmapper
278//
279// # Run fuzzing until a crash is found or Ctrl+C is pressed
280// go test -fuzz=FuzzTransformEndpoint ./cmd/termmapper
281//
282// # Run fuzzing with verbose output
283// go test -fuzz=FuzzTransformEndpoint -v ./cmd/termmapper
284//
285// go test -run=FuzzTransformEndpoint/testdata/fuzz/FuzzTransformEndpoint/$SEED