blob: 92318a3c0a5caaf76bfd57eedcd1e4cfc3686b4b [file] [log] [blame]
package main
import (
"bytes"
"encoding/json"
"html/template"
"io"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sort"
"strings"
"testing"
tmconfig "github.com/KorAP/Koral-Mapper/config"
"github.com/KorAP/Koral-Mapper/mapper"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// jsEscapeURL converts a URL to its html/template JS string escaped form.
// In JS string context, html/template escapes / as \/ and & as \u0026.
func jsEscapeURL(u string) string {
u = strings.ReplaceAll(u, "/", `\/`)
u = strings.ReplaceAll(u, "&", `\u0026`)
return u
}
func loadConfigFromYAML(t *testing.T, configYAML string, mappingYAMLs ...string) *tmconfig.MappingConfig {
t.Helper()
configPath := ""
if configYAML != "" {
cfgFile, err := os.CreateTemp("", "koralmapper-config-*.yaml")
require.NoError(t, err)
t.Cleanup(func() { _ = os.Remove(cfgFile.Name()) })
_, err = cfgFile.WriteString(configYAML)
require.NoError(t, err)
require.NoError(t, cfgFile.Close())
configPath = cfgFile.Name()
}
mappingPaths := make([]string, 0, len(mappingYAMLs))
for _, content := range mappingYAMLs {
mapFile, err := os.CreateTemp("", "koralmapper-mapping-*.yaml")
require.NoError(t, err)
t.Cleanup(func() { _ = os.Remove(mapFile.Name()) })
_, err = mapFile.WriteString(content)
require.NoError(t, err)
require.NoError(t, mapFile.Close())
mappingPaths = append(mappingPaths, mapFile.Name())
}
cfg, err := tmconfig.LoadFromSources(configPath, mappingPaths)
require.NoError(t, err)
return cfg
}
func TestTransformEndpoint(t *testing.T) {
cfg := loadConfigFromYAML(t, `
lists:
- id: test-mapper
foundryA: opennlp
layerA: p
foundryB: upos
layerB: p
rewrites: true
mappings:
- "[PIDAT] <> [opennlp/p=PIDAT & opennlp/p=AdjType:Pdt]"
- "[DET] <> [opennlp/p=DET]"
`)
// Create mapper
m, err := mapper.NewMapper(cfg.Lists)
require.NoError(t, err)
// Create fiber app
app := fiber.New()
setupRoutes(app, m, cfg)
tests := []struct {
name string
mapID string
direction string
foundryA string
foundryB string
layerA string
layerB string
input string
expectedCode int
expectedBody string
expectedError string
}{
{
name: "Simple A to B mapping",
mapID: "test-mapper",
direction: "atob",
input: `{
"@type": "koral:token",
"wrap": {
"@type": "koral:term",
"foundry": "opennlp",
"key": "PIDAT",
"layer": "p",
"match": "match:eq"
}
}`,
expectedCode: http.StatusOK,
expectedBody: `{
"@type": "koral:token",
"wrap": {
"@type": "koral:termGroup",
"operands": [
{
"@type": "koral:term",
"foundry": "opennlp",
"key": "PIDAT",
"layer": "p",
"match": "match:eq"
},
{
"@type": "koral:term",
"foundry": "opennlp",
"key": "AdjType",
"layer": "p",
"match": "match:eq",
"value": "Pdt"
}
],
"relation": "relation:and",
"rewrites": [
{
"@type": "koral:rewrite",
"editor": "Koral-Mapper",
"original": {
"@type": "koral:term",
"foundry": "opennlp",
"key": "PIDAT",
"layer": "p",
"match": "match:eq"
}
}
]
}
}`,
},
{
name: "B to A mapping",
mapID: "test-mapper",
direction: "btoa",
input: `{
"@type": "koral:token",
"wrap": {
"@type": "koral:termGroup",
"operands": [
{
"@type": "koral:term",
"foundry": "opennlp",
"key": "PIDAT",
"layer": "p",
"match": "match:eq"
},
{
"@type": "koral:term",
"foundry": "opennlp",
"key": "AdjType",
"layer": "p",
"match": "match:eq",
"value": "Pdt"
}
],
"relation": "relation:and"
}
}`,
expectedCode: http.StatusOK,
expectedBody: `{
"@type": "koral:token",
"wrap": {
"@type": "koral:term",
"foundry": "opennlp",
"key": "PIDAT",
"layer": "p",
"match": "match:eq",
"rewrites": [
{
"@type": "koral:rewrite",
"editor": "Koral-Mapper",
"original": {
"@type": "koral:termGroup",
"operands": [
{
"@type": "koral:term",
"foundry": "opennlp",
"key": "PIDAT",
"layer": "p",
"match": "match:eq"
},
{
"@type": "koral:term",
"foundry": "opennlp",
"key": "AdjType",
"layer": "p",
"match": "match:eq",
"value": "Pdt"
}
],
"relation": "relation:and"
}
}
]
}
}`,
},
{
name: "Mapping with foundry override",
mapID: "test-mapper",
direction: "atob",
foundryB: "custom",
input: `{
"@type": "koral:token",
"wrap": {
"@type": "koral:term",
"foundry": "opennlp",
"key": "PIDAT",
"layer": "p",
"match": "match:eq"
}
}`,
expectedCode: http.StatusOK,
expectedBody: `{
"@type": "koral:token",
"wrap": {
"@type": "koral:termGroup",
"operands": [
{
"@type": "koral:term",
"foundry": "custom",
"key": "PIDAT",
"layer": "p",
"match": "match:eq"
},
{
"@type": "koral:term",
"foundry": "custom",
"key": "AdjType",
"layer": "p",
"match": "match:eq",
"value": "Pdt"
}
],
"relation": "relation:and",
"rewrites": [
{
"@type": "koral:rewrite",
"editor": "Koral-Mapper",
"original": {
"@type": "koral:term",
"foundry": "opennlp",
"key": "PIDAT",
"layer": "p",
"match": "match:eq"
}
}
]
}
}`,
},
{
name: "Invalid mapping ID",
mapID: "nonexistent",
direction: "atob",
input: `{"@type": "koral:token"}`,
expectedCode: http.StatusInternalServerError,
expectedError: "mapping list with ID nonexistent not found",
},
{
name: "Invalid direction",
mapID: "test-mapper",
direction: "invalid",
input: `{"@type": "koral:token"}`,
expectedCode: http.StatusBadRequest,
expectedError: "invalid direction, must be 'atob' or 'btoa'",
},
{
name: "Invalid JSON",
mapID: "test-mapper",
direction: "atob",
input: `invalid json`,
expectedCode: http.StatusBadRequest,
expectedError: "invalid JSON in request body",
},
}
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, bytes.NewBufferString(tt.input))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
// Check status code
assert.Equal(t, tt.expectedCode, resp.StatusCode)
// Read response body
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
if tt.expectedError != "" {
// Check error message
var errResp fiber.Map
err = json.Unmarshal(body, &errResp)
require.NoError(t, err)
assert.Equal(t, tt.expectedError, errResp["error"])
} else {
// Compare JSON responses
var expected, actual any
err = json.Unmarshal([]byte(tt.expectedBody), &expected)
require.NoError(t, err)
err = json.Unmarshal(body, &actual)
require.NoError(t, err)
assert.Equal(t, expected, actual)
}
})
}
}
func TestResponseTransformEndpoint(t *testing.T) {
cfg := loadConfigFromYAML(t, `
lists:
- id: test-response-mapper
foundryA: marmot
layerA: m
foundryB: opennlp
layerB: p
mappings:
- "[gender:masc] <> [p=M & m=M]"
`)
// Create mapper
m, err := mapper.NewMapper(cfg.Lists)
require.NoError(t, err)
// Create fiber app
app := fiber.New()
setupRoutes(app, m, cfg)
tests := []struct {
name string
mapID string
direction string
foundryA string
foundryB string
layerA string
layerB string
input string
expectedCode int
expectedBody string
expectedError string
}{
{
name: "Simple response mapping with snippet transformation",
mapID: "test-response-mapper",
direction: "atob",
input: `{
"snippet": "<span title=\"marmot/m:gender:masc\">Der</span>"
}`,
expectedCode: http.StatusOK,
expectedBody: `{
"snippet": "<span title=\"marmot/m:gender:masc\"><span title=\"opennlp/p:M\" class=\"notinindex\"><span title=\"opennlp/m:M\" class=\"notinindex\">Der</span></span></span>"
}`,
},
{
name: "Response with no snippet field",
mapID: "test-response-mapper",
direction: "atob",
input: `{
"@type": "koral:response",
"meta": {
"version": "Krill-0.64.1"
}
}`,
expectedCode: http.StatusOK,
expectedBody: `{
"@type": "koral:response",
"meta": {
"version": "Krill-0.64.1"
}
}`,
},
{
name: "Response with null snippet",
mapID: "test-response-mapper",
direction: "atob",
input: `{
"snippet": null
}`,
expectedCode: http.StatusOK,
expectedBody: `{
"snippet": null
}`,
},
{
name: "Response with non-string snippet",
mapID: "test-response-mapper",
direction: "atob",
input: `{
"snippet": 123
}`,
expectedCode: http.StatusOK,
expectedBody: `{
"snippet": 123
}`,
},
{
name: "Response mapping with foundry override",
mapID: "test-response-mapper",
direction: "atob",
foundryB: "custom",
input: `{
"snippet": "<span title=\"marmot/m:gender:masc\">Der</span>"
}`,
expectedCode: http.StatusOK,
expectedBody: `{
"snippet": "<span title=\"marmot/m:gender:masc\"><span title=\"custom/p:M\" class=\"notinindex\"><span title=\"custom/m:M\" class=\"notinindex\">Der</span></span></span>"
}`,
},
{
name: "Invalid mapping ID for response",
mapID: "nonexistent",
direction: "atob",
input: `{"snippet": "<span>test</span>"}`,
expectedCode: http.StatusInternalServerError,
expectedError: "mapping list with ID nonexistent not found",
},
{
name: "Invalid direction for response",
mapID: "test-response-mapper",
direction: "invalid",
input: `{"snippet": "<span>test</span>"}`,
expectedCode: http.StatusBadRequest,
expectedError: "invalid direction, must be 'atob' or 'btoa'",
},
{
name: "Invalid JSON for response",
mapID: "test-response-mapper",
direction: "atob",
input: `{invalid json}`,
expectedCode: http.StatusBadRequest,
expectedError: "invalid JSON in request body",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Build URL with query parameters
url := "/" + tt.mapID + "/response"
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, bytes.NewBufferString(tt.input))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
// Check status code
assert.Equal(t, tt.expectedCode, resp.StatusCode)
// Read response body
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
if tt.expectedError != "" {
// Check error message
var errResp fiber.Map
err = json.Unmarshal(body, &errResp)
require.NoError(t, err)
assert.Equal(t, tt.expectedError, errResp["error"])
} else {
// Compare JSON responses
var expected, actual any
err = json.Unmarshal([]byte(tt.expectedBody), &expected)
require.NoError(t, err)
err = json.Unmarshal(body, &actual)
require.NoError(t, err)
assert.Equal(t, expected, actual)
}
})
}
}
func TestHealthEndpoint(t *testing.T) {
// Create test mapping list
mappingList := tmconfig.MappingList{
ID: "test-mapper",
Mappings: []tmconfig.MappingRule{
"[A] <> [B]",
},
}
// Create mapper
m, err := mapper.NewMapper([]tmconfig.MappingList{mappingList})
require.NoError(t, err)
// Create mock config for testing
mockConfig := &tmconfig.MappingConfig{
Lists: []tmconfig.MappingList{mappingList},
}
// Create fiber app
app := fiber.New()
setupRoutes(app, m, mockConfig)
// Test health endpoint
req := httptest.NewRequest(http.MethodGet, "/health", nil)
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t, "OK", string(body))
req = httptest.NewRequest(http.MethodGet, "/", nil)
resp, err = app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Contains(t, string(body), "Koral-Mapper")
}
func TestKalamarPluginWithCustomSdkAndServer(t *testing.T) {
// Create test mapping list
mappingList := tmconfig.MappingList{
ID: "test-mapper",
Mappings: []tmconfig.MappingRule{
"[A] <> [B]",
},
}
// Create mapper
m, err := mapper.NewMapper([]tmconfig.MappingList{mappingList})
require.NoError(t, err)
tests := []struct {
name string
customSDK string
customServer string
expectedSDK string
expectedServer string
}{
{
name: "Custom SDK and Server values",
customSDK: "https://custom.example.com/custom-sdk.js",
customServer: "https://custom.example.com/",
expectedSDK: "https://custom.example.com/custom-sdk.js",
expectedServer: "https://custom.example.com/",
},
{
name: "Only custom SDK value",
customSDK: "https://custom.example.com/custom-sdk.js",
customServer: "https://korap.ids-mannheim.de/", // defaults applied during parsing
expectedSDK: "https://custom.example.com/custom-sdk.js",
expectedServer: "https://korap.ids-mannheim.de/",
},
{
name: "Only custom Server value",
customSDK: "https://korap.ids-mannheim.de/js/korap-plugin-latest.js", // defaults applied during parsing
customServer: "https://custom.example.com/",
expectedSDK: "https://korap.ids-mannheim.de/js/korap-plugin-latest.js",
expectedServer: "https://custom.example.com/",
},
{
name: "Defaults applied during parsing",
customSDK: "https://korap.ids-mannheim.de/js/korap-plugin-latest.js", // defaults applied during parsing
customServer: "https://korap.ids-mannheim.de/", // defaults applied during parsing
expectedSDK: "https://korap.ids-mannheim.de/js/korap-plugin-latest.js",
expectedServer: "https://korap.ids-mannheim.de/",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create mock config with custom values
mockConfig := &tmconfig.MappingConfig{
SDK: tt.customSDK,
Server: tt.customServer,
Lists: []tmconfig.MappingList{mappingList},
}
// Create fiber app
app := fiber.New()
setupRoutes(app, m, mockConfig)
// Test Kalamar plugin endpoint
req := httptest.NewRequest(http.MethodGet, "/", nil)
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
htmlContent := string(body)
// Check that the HTML contains the expected SDK and Server values
assert.Contains(t, htmlContent, `src="`+tt.expectedSDK+`"`)
assert.Contains(t, htmlContent, `data-server="`+tt.expectedServer+`"`)
// Ensure it's still a valid HTML page
assert.Contains(t, htmlContent, "Koral-Mapper")
assert.Contains(t, htmlContent, "<!DOCTYPE html>")
})
}
}
func TestMultipleMappingFiles(t *testing.T) {
// Create test mapping files
mappingFile1Content := `
id: test-mapper-1
foundryA: opennlp
layerA: p
foundryB: upos
layerB: p
mappings:
- "[PIDAT] <> [DET & AdjType=Pdt]"
- "[PAV] <> [ADV & PronType=Dem]"
`
mappingFile1, err := os.CreateTemp("", "mapping1-*.yaml")
require.NoError(t, err)
defer os.Remove(mappingFile1.Name())
_, err = mappingFile1.WriteString(mappingFile1Content)
require.NoError(t, err)
err = mappingFile1.Close()
require.NoError(t, err)
mappingFile2Content := `
id: test-mapper-2
foundryA: stts
layerA: p
foundryB: upos
layerB: p
mappings:
- "[DET] <> [PRON]"
- "[ADJ] <> [NOUN]"
`
mappingFile2, err := os.CreateTemp("", "mapping2-*.yaml")
require.NoError(t, err)
defer os.Remove(mappingFile2.Name())
_, err = mappingFile2.WriteString(mappingFile2Content)
require.NoError(t, err)
err = mappingFile2.Close()
require.NoError(t, err)
// Load configuration using multiple mapping files
config, err := tmconfig.LoadFromSources("", []string{mappingFile1.Name(), mappingFile2.Name()})
require.NoError(t, err)
// Create mapper
m, err := mapper.NewMapper(config.Lists)
require.NoError(t, err)
// Create fiber app
app := fiber.New()
setupRoutes(app, m, config)
// Test that both mappers work
testCases := []struct {
name string
mapID string
input string
expectGroup bool
expectedKey string
}{
{
name: "test-mapper-1 with complex mapping",
mapID: "test-mapper-1",
input: `{
"@type": "koral:token",
"wrap": {
"@type": "koral:term",
"foundry": "opennlp",
"key": "PIDAT",
"layer": "p",
"match": "match:eq"
}
}`,
expectGroup: true, // This mapping creates a termGroup because of "&"
expectedKey: "DET", // The first operand should be DET
},
{
name: "test-mapper-2 with simple mapping",
mapID: "test-mapper-2",
input: `{
"@type": "koral:token",
"wrap": {
"@type": "koral:term",
"foundry": "stts",
"key": "DET",
"layer": "p",
"match": "match:eq"
}
}`,
expectGroup: false, // This mapping creates a simple term
expectedKey: "PRON",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/"+tc.mapID+"/query?dir=atob", bytes.NewBufferString(tc.input))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]any
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
// Check that the mapping was applied
wrap := result["wrap"].(map[string]any)
if tc.expectGroup {
// For complex mappings, check the first operand
assert.Equal(t, "koral:termGroup", wrap["@type"])
operands := wrap["operands"].([]any)
require.Greater(t, len(operands), 0)
firstOperand := operands[0].(map[string]any)
assert.Equal(t, tc.expectedKey, firstOperand["key"])
} else {
// For simple mappings, check the key directly
assert.Equal(t, "koral:term", wrap["@type"])
assert.Equal(t, tc.expectedKey, wrap["key"])
}
})
}
}
func TestCombinedConfigAndMappingFiles(t *testing.T) {
// Create main config file
mainConfigContent := `
sdk: "https://custom.example.com/sdk.js"
server: "https://custom.example.com/"
lists:
- id: main-mapper
foundryA: opennlp
layerA: p
mappings:
- "[A] <> [B]"
`
mainConfigFile, err := os.CreateTemp("", "main-config-*.yaml")
require.NoError(t, err)
defer os.Remove(mainConfigFile.Name())
_, err = mainConfigFile.WriteString(mainConfigContent)
require.NoError(t, err)
err = mainConfigFile.Close()
require.NoError(t, err)
// Create individual mapping file
mappingFileContent := `
id: additional-mapper
foundryA: stts
layerA: p
mappings:
- "[C] <> [D]"
`
mappingFile, err := os.CreateTemp("", "mapping-*.yaml")
require.NoError(t, err)
defer os.Remove(mappingFile.Name())
_, err = mappingFile.WriteString(mappingFileContent)
require.NoError(t, err)
err = mappingFile.Close()
require.NoError(t, err)
// Load configuration from both sources
config, err := tmconfig.LoadFromSources(mainConfigFile.Name(), []string{mappingFile.Name()})
require.NoError(t, err)
// Verify that both mappers are loaded
require.Len(t, config.Lists, 2)
ids := make([]string, len(config.Lists))
for i, list := range config.Lists {
ids[i] = list.ID
}
assert.Contains(t, ids, "main-mapper")
assert.Contains(t, ids, "additional-mapper")
// Verify custom SDK and server are preserved from main config
assert.Equal(t, "https://custom.example.com/sdk.js", config.SDK)
assert.Equal(t, "https://custom.example.com/", config.Server)
// Create mapper and test it works
m, err := mapper.NewMapper(config.Lists)
require.NoError(t, err)
require.NotNil(t, m)
}
func TestExpandGlobs(t *testing.T) {
// Create a temporary directory for test files
tempDir, err := os.MkdirTemp("", "glob_test_*")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
// Create test files with .yaml and .yml extensions
testFiles := []struct {
name string
content string
}{
{
name: "mapper1.yaml",
content: `
id: test-mapper-1
mappings:
- "[A] <> [B]"
`,
},
{
name: "mapper2.yml",
content: `
id: test-mapper-2
mappings:
- "[C] <> [D]"
`,
},
{
name: "mapper3.yaml",
content: `
id: test-mapper-3
mappings:
- "[E] <> [F]"
`,
},
{
name: "other.txt",
content: "not a yaml file",
},
}
for _, file := range testFiles {
filePath := filepath.Join(tempDir, file.name)
err := os.WriteFile(filePath, []byte(file.content), 0644)
require.NoError(t, err)
}
tests := []struct {
name string
patterns []string
expected []string
expectErr bool
}{
{
name: "Single literal file",
patterns: []string{filepath.Join(tempDir, "mapper1.yaml")},
expected: []string{filepath.Join(tempDir, "mapper1.yaml")},
},
{
name: "Multiple literal files",
patterns: []string{filepath.Join(tempDir, "mapper1.yaml"), filepath.Join(tempDir, "mapper2.yml")},
expected: []string{filepath.Join(tempDir, "mapper1.yaml"), filepath.Join(tempDir, "mapper2.yml")},
},
{
name: "Glob pattern for yaml files",
patterns: []string{filepath.Join(tempDir, "*.yaml")},
expected: []string{filepath.Join(tempDir, "mapper1.yaml"), filepath.Join(tempDir, "mapper3.yaml")},
},
{
name: "Glob pattern for yml files",
patterns: []string{filepath.Join(tempDir, "*.yml")},
expected: []string{filepath.Join(tempDir, "mapper2.yml")},
},
{
name: "Glob pattern for all yaml/yml files",
patterns: []string{filepath.Join(tempDir, "*.y*ml")},
expected: []string{
filepath.Join(tempDir, "mapper1.yaml"),
filepath.Join(tempDir, "mapper2.yml"),
filepath.Join(tempDir, "mapper3.yaml"),
},
},
{
name: "Mixed literal and glob",
patterns: []string{filepath.Join(tempDir, "mapper1.yaml"), filepath.Join(tempDir, "*.yml")},
expected: []string{filepath.Join(tempDir, "mapper1.yaml"), filepath.Join(tempDir, "mapper2.yml")},
},
{
name: "No matches - treats as literal",
patterns: []string{filepath.Join(tempDir, "nonexistent*.yaml")},
expected: []string{filepath.Join(tempDir, "nonexistent*.yaml")},
},
{
name: "Invalid glob pattern",
patterns: []string{filepath.Join(tempDir, "[")},
expectErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := expandGlobs(tt.patterns)
if tt.expectErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
// Sort both slices for comparison since glob results may not be in consistent order
sort.Strings(result)
sort.Strings(tt.expected)
assert.Equal(t, tt.expected, result)
})
}
}
func TestGlobMappingFileLoading(t *testing.T) {
// Create a temporary directory for test files
tempDir, err := os.MkdirTemp("", "glob_mapping_test_*")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
// Create test mapping files
testFiles := []struct {
name string
content string
}{
{
name: "pos-mapper.yaml",
content: `
id: pos-mapper
foundryA: opennlp
layerA: p
foundryB: upos
layerB: p
mappings:
- "[PIDAT] <> [DET]"
- "[ADJA] <> [ADJ]"
`,
},
{
name: "ner-mapper.yml",
content: `
id: ner-mapper
foundryA: opennlp
layerA: ner
foundryB: upos
layerB: ner
mappings:
- "[PER] <> [PERSON]"
- "[LOC] <> [LOCATION]"
`,
},
{
name: "special-mapper.yaml",
content: `
id: special-mapper
mappings:
- "[X] <> [Y]"
`,
},
}
for _, file := range testFiles {
filePath := filepath.Join(tempDir, file.name)
err := os.WriteFile(filePath, []byte(file.content), 0644)
require.NoError(t, err)
}
tests := []struct {
name string
configFile string
mappingPattern string
expectedIDs []string
}{
{
name: "Load all yaml files",
mappingPattern: filepath.Join(tempDir, "*.yaml"),
expectedIDs: []string{"pos-mapper", "special-mapper"},
},
{
name: "Load all yml files",
mappingPattern: filepath.Join(tempDir, "*.yml"),
expectedIDs: []string{"ner-mapper"},
},
{
name: "Load all yaml/yml files",
mappingPattern: filepath.Join(tempDir, "*-mapper.y*ml"),
expectedIDs: []string{"pos-mapper", "ner-mapper", "special-mapper"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Expand the glob pattern
expanded, err := expandGlobs([]string{tt.mappingPattern})
require.NoError(t, err)
// Load configuration using the expanded file list
config, err := tmconfig.LoadFromSources(tt.configFile, expanded)
require.NoError(t, err)
// Verify that the expected mappers are loaded
require.Len(t, config.Lists, len(tt.expectedIDs))
actualIDs := make([]string, len(config.Lists))
for i, list := range config.Lists {
actualIDs[i] = list.ID
}
// Sort both slices for comparison
sort.Strings(actualIDs)
sort.Strings(tt.expectedIDs)
assert.Equal(t, tt.expectedIDs, actualIDs)
// Create mapper to ensure all loaded configs are valid
m, err := mapper.NewMapper(config.Lists)
require.NoError(t, err)
require.NotNil(t, m)
})
}
}
func TestGlobErrorHandling(t *testing.T) {
tests := []struct {
name string
patterns []string
expectErr bool
}{
{
name: "Empty patterns",
patterns: []string{},
expectErr: false, // Should return empty slice, no error
},
{
name: "Invalid glob pattern",
patterns: []string{"["},
expectErr: true,
},
{
name: "Valid and invalid mixed",
patterns: []string{"valid.yaml", "["},
expectErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := expandGlobs(tt.patterns)
if tt.expectErr {
assert.Error(t, err)
assert.Nil(t, result)
} else {
assert.NoError(t, err)
if len(tt.patterns) == 0 {
assert.Empty(t, result)
}
}
})
}
}
func TestGlobIntegrationWithTestData(t *testing.T) {
// Test that our glob functionality works with the actual testdata files
// This ensures the feature works end-to-end in a realistic scenario
// Expand glob pattern for the example mapper files
expanded, err := expandGlobs([]string{"../../testdata/example-mapper*.yaml"})
require.NoError(t, err)
// Should match exactly the two mapper files
sort.Strings(expanded)
assert.Len(t, expanded, 2)
assert.Contains(t, expanded[0], "example-mapper1.yaml")
assert.Contains(t, expanded[1], "example-mapper2.yaml")
// Load configuration using the expanded files
config, err := tmconfig.LoadFromSources("", expanded)
require.NoError(t, err)
// Verify that both mappers are loaded correctly
require.Len(t, config.Lists, 2)
// Get the IDs to verify they match the expected ones
actualIDs := make([]string, len(config.Lists))
for i, list := range config.Lists {
actualIDs[i] = list.ID
}
sort.Strings(actualIDs)
expectedIDs := []string{"example-mapper-1", "example-mapper-2"}
assert.Equal(t, expectedIDs, actualIDs)
// Create mapper to ensure everything works
m, err := mapper.NewMapper(config.Lists)
require.NoError(t, err)
require.NotNil(t, m)
// Test that the mapper actually works with a real transformation
app := fiber.New()
setupRoutes(app, m, config)
// Test a transformation from example-mapper-1
testInput := `{
"@type": "koral:token",
"wrap": {
"@type": "koral:term",
"foundry": "opennlp",
"key": "PIDAT",
"layer": "p",
"match": "match:eq"
}
}`
req := httptest.NewRequest(http.MethodPost, "/example-mapper-1/query?dir=atob", bytes.NewBufferString(testInput))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]any
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
// Verify the transformation was applied
wrap := result["wrap"].(map[string]any)
assert.Equal(t, "koral:termGroup", wrap["@type"])
operands := wrap["operands"].([]any)
require.Greater(t, len(operands), 0)
firstOperand := operands[0].(map[string]any)
assert.Equal(t, "DET", firstOperand["key"])
}
func TestConfigurableServiceURL(t *testing.T) {
// Create test mapping list
mappingList := tmconfig.MappingList{
ID: "test-mapper",
Mappings: []tmconfig.MappingRule{
"[A] <> [B]",
},
}
tests := []struct {
name string
customServiceURL string
expectedServiceURL string
}{
{
name: "Custom service URL",
customServiceURL: "https://custom.example.com/plugin/koralmapper",
expectedServiceURL: "https://custom.example.com/plugin/koralmapper",
},
{
name: "Default service URL when not specified",
customServiceURL: "", // Will use default
expectedServiceURL: "https://korap.ids-mannheim.de/plugin/koralmapper",
},
{
name: "Custom service URL with different path",
customServiceURL: "https://my-server.org/api/v1/koralmapper",
expectedServiceURL: "https://my-server.org/api/v1/koralmapper",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create mapper
m, err := mapper.NewMapper([]tmconfig.MappingList{mappingList})
require.NoError(t, err)
// Create mock config with custom service URL
mockConfig := &tmconfig.MappingConfig{
ServiceURL: tt.customServiceURL,
Lists: []tmconfig.MappingList{mappingList},
}
// Apply defaults to simulate the real loading process
tmconfig.ApplyDefaults(mockConfig)
// Create fiber app
app := fiber.New()
setupRoutes(app, m, mockConfig)
// Test Kalamar plugin endpoint with a specific mapID
req := httptest.NewRequest(http.MethodGet, "/test-mapper", nil)
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
htmlContent := string(body)
// html/template applies JS string escaping (/ -> \/, & -> \u0026)
expectedJSURL := jsEscapeURL(tt.expectedServiceURL + "/test-mapper/query")
assert.Contains(t, htmlContent, "'service' : '"+expectedJSURL)
// Ensure it's still a valid HTML page
assert.Contains(t, htmlContent, "Koral-Mapper")
assert.Contains(t, htmlContent, "<!DOCTYPE html>")
})
}
}
func TestServiceURLConfigFileLoading(t *testing.T) {
// Create a temporary config file with custom service URL
configContent := `
sdk: "https://custom.example.com/sdk.js"
server: "https://custom.example.com/"
serviceURL: "https://custom.example.com/api/koralmapper"
lists:
- id: config-mapper
mappings:
- "[X] <> [Y]"
`
configFile, err := os.CreateTemp("", "service-url-config-*.yaml")
require.NoError(t, err)
defer os.Remove(configFile.Name())
_, err = configFile.WriteString(configContent)
require.NoError(t, err)
err = configFile.Close()
require.NoError(t, err)
// Load configuration from file
config, err := tmconfig.LoadFromSources(configFile.Name(), nil)
require.NoError(t, err)
// Verify that the service URL was loaded correctly
assert.Equal(t, "https://custom.example.com/api/koralmapper", config.ServiceURL)
// Verify other fields are also preserved
assert.Equal(t, "https://custom.example.com/sdk.js", config.SDK)
assert.Equal(t, "https://custom.example.com/", config.Server)
// Create mapper and test the service URL is used in the HTML
m, err := mapper.NewMapper(config.Lists)
require.NoError(t, err)
app := fiber.New()
setupRoutes(app, m, config)
req := httptest.NewRequest(http.MethodGet, "/config-mapper", nil)
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
htmlContent := string(body)
expectedJSURL := jsEscapeURL("https://custom.example.com/api/koralmapper/config-mapper/query")
assert.Contains(t, htmlContent, "'service' : '"+expectedJSURL)
}
func TestServiceURLDefaults(t *testing.T) {
// Test that defaults are applied correctly when creating a config
config := &tmconfig.MappingConfig{
Lists: []tmconfig.MappingList{
{
ID: "test",
Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
},
},
}
// Apply defaults (simulating what happens during loading)
tmconfig.ApplyDefaults(config)
// Check that the default service URL was applied
assert.Equal(t, "https://korap.ids-mannheim.de/plugin/koralmapper", config.ServiceURL)
// Check that other defaults were also applied
assert.Equal(t, "https://korap.ids-mannheim.de/", config.Server)
assert.Equal(t, "https://korap.ids-mannheim.de/js/korap-plugin-latest.js", config.SDK)
assert.Equal(t, "https://korap.ids-mannheim.de/css/kalamar-plugin-latest.css", config.Stylesheet)
assert.Equal(t, 5725, config.Port)
assert.Equal(t, "warn", config.LogLevel)
}
func TestServiceURLWithExampleConfig(t *testing.T) {
// Test that the actual example config file works with the new serviceURL functionality
// and that defaults are properly applied when serviceURL is not specified
config, err := tmconfig.LoadFromSources("../../testdata/example-config.yaml", nil)
require.NoError(t, err)
// Verify that the default service URL was applied since it's not in the example config
assert.Equal(t, "https://korap.ids-mannheim.de/plugin/koralmapper", config.ServiceURL)
// Verify other values from the example config are preserved
assert.Equal(t, "https://korap.ids-mannheim.de/js/korap-plugin-latest.js", config.SDK)
assert.Equal(t, "https://korap.ids-mannheim.de/css/kalamar-plugin-latest.css", config.Stylesheet)
assert.Equal(t, "https://korap.ids-mannheim.de/", config.Server)
// Verify the mapper was loaded correctly
require.Len(t, config.Lists, 1)
assert.Equal(t, "main-config-mapper", config.Lists[0].ID)
// Create mapper and test that the service URL is used correctly in the HTML
m, err := mapper.NewMapper(config.Lists)
require.NoError(t, err)
app := fiber.New()
setupRoutes(app, m, config)
req := httptest.NewRequest(http.MethodGet, "/main-config-mapper", nil)
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
htmlContent := string(body)
expectedJSURL := jsEscapeURL("https://korap.ids-mannheim.de/plugin/koralmapper/main-config-mapper/query")
assert.Contains(t, htmlContent, "'service' : '"+expectedJSURL)
}
func TestBuildMapServiceURLWithURLJoining(t *testing.T) {
tests := []struct {
name string
serviceURL string
mapID string
endpoint string
expected string
}{
{
name: "Service URL without trailing slash",
serviceURL: "https://example.com/plugin/koralmapper",
mapID: "test-mapper",
endpoint: "query",
expected: "https://example.com/plugin/koralmapper/test-mapper/query?dir=atob",
},
{
name: "Service URL with trailing slash",
serviceURL: "https://example.com/plugin/koralmapper/",
mapID: "test-mapper",
endpoint: "query",
expected: "https://example.com/plugin/koralmapper/test-mapper/query?dir=atob",
},
{
name: "Map ID with leading slash",
serviceURL: "https://example.com/plugin/koralmapper",
mapID: "/test-mapper",
endpoint: "query",
expected: "https://example.com/plugin/koralmapper/test-mapper/query?dir=atob",
},
{
name: "Both with slashes",
serviceURL: "https://example.com/plugin/koralmapper/",
mapID: "/test-mapper",
endpoint: "query",
expected: "https://example.com/plugin/koralmapper/test-mapper/query?dir=atob",
},
{
name: "Complex map ID",
serviceURL: "https://example.com/api/v1/",
mapID: "complex-mapper-name_123",
endpoint: "query",
expected: "https://example.com/api/v1/complex-mapper-name_123/query?dir=atob",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := buildMapServiceURL(tt.serviceURL, tt.mapID, tt.endpoint, QueryParams{
Dir: "atob",
FoundryA: "",
FoundryB: "",
LayerA: "",
LayerB: "",
})
require.NoError(t, err)
assert.Equal(t, tt.expected, got)
})
}
}
func TestKalamarPluginWithQueryParameters(t *testing.T) {
// Create test mapping list
mappingList := tmconfig.MappingList{
ID: "test-mapper",
Mappings: []tmconfig.MappingRule{
"[A] <> [B]",
},
}
// Create mapper
m, err := mapper.NewMapper([]tmconfig.MappingList{mappingList})
require.NoError(t, err)
// Create mock config
mockConfig := &tmconfig.MappingConfig{
ServiceURL: "https://example.com/plugin/koralmapper",
Lists: []tmconfig.MappingList{mappingList},
}
// Apply defaults
tmconfig.ApplyDefaults(mockConfig)
// Create fiber app
app := fiber.New()
setupRoutes(app, m, mockConfig)
tests := []struct {
name string
url string
expectedQueryURL string
expectedRespURL string
expectedStatus int
expectedError string
}{
{
name: "Default parameters (no query params)",
url: "/test-mapper",
expectedQueryURL: "https://example.com/plugin/koralmapper/test-mapper/query?dir=atob",
expectedRespURL: "https://example.com/plugin/koralmapper/test-mapper/response?dir=btoa",
expectedStatus: http.StatusOK,
},
{
name: "Explicit dir=atob",
url: "/test-mapper?dir=atob",
expectedQueryURL: "https://example.com/plugin/koralmapper/test-mapper/query?dir=atob",
expectedRespURL: "https://example.com/plugin/koralmapper/test-mapper/response?dir=btoa",
expectedStatus: http.StatusOK,
},
{
name: "Explicit dir=btoa",
url: "/test-mapper?dir=btoa",
expectedQueryURL: "https://example.com/plugin/koralmapper/test-mapper/query?dir=btoa",
expectedRespURL: "https://example.com/plugin/koralmapper/test-mapper/response?dir=atob",
expectedStatus: http.StatusOK,
},
{
name: "With foundry parameters",
url: "/test-mapper?dir=atob&foundryA=opennlp&foundryB=upos",
expectedQueryURL: "https://example.com/plugin/koralmapper/test-mapper/query?dir=atob&foundryA=opennlp&foundryB=upos",
expectedRespURL: "https://example.com/plugin/koralmapper/test-mapper/response?dir=btoa&foundryA=opennlp&foundryB=upos",
expectedStatus: http.StatusOK,
},
{
name: "With layer parameters",
url: "/test-mapper?dir=btoa&layerA=pos&layerB=upos",
expectedQueryURL: "https://example.com/plugin/koralmapper/test-mapper/query?dir=btoa&layerA=pos&layerB=upos",
expectedRespURL: "https://example.com/plugin/koralmapper/test-mapper/response?dir=atob&layerA=pos&layerB=upos",
expectedStatus: http.StatusOK,
},
{
name: "All parameters",
url: "/test-mapper?dir=atob&foundryA=opennlp&foundryB=upos&layerA=pos&layerB=upos",
expectedQueryURL: "https://example.com/plugin/koralmapper/test-mapper/query?dir=atob&foundryA=opennlp&foundryB=upos&layerA=pos&layerB=upos",
expectedRespURL: "https://example.com/plugin/koralmapper/test-mapper/response?dir=btoa&foundryA=opennlp&foundryB=upos&layerA=pos&layerB=upos",
expectedStatus: http.StatusOK,
},
{
name: "Invalid direction",
url: "/test-mapper?dir=invalid",
expectedStatus: http.StatusBadRequest,
expectedError: "invalid direction, must be 'atob' or 'btoa'",
},
{
name: "Parameter too long",
url: "/test-mapper?foundryA=" + strings.Repeat("a", 1025),
expectedStatus: http.StatusBadRequest,
expectedError: "foundryA too long (max 1024 bytes)",
},
{
name: "Invalid characters in parameter",
url: "/test-mapper?foundryA=invalid<>chars",
expectedStatus: http.StatusBadRequest,
expectedError: "foundryA contains invalid characters",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tt.url, nil)
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, tt.expectedStatus, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
if tt.expectedError != "" {
// Check error message
var errResp fiber.Map
err = json.Unmarshal(body, &errResp)
require.NoError(t, err)
assert.Equal(t, tt.expectedError, errResp["error"])
} else {
htmlContent := string(body)
// html/template applies JS string escaping in script contexts
assert.Contains(t, htmlContent, "'service' : '"+jsEscapeURL(tt.expectedQueryURL)+"'")
assert.Contains(t, htmlContent, "'service' : '"+jsEscapeURL(tt.expectedRespURL)+"'")
// Ensure it's still a valid HTML page
assert.Contains(t, htmlContent, "Koral-Mapper")
assert.Contains(t, htmlContent, "<!DOCTYPE html>")
}
})
}
}
func TestBuildQueryParams(t *testing.T) {
tests := []struct {
name string
dir string
foundryA string
foundryB string
layerA string
layerB string
expected string
}{
{
name: "Only direction parameter",
dir: "atob",
expected: "dir=atob",
},
{
name: "All parameters",
dir: "btoa",
foundryA: "opennlp",
foundryB: "upos",
layerA: "pos",
layerB: "upos",
expected: "dir=btoa&foundryA=opennlp&foundryB=upos&layerA=pos&layerB=upos",
},
{
name: "Some parameters empty",
dir: "atob",
foundryA: "opennlp",
foundryB: "",
layerA: "pos",
layerB: "",
expected: "dir=atob&foundryA=opennlp&layerA=pos",
},
{
name: "All parameters empty",
dir: "",
foundryA: "",
foundryB: "",
layerA: "",
layerB: "",
expected: "",
},
{
name: "URL encoding needed",
dir: "atob",
foundryA: "test space",
foundryB: "test&special",
expected: "dir=atob&foundryA=test+space&foundryB=test%26special",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := buildQueryParams(tt.dir, tt.foundryA, tt.foundryB, tt.layerA, tt.layerB)
assert.Equal(t, tt.expected, result)
})
}
}
func TestCompositeQueryEndpoint(t *testing.T) {
cfg := loadConfigFromYAML(t, `
lists:
- id: step1
foundryA: opennlp
layerA: p
foundryB: stts
layerB: p
rewrites: true
mappings:
- "[PIDAT] <> [DET]"
- id: step2
foundryA: stts
layerA: p
foundryB: upos
layerB: p
rewrites: true
mappings:
- "[DET] <> [PRON]"
`)
m, err := mapper.NewMapper(cfg.Lists)
require.NoError(t, err)
app := fiber.New()
setupRoutes(app, m, cfg)
tests := []struct {
name string
url string
input string
expectedCode int
expected any
}{
{
name: "cascades two query mappings",
url: "/query/step1:atob;step2:atob",
expectedCode: http.StatusOK,
input: `{
"@type": "koral:token",
"wrap": {
"@type": "koral:term",
"foundry": "opennlp",
"key": "PIDAT",
"layer": "p",
"match": "match:eq"
}
}`,
expected: map[string]any{
"@type": "koral:token",
"wrap": map[string]any{
"@type": "koral:term",
"foundry": "upos",
"key": "PRON",
"layer": "p",
"match": "match:eq",
"rewrites": []any{
map[string]any{
"@type": "koral:rewrite",
"editor": "Koral-Mapper",
"original": map[string]any{
"@type": "koral:term",
"foundry": "opennlp",
"key": "PIDAT",
"layer": "p",
"match": "match:eq",
},
},
map[string]any{
"@type": "koral:rewrite",
"editor": "Koral-Mapper",
"original": map[string]any{
"@type": "koral:term",
"foundry": "stts",
"key": "DET",
"layer": "p",
"match": "match:eq",
},
},
},
},
},
},
{
name: "invalid cfg returns bad request",
url: "/query/missing:atob",
expectedCode: http.StatusBadRequest,
input: `{"@type": "koral:token"}`,
expected: map[string]any{
"error": `unknown mapping ID "missing"`,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, tt.url, bytes.NewBufferString(tt.input))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, tt.expectedCode, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var actual any
err = json.Unmarshal(body, &actual)
require.NoError(t, err)
assert.Equal(t, tt.expected, actual)
})
}
}
func TestCompositeResponseEndpoint(t *testing.T) {
cfg := loadConfigFromYAML(t, `
lists:
- id: resp-step1
type: corpus
mappings:
- "textClass=novel <> genre=fiction"
- id: resp-step2
type: corpus
mappings:
- "genre=fiction <> category=lit"
`)
m, err := mapper.NewMapper(cfg.Lists)
require.NoError(t, err)
app := fiber.New()
setupRoutes(app, m, cfg)
tests := []struct {
name string
url string
input string
expectedCode int
assertBody func(t *testing.T, actual map[string]any)
}{
{
name: "cascades two response mappings",
url: "/response/resp-step1:atob;resp-step2:atob",
expectedCode: http.StatusOK,
input: `{
"fields": [{
"@type": "koral:field",
"key": "textClass",
"value": "novel",
"type": "type:string"
}]
}`,
assertBody: func(t *testing.T, actual map[string]any) {
fields := actual["fields"].([]any)
require.Len(t, fields, 3)
assert.Equal(t, "textClass", fields[0].(map[string]any)["key"])
assert.Equal(t, "genre", fields[1].(map[string]any)["key"])
assert.Equal(t, "fiction", fields[1].(map[string]any)["value"])
assert.Equal(t, "category", fields[2].(map[string]any)["key"])
assert.Equal(t, "lit", fields[2].(map[string]any)["value"])
},
},
{
name: "invalid cfg returns bad request",
url: "/response/resp-step1",
expectedCode: http.StatusBadRequest,
input: `{"fields": []}`,
assertBody: func(t *testing.T, actual map[string]any) {
assert.Contains(t, actual["error"], "expected at least 2 colon-separated fields")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, tt.url, bytes.NewBufferString(tt.input))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, tt.expectedCode, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var actual map[string]any
err = json.Unmarshal(body, &actual)
require.NoError(t, err)
tt.assertBody(t, actual)
})
}
}
func TestEmbeddedFilesExist(t *testing.T) {
files := []string{"static/config.html", "static/plugin.html", "static/config.js", "static/style.css"}
for _, f := range files {
t.Run(f, func(t *testing.T) {
data, err := fs.ReadFile(staticFS, f)
require.NoError(t, err, "embedded file %s should exist", f)
assert.NotEmpty(t, data, "embedded file %s should not be empty", f)
})
}
}
func TestConfigTemplateParsesSuccessfully(t *testing.T) {
tmpl, err := template.ParseFS(staticFS, "static/config.html")
require.NoError(t, err, "config template should parse without error")
require.NotNil(t, tmpl)
}
func TestStaticFileServing(t *testing.T) {
mappingList := tmconfig.MappingList{
ID: "test-mapper",
Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
}
m, err := mapper.NewMapper([]tmconfig.MappingList{mappingList})
require.NoError(t, err)
mockConfig := &tmconfig.MappingConfig{Lists: []tmconfig.MappingList{mappingList}}
tmconfig.ApplyDefaults(mockConfig)
app := fiber.New()
setupRoutes(app, m, mockConfig)
tests := []struct {
name string
url string
expectedCode int
expectedCType string
}{
{
name: "config.js is served with correct content type",
url: "/static/config.js",
expectedCode: http.StatusOK,
expectedCType: "text/javascript",
},
{
name: "style.css is served with correct content type",
url: "/static/style.css",
expectedCode: http.StatusOK,
expectedCType: "text/css",
},
{
name: "non-existent static file returns 404",
url: "/static/nonexistent.txt",
expectedCode: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tt.url, nil)
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, tt.expectedCode, resp.StatusCode)
if tt.expectedCType != "" {
assert.Contains(t, resp.Header.Get("Content-Type"), tt.expectedCType)
}
})
}
}
func TestStaticFileContent(t *testing.T) {
mappingList := tmconfig.MappingList{
ID: "test-mapper",
Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
}
m, err := mapper.NewMapper([]tmconfig.MappingList{mappingList})
require.NoError(t, err)
mockConfig := &tmconfig.MappingConfig{Lists: []tmconfig.MappingList{mappingList}}
tmconfig.ApplyDefaults(mockConfig)
app := fiber.New()
setupRoutes(app, m, mockConfig)
// Verify served content matches embedded content
files := []string{"config.js", "style.css"}
for _, f := range files {
t.Run(f, func(t *testing.T) {
embedded, err := fs.ReadFile(staticFS, "static/"+f)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/static/"+f, nil)
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t, embedded, body, "served content should match embedded content for %s", f)
})
}
}
func TestConfigPageRendering(t *testing.T) {
lists := []tmconfig.MappingList{
{
ID: "anno-mapper",
Description: "Annotation mapping",
FoundryA: "opennlp",
LayerA: "p",
FoundryB: "upos",
LayerB: "p",
Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
},
{
ID: "corpus-mapper",
Type: "corpus",
Description: "Corpus mapping",
FieldA: "wikiCat",
FieldB: "textClass",
Mappings: []tmconfig.MappingRule{"textClass=science <> textClass=akademisch"},
},
}
m, err := mapper.NewMapper(lists)
require.NoError(t, err)
mockConfig := &tmconfig.MappingConfig{
SDK: "https://example.com/sdk.js",
Stylesheet: "https://example.com/kalamar.css",
Server: "https://example.com/",
ServiceURL: "https://example.com/plugin/koralmapper",
Lists: lists,
}
app := fiber.New()
setupRoutes(app, m, mockConfig)
req := httptest.NewRequest(http.MethodGet, "/", nil)
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
htmlContent := string(body)
// HTML structure
assert.Contains(t, htmlContent, "<!DOCTYPE html>")
assert.Contains(t, htmlContent, `<meta charset="UTF-8">`)
assert.Contains(t, htmlContent, "Koral-Mapper")
// SDK and server
assert.Contains(t, htmlContent, `src="https://example.com/sdk.js"`)
assert.Contains(t, htmlContent, `href="https://example.com/kalamar.css"`)
assert.Contains(t, htmlContent, `data-server="https://example.com/"`)
// ServiceURL as data attribute
assert.Contains(t, htmlContent, `data-service-url="https://example.com/plugin/koralmapper"`)
// Static file references
assert.Contains(t, htmlContent, `static/style.css`)
assert.Contains(t, htmlContent, `static/config.js`)
// Request/response sections
assert.Contains(t, htmlContent, "<legend>Request</legend>")
assert.Contains(t, htmlContent, "<legend>Response</legend>")
// Annotation mapping entries
assert.Contains(t, htmlContent, "query")
assert.Contains(t, htmlContent, "anno-mapper")
assert.Contains(t, htmlContent, `data-id="anno-mapper"`)
assert.Contains(t, htmlContent, `data-type="annotation"`)
assert.Contains(t, htmlContent, `placeholder="opennlp"`)
assert.Contains(t, htmlContent, `placeholder="upos"`)
assert.Contains(t, htmlContent, "Annotation mapping")
// Corpus mapping entries
assert.Contains(t, htmlContent, "corpus")
assert.Contains(t, htmlContent, "<strong>corpus-mapper</strong>")
assert.Contains(t, htmlContent, `data-id="corpus-mapper"`)
assert.Contains(t, htmlContent, `data-type="corpus"`)
assert.Contains(t, htmlContent, `data-default-field-a="wikiCat"`)
assert.Contains(t, htmlContent, `data-default-field-b="textClass"`)
assert.Contains(t, htmlContent, `class="request-fieldA"`)
assert.Contains(t, htmlContent, `class="request-fieldB"`)
assert.Contains(t, htmlContent, `class="response-fieldA"`)
assert.Contains(t, htmlContent, `class="response-fieldB"`)
assert.Contains(t, htmlContent, "Corpus mapping")
// Reset button
assert.Contains(t, htmlContent, `id="reset-btn"`)
assert.Contains(t, htmlContent, "Reset all")
}
func TestConfigPageAnnotationMappingHasFoundryInputs(t *testing.T) {
lists := []tmconfig.MappingList{
{
ID: "anno-mapper",
FoundryA: "opennlp",
LayerA: "p",
FoundryB: "upos",
LayerB: "pos",
Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
},
}
m, err := mapper.NewMapper(lists)
require.NoError(t, err)
mockConfig := &tmconfig.MappingConfig{Lists: lists}
tmconfig.ApplyDefaults(mockConfig)
app := fiber.New()
setupRoutes(app, m, mockConfig)
req := httptest.NewRequest(http.MethodGet, "/", nil)
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
htmlContent := string(body)
// Data attributes for default values
assert.Contains(t, htmlContent, `data-default-foundry-a="opennlp"`)
assert.Contains(t, htmlContent, `data-default-layer-a="p"`)
assert.Contains(t, htmlContent, `data-default-foundry-b="upos"`)
assert.Contains(t, htmlContent, `data-default-layer-b="pos"`)
// Input fields with request/response-specific CSS classes
assert.Contains(t, htmlContent, `class="request-foundryA"`)
assert.Contains(t, htmlContent, `class="request-layerA"`)
assert.Contains(t, htmlContent, `class="request-foundryB"`)
assert.Contains(t, htmlContent, `class="request-layerB"`)
assert.Contains(t, htmlContent, `class="response-foundryA"`)
assert.Contains(t, htmlContent, `class="response-layerA"`)
assert.Contains(t, htmlContent, `class="response-foundryB"`)
assert.Contains(t, htmlContent, `class="response-layerB"`)
// Direction arrows are independent for request/response
assert.Contains(t, htmlContent, `class="request-dir-arrow"`)
assert.Contains(t, htmlContent, `class="response-dir-arrow"`)
assert.Contains(t, htmlContent, `data-dir="atob"`)
assert.Contains(t, htmlContent, `data-dir="btoa"`)
// Request and response checkboxes
assert.Contains(t, htmlContent, `class="checkbox request-cb"`)
assert.Contains(t, htmlContent, `class="checkbox response-cb"`)
}
func TestConfigPageCorpusMappingHasFieldAndDirectionInputs(t *testing.T) {
lists := []tmconfig.MappingList{
{
ID: "corpus-mapper",
Type: "corpus",
FieldA: "genre",
FieldB: "topic",
Mappings: []tmconfig.MappingRule{"textClass=science <> textClass=akademisch"},
},
}
m, err := mapper.NewMapper(lists)
require.NoError(t, err)
mockConfig := &tmconfig.MappingConfig{Lists: lists}
tmconfig.ApplyDefaults(mockConfig)
app := fiber.New()
setupRoutes(app, m, mockConfig)
req := httptest.NewRequest(http.MethodGet, "/", nil)
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
htmlContent := string(body)
// Corpus section exists
assert.Contains(t, htmlContent, `data-id="corpus-mapper"`)
assert.Contains(t, htmlContent, `data-type="corpus"`)
// Checkboxes present
assert.Contains(t, htmlContent, `class="checkbox request-cb"`)
assert.Contains(t, htmlContent, `class="checkbox response-cb"`)
// No annotation foundry/layer inputs (only corpus mappings)
assert.NotContains(t, htmlContent, `class="request-foundryA"`)
assert.NotContains(t, htmlContent, `class="request-layerA"`)
assert.Contains(t, htmlContent, `class="request-dir-arrow"`)
assert.Contains(t, htmlContent, `class="response-dir-arrow"`)
assert.Contains(t, htmlContent, `class="request-fieldA"`)
assert.Contains(t, htmlContent, `class="request-fieldB"`)
assert.Contains(t, htmlContent, `class="response-fieldA"`)
assert.Contains(t, htmlContent, `class="response-fieldB"`)
assert.Contains(t, htmlContent, `placeholder="genre"`)
assert.Contains(t, htmlContent, `placeholder="topic"`)
}
// TestPluginPageEscapesMapID verifies that the plugin page (GET /:map)
// properly HTML-escapes the map ID to prevent XSS via URL path injection.
func TestPluginPageEscapesMapID(t *testing.T) {
mappingList := tmconfig.MappingList{
ID: "test-mapper",
Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
}
m, err := mapper.NewMapper([]tmconfig.MappingList{mappingList})
require.NoError(t, err)
mockConfig := &tmconfig.MappingConfig{
ServiceURL: "https://example.com/plugin/koralmapper",
Lists: []tmconfig.MappingList{mappingList},
}
tmconfig.ApplyDefaults(mockConfig)
app := fiber.New()
setupRoutes(app, m, mockConfig)
// Use a map ID that contains HTML/JS injection payload
maliciousMapID := `"><script>alert(1)</script>`
req := httptest.NewRequest(http.MethodGet, "/"+maliciousMapID, nil)
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
// The request may fail validation (contains invalid chars), which is also acceptable.
// If it renders, the output must not contain unescaped script tags.
if resp.StatusCode == http.StatusOK {
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
htmlContent := string(body)
// The raw script tag must NOT appear in the output
assert.NotContains(t, htmlContent, "<script>alert(1)</script>")
// If rendered, it should be escaped
assert.Contains(t, htmlContent, "&lt;script&gt;")
}
}
// TestPluginPageEscapesServiceURL verifies that the plugin page properly
// escapes ServiceURL values to prevent template injection.
func TestPluginPageEscapesServiceURL(t *testing.T) {
mappingList := tmconfig.MappingList{
ID: "test-mapper",
Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
}
m, err := mapper.NewMapper([]tmconfig.MappingList{mappingList})
require.NoError(t, err)
// Inject a ServiceURL with HTML-special characters
mockConfig := &tmconfig.MappingConfig{
ServiceURL: `https://example.com/plugin" onload="alert(1)`,
Lists: []tmconfig.MappingList{mappingList},
}
tmconfig.ApplyDefaults(mockConfig)
app := fiber.New()
setupRoutes(app, m, mockConfig)
req := httptest.NewRequest(http.MethodGet, "/test-mapper", nil)
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
htmlContent := string(body)
// The unescaped injection must NOT appear in the output
assert.NotContains(t, htmlContent, `onload="alert(1)"`)
}
func TestConfigPageBackwardCompatibility(t *testing.T) {
lists := []tmconfig.MappingList{
{
ID: "test-mapper",
Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
},
}
m, err := mapper.NewMapper(lists)
require.NoError(t, err)
mockConfig := &tmconfig.MappingConfig{
ServiceURL: "https://example.com/plugin/koralmapper",
Lists: lists,
}
tmconfig.ApplyDefaults(mockConfig)
app := fiber.New()
setupRoutes(app, m, mockConfig)
req := httptest.NewRequest(http.MethodGet, "/test-mapper", nil)
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
htmlContent := string(body)
// Old-style single-mapping page behavior
assert.Contains(t, htmlContent, "<!DOCTYPE html>")
assert.Contains(t, htmlContent, "Koral-Mapper")
assert.Contains(t, htmlContent, "Map ID: test-mapper")
assert.Contains(t, htmlContent, "KorAPlugin.sendMsg")
assert.Contains(t, htmlContent, `test-mapper\/query`)
}
func TestBuildConfigPageData(t *testing.T) {
lists := []tmconfig.MappingList{
{
ID: "anno1",
Description: "First annotation",
FoundryA: "f1a",
LayerA: "l1a",
FoundryB: "f1b",
LayerB: "l1b",
Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
},
{
ID: "corpus1",
Type: "corpus",
Description: "First corpus",
Mappings: []tmconfig.MappingRule{"textClass=a <> textClass=b"},
},
{
ID: "anno2",
Type: "annotation",
Description: "Second annotation",
FoundryA: "f2a",
LayerA: "l2a",
FoundryB: "f2b",
LayerB: "l2b",
Mappings: []tmconfig.MappingRule{"[C] <> [D]"},
},
}
mockConfig := &tmconfig.MappingConfig{
SDK: "https://example.com/sdk.js",
Stylesheet: "https://example.com/kalamar.css",
Server: "https://example.com/",
ServiceURL: "https://example.com/service",
Lists: lists,
}
data := buildConfigPageData(mockConfig)
assert.Equal(t, "https://example.com/sdk.js", data.SDK)
assert.Equal(t, "https://example.com/kalamar.css", data.Stylesheet)
assert.Equal(t, "https://example.com/", data.Server)
assert.Equal(t, "https://example.com/service", data.ServiceURL)
require.Len(t, data.AnnotationMappings, 2)
assert.Equal(t, "anno1", data.AnnotationMappings[0].ID)
assert.Equal(t, "annotation", data.AnnotationMappings[0].Type)
assert.Equal(t, "f1a", data.AnnotationMappings[0].FoundryA)
assert.Equal(t, "First annotation", data.AnnotationMappings[0].Description)
assert.Equal(t, "anno2", data.AnnotationMappings[1].ID)
assert.Equal(t, "annotation", data.AnnotationMappings[1].Type)
require.Len(t, data.CorpusMappings, 1)
assert.Equal(t, "corpus1", data.CorpusMappings[0].ID)
assert.Equal(t, "corpus", data.CorpusMappings[0].Type)
assert.Equal(t, "First corpus", data.CorpusMappings[0].Description)
}
func TestConfigPageDefaultsAsPlaceholdersOnly(t *testing.T) {
lists := []tmconfig.MappingList{
{
ID: "anno-mapper",
FoundryA: "opennlp",
LayerA: "p",
FoundryB: "upos",
LayerB: "pos",
Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
},
{
ID: "corpus-mapper",
Type: "corpus",
FieldA: "genre",
FieldB: "topic",
Mappings: []tmconfig.MappingRule{
"textClass=science <> textClass=akademisch",
},
},
}
m, err := mapper.NewMapper(lists)
require.NoError(t, err)
mockConfig := &tmconfig.MappingConfig{Lists: lists}
tmconfig.ApplyDefaults(mockConfig)
app := fiber.New()
setupRoutes(app, m, mockConfig)
req := httptest.NewRequest(http.MethodGet, "/", nil)
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
htmlContent := string(body)
// Placeholders are present
assert.Contains(t, htmlContent, `placeholder="opennlp"`)
assert.Contains(t, htmlContent, `placeholder="upos"`)
assert.Contains(t, htmlContent, `placeholder="genre"`)
assert.Contains(t, htmlContent, `placeholder="topic"`)
// Value attributes must NOT appear on mapping inputs (defaults shown as
// placeholders only). We check that the combined string value="opennlp"
// etc. is absent.
assert.NotContains(t, htmlContent, `value="opennlp"`)
assert.NotContains(t, htmlContent, `value="upos"`)
assert.NotContains(t, htmlContent, `value="genre"`)
assert.NotContains(t, htmlContent, `value="topic"`)
}
func TestConfigPageHasResetButton(t *testing.T) {
lists := []tmconfig.MappingList{
{
ID: "test-mapper",
Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
},
}
m, err := mapper.NewMapper(lists)
require.NoError(t, err)
mockConfig := &tmconfig.MappingConfig{Lists: lists}
tmconfig.ApplyDefaults(mockConfig)
app := fiber.New()
setupRoutes(app, m, mockConfig)
req := httptest.NewRequest(http.MethodGet, "/", nil)
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
htmlContent := string(body)
assert.Contains(t, htmlContent, `id="reset-btn"`)
assert.Contains(t, htmlContent, "Reset all")
assert.Contains(t, htmlContent, `type="button"`)
}
func TestAddRewritesDefaultOff(t *testing.T) {
cfg := loadConfigFromYAML(t, `
lists:
- id: test-mapper
foundryA: opennlp
layerA: p
foundryB: upos
layerB: p
mappings:
- "[PIDAT] <> [DET]"
`)
m, err := mapper.NewMapper(cfg.Lists)
require.NoError(t, err)
app := fiber.New()
setupRoutes(app, m, cfg)
input := `{
"@type": "koral:token",
"wrap": {
"@type": "koral:term",
"foundry": "opennlp",
"key": "PIDAT",
"layer": "p",
"match": "match:eq"
}
}`
req := httptest.NewRequest(http.MethodPost, "/test-mapper/query?dir=atob", bytes.NewBufferString(input))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]any
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
wrap := result["wrap"].(map[string]any)
assert.Equal(t, "DET", wrap["key"])
assert.Nil(t, wrap["rewrites"], "rewrites should not be present by default")
}
func TestAddRewritesEnabledViaYAML(t *testing.T) {
cfg := loadConfigFromYAML(t, `
lists:
- id: test-mapper
foundryA: opennlp
layerA: p
foundryB: upos
layerB: p
rewrites: true
mappings:
- "[PIDAT] <> [DET]"
`)
m, err := mapper.NewMapper(cfg.Lists)
require.NoError(t, err)
app := fiber.New()
setupRoutes(app, m, cfg)
input := `{
"@type": "koral:token",
"wrap": {
"@type": "koral:term",
"foundry": "opennlp",
"key": "PIDAT",
"layer": "p",
"match": "match:eq"
}
}`
req := httptest.NewRequest(http.MethodPost, "/test-mapper/query?dir=atob", bytes.NewBufferString(input))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]any
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
wrap := result["wrap"].(map[string]any)
assert.Equal(t, "DET", wrap["key"])
assert.NotNil(t, wrap["rewrites"], "rewrites should be present when rewrites are enabled in YAML")
}
func TestAddRewritesQueryParamOverridesYAML(t *testing.T) {
cfg := loadConfigFromYAML(t, `
lists:
- id: test-mapper
foundryA: opennlp
layerA: p
foundryB: upos
layerB: p
rewrites: true
mappings:
- "[PIDAT] <> [DET]"
`)
m, err := mapper.NewMapper(cfg.Lists)
require.NoError(t, err)
app := fiber.New()
setupRoutes(app, m, cfg)
input := `{
"@type": "koral:token",
"wrap": {
"@type": "koral:term",
"foundry": "opennlp",
"key": "PIDAT",
"layer": "p",
"match": "match:eq"
}
}`
// Override YAML rewrites=true with query param rewrites=false
req := httptest.NewRequest(http.MethodPost, "/test-mapper/query?dir=atob&rewrites=false", bytes.NewBufferString(input))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]any
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
wrap := result["wrap"].(map[string]any)
assert.Equal(t, "DET", wrap["key"])
assert.Nil(t, wrap["rewrites"], "rewrites should be suppressed by query param override")
}
func TestAddRewritesQueryParamEnablesWhenYAMLOff(t *testing.T) {
cfg := loadConfigFromYAML(t, `
lists:
- id: test-mapper
foundryA: opennlp
layerA: p
foundryB: upos
layerB: p
mappings:
- "[PIDAT] <> [DET]"
`)
m, err := mapper.NewMapper(cfg.Lists)
require.NoError(t, err)
app := fiber.New()
setupRoutes(app, m, cfg)
input := `{
"@type": "koral:token",
"wrap": {
"@type": "koral:term",
"foundry": "opennlp",
"key": "PIDAT",
"layer": "p",
"match": "match:eq"
}
}`
req := httptest.NewRequest(http.MethodPost, "/test-mapper/query?dir=atob&rewrites=true", bytes.NewBufferString(input))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]any
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
wrap := result["wrap"].(map[string]any)
assert.Equal(t, "DET", wrap["key"])
assert.NotNil(t, wrap["rewrites"], "rewrites should be present when enabled by query param")
}
// TestSecurityHeadersPresent verifies that all HTTP responses include
// security headers to mitigate MIME-sniffing and referrer leaks.
// X-Frame-Options is intentionally NOT set because the service is
// embedded in cross-origin iframes as a Kalamar plugin.
func TestSecurityHeadersPresent(t *testing.T) {
mappingList := tmconfig.MappingList{
ID: "test-mapper",
Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
}
m, err := mapper.NewMapper([]tmconfig.MappingList{mappingList})
require.NoError(t, err)
mockConfig := &tmconfig.MappingConfig{Lists: []tmconfig.MappingList{mappingList}}
tmconfig.ApplyDefaults(mockConfig)
app := fiber.New()
setupRoutes(app, m, mockConfig)
endpoints := []struct {
method string
url string
body string
}{
{http.MethodGet, "/health", ""},
{http.MethodGet, "/", ""},
{http.MethodPost, "/test-mapper/query?dir=atob", `{"@type":"koral:token","wrap":{"@type":"koral:term","foundry":"x","key":"A","layer":"p","match":"match:eq"}}`},
}
for _, ep := range endpoints {
t.Run(ep.method+" "+ep.url, func(t *testing.T) {
var req *http.Request
if ep.body != "" {
req = httptest.NewRequest(ep.method, ep.url, bytes.NewBufferString(ep.body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(ep.method, ep.url, nil)
}
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"),
"X-Content-Type-Options header must be set to nosniff")
assert.Equal(t, "strict-origin-when-cross-origin", resp.Header.Get("Referrer-Policy"),
"Referrer-Policy header must be set")
assert.Empty(t, resp.Header.Get("X-Frame-Options"),
"X-Frame-Options must NOT be set (cross-origin iframe embedding)")
})
}
}
// TestRateLimitingEnforced verifies that the server applies per-client rate
// limiting and returns HTTP 429 when the limit is exceeded.
func TestRateLimitingEnforced(t *testing.T) {
mappingList := tmconfig.MappingList{
ID: "test-mapper",
Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
}
m, err := mapper.NewMapper([]tmconfig.MappingList{mappingList})
require.NoError(t, err)
mockConfig := &tmconfig.MappingConfig{Lists: []tmconfig.MappingList{mappingList}}
tmconfig.ApplyDefaults(mockConfig)
app := fiber.New()
setupRoutes(app, m, mockConfig)
// The default rate limit is 100/min. Send more requests than that
// to verify enforcement.
var lastStatus int
exceeded := false
for i := 0; i < 150; i++ {
req := httptest.NewRequest(http.MethodGet, "/health", nil)
resp, err := app.Test(req)
require.NoError(t, err)
resp.Body.Close()
lastStatus = resp.StatusCode
if lastStatus == fiber.StatusTooManyRequests {
exceeded = true
break
}
}
assert.True(t, exceeded, "rate limiter should return 429 when limit is exceeded, last status was %d", lastStatus)
}
// TestRateLimitingConfigurable verifies that the rate limit can be customized
// via the rateLimit configuration field.
func TestRateLimitingConfigurable(t *testing.T) {
mappingList := tmconfig.MappingList{
ID: "test-mapper",
Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
}
m, err := mapper.NewMapper([]tmconfig.MappingList{mappingList})
require.NoError(t, err)
// Set a very low rate limit to make testing fast
mockConfig := &tmconfig.MappingConfig{
RateLimit: 5,
Lists: []tmconfig.MappingList{mappingList},
}
tmconfig.ApplyDefaults(mockConfig)
app := fiber.New()
setupRoutes(app, m, mockConfig)
// With a limit of 5, the 6th request should be rejected
for i := 0; i < 5; i++ {
req := httptest.NewRequest(http.MethodGet, "/health", nil)
resp, err := app.Test(req)
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode,
"request %d should succeed within the rate limit", i+1)
}
// The next request should exceed the limit
req := httptest.NewRequest(http.MethodGet, "/health", nil)
resp, err := app.Test(req)
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, fiber.StatusTooManyRequests, resp.StatusCode,
"request beyond the configured limit should return 429")
}
// TestRateLimitDefaultValue verifies the default rate limit is 100.
func TestRateLimitDefaultValue(t *testing.T) {
cfg := &tmconfig.MappingConfig{}
tmconfig.ApplyDefaults(cfg)
assert.Equal(t, 100, cfg.RateLimit, "default rate limit should be 100 requests per minute")
}
func TestConfigPagePreservesOrderOfMappings(t *testing.T) {
lists := []tmconfig.MappingList{
{
ID: "mapper-z",
FoundryA: "fa",
FoundryB: "fb",
Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
},
{
ID: "mapper-a",
FoundryA: "fa",
FoundryB: "fb",
Mappings: []tmconfig.MappingRule{"[C] <> [D]"},
},
{
ID: "mapper-m",
FoundryA: "fa",
FoundryB: "fb",
Mappings: []tmconfig.MappingRule{"[E] <> [F]"},
},
}
m, err := mapper.NewMapper(lists)
require.NoError(t, err)
mockConfig := &tmconfig.MappingConfig{Lists: lists}
tmconfig.ApplyDefaults(mockConfig)
app := fiber.New()
setupRoutes(app, m, mockConfig)
req := httptest.NewRequest(http.MethodGet, "/", nil)
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
htmlContent := string(body)
// Verify the order is preserved (z before a before m)
idxZ := strings.Index(htmlContent, `data-id="mapper-z"`)
idxA := strings.Index(htmlContent, `data-id="mapper-a"`)
idxM := strings.Index(htmlContent, `data-id="mapper-m"`)
assert.Greater(t, idxA, idxZ, "mapper-a should appear after mapper-z")
assert.Greater(t, idxM, idxA, "mapper-m should appear after mapper-a")
}