From 7d95cf6472fbf5b6713a037b52a466e25cef650c Mon Sep 17 00:00:00 2001 From: oliverpool Date: Tue, 9 Apr 2024 11:19:52 +0200 Subject: [PATCH 1/4] webhook: add org tests --- tests/integration/repo_webhook_test.go | 169 +++++++++++++------------ 1 file changed, 89 insertions(+), 80 deletions(-) diff --git a/tests/integration/repo_webhook_test.go b/tests/integration/repo_webhook_test.go index 3375c0f1e..1898c3597 100644 --- a/tests/integration/repo_webhook_test.go +++ b/tests/integration/repo_webhook_test.go @@ -288,99 +288,108 @@ func assertInput(t testing.TB, form *goquery.Selection, name string) string { func testWebhookForms(name string, session *TestSession, validFields map[string]string, invalidPatches ...map[string]string) func(t *testing.T) { return func(t *testing.T) { - // new webhook form - resp := session.MakeRequest(t, NewRequest(t, "GET", "/user2/repo1/settings/hooks/"+name+"/new"), http.StatusOK) - htmlForm := NewHTMLParser(t, resp.Body).Find(`form[action^="/user2/repo1/settings/hooks/"]`) + t.Run("repo1", func(t *testing.T) { + testWebhookFormsShared(t, "/user2/repo1/settings/hooks", name, session, validFields, invalidPatches...) + }) + t.Run("org3", func(t *testing.T) { + testWebhookFormsShared(t, "/org/org3/settings/hooks", name, session, validFields, invalidPatches...) + }) + } +} - // fill the form - payload := map[string]string{ - "_csrf": htmlForm.Find(`input[name="_csrf"]`).AttrOr("value", ""), - "events": "send_everything", - } - for k, v := range validFields { - assertInput(t, htmlForm, k) - payload[k] = v - } - if t.Failed() { - t.FailNow() // prevent further execution if the form could not be filled properly - } +func testWebhookFormsShared(t *testing.T, endpoint, name string, session *TestSession, validFields map[string]string, invalidPatches ...map[string]string) { + // new webhook form + resp := session.MakeRequest(t, NewRequest(t, "GET", endpoint+"/"+name+"/new"), http.StatusOK) + htmlForm := NewHTMLParser(t, resp.Body).Find(`form[action^="` + endpoint + `/"]`) - // create the webhook (this redirects back to the hook list) - resp = session.MakeRequest(t, NewRequestWithValues(t, "POST", "/user2/repo1/settings/hooks/"+name+"/new", payload), http.StatusSeeOther) - assertHasFlashMessages(t, resp, "success") + // fill the form + payload := map[string]string{ + "_csrf": htmlForm.Find(`input[name="_csrf"]`).AttrOr("value", ""), + "events": "send_everything", + } + for k, v := range validFields { + assertInput(t, htmlForm, k) + payload[k] = v + } + if t.Failed() { + t.FailNow() // prevent further execution if the form could not be filled properly + } - // find last created hook in the hook list - // (a bit hacky, but the list should be sorted) - resp = session.MakeRequest(t, NewRequest(t, "GET", "/user2/repo1/settings/hooks"), http.StatusOK) - htmlDoc := NewHTMLParser(t, resp.Body) - editFormURL := htmlDoc.Find(`a[href^="/user2/repo1/settings/hooks/"]`).Last().AttrOr("href", "") - assert.NotEmpty(t, editFormURL) + // create the webhook (this redirects back to the hook list) + resp = session.MakeRequest(t, NewRequestWithValues(t, "POST", endpoint+"/"+name+"/new", payload), http.StatusSeeOther) + assertHasFlashMessages(t, resp, "success") - // edit webhook form - resp = session.MakeRequest(t, NewRequest(t, "GET", editFormURL), http.StatusOK) - htmlForm = NewHTMLParser(t, resp.Body).Find(`form[action^="/user2/repo1/settings/hooks/"]`) - editPostURL := htmlForm.AttrOr("action", "") - assert.NotEmpty(t, editPostURL) + // find last created hook in the hook list + // (a bit hacky, but the list should be sorted) + resp = session.MakeRequest(t, NewRequest(t, "GET", endpoint), http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + editFormURL := htmlDoc.Find(`a[href^="`+endpoint+`/"]`).Last().AttrOr("href", "") + assert.NotEmpty(t, editFormURL) - // fill the form - payload = map[string]string{ - "_csrf": htmlForm.Find(`input[name="_csrf"]`).AttrOr("value", ""), - "events": "push_only", - } - for k, v := range validFields { - assert.Equal(t, v, assertInput(t, htmlForm, k), "input %q did not contain value %q", k, v) - payload[k] = v - } + // edit webhook form + resp = session.MakeRequest(t, NewRequest(t, "GET", editFormURL), http.StatusOK) + htmlForm = NewHTMLParser(t, resp.Body).Find(`form[action^="` + endpoint + `/"]`) + editPostURL := htmlForm.AttrOr("action", "") + assert.NotEmpty(t, editPostURL) - // update the webhook - resp = session.MakeRequest(t, NewRequestWithValues(t, "POST", editPostURL, payload), http.StatusSeeOther) - assertHasFlashMessages(t, resp, "success") + // fill the form + payload = map[string]string{ + "_csrf": htmlForm.Find(`input[name="_csrf"]`).AttrOr("value", ""), + "events": "push_only", + } + for k, v := range validFields { + assert.Equal(t, v, assertInput(t, htmlForm, k), "input %q did not contain value %q", k, v) + payload[k] = v + } - // check the updated webhook - resp = session.MakeRequest(t, NewRequest(t, "GET", editFormURL), http.StatusOK) - htmlForm = NewHTMLParser(t, resp.Body).Find(`form[action^="/user2/repo1/settings/hooks/"]`) - for k, v := range validFields { - assert.Equal(t, v, assertInput(t, htmlForm, k), "input %q did not contain value %q", k, v) - } + // update the webhook + resp = session.MakeRequest(t, NewRequestWithValues(t, "POST", editPostURL, payload), http.StatusSeeOther) + assertHasFlashMessages(t, resp, "success") - if len(invalidPatches) > 0 { - // check that invalid fields are rejected - resp := session.MakeRequest(t, NewRequest(t, "GET", "/user2/repo1/settings/hooks/"+name+"/new"), http.StatusOK) - htmlForm := NewHTMLParser(t, resp.Body).Find(`form[action^="/user2/repo1/settings/hooks/"]`) + // check the updated webhook + resp = session.MakeRequest(t, NewRequest(t, "GET", editFormURL), http.StatusOK) + htmlForm = NewHTMLParser(t, resp.Body).Find(`form[action^="` + endpoint + `/"]`) + for k, v := range validFields { + assert.Equal(t, v, assertInput(t, htmlForm, k), "input %q did not contain value %q", k, v) + } - for _, invalidPatch := range invalidPatches { - t.Run("invalid", func(t *testing.T) { - // fill the form - payload := map[string]string{ - "_csrf": htmlForm.Find(`input[name="_csrf"]`).AttrOr("value", ""), - "events": "send_everything", - } - for k, v := range validFields { + if len(invalidPatches) > 0 { + // check that invalid fields are rejected + resp := session.MakeRequest(t, NewRequest(t, "GET", endpoint+"/"+name+"/new"), http.StatusOK) + htmlForm := NewHTMLParser(t, resp.Body).Find(`form[action^="` + endpoint + `/"]`) + + for _, invalidPatch := range invalidPatches { + t.Run("invalid", func(t *testing.T) { + // fill the form + payload := map[string]string{ + "_csrf": htmlForm.Find(`input[name="_csrf"]`).AttrOr("value", ""), + "events": "send_everything", + } + for k, v := range validFields { + payload[k] = v + } + for k, v := range invalidPatch { + if v == "" { + delete(payload, k) + } else { payload[k] = v } - for k, v := range invalidPatch { - if v == "" { - delete(payload, k) - } else { - payload[k] = v - } - } + } - resp := session.MakeRequest(t, NewRequestWithValues(t, "POST", "/user2/repo1/settings/hooks/"+name+"/new", payload), http.StatusUnprocessableEntity) - // check that the invalid form is pre-filled - htmlForm = NewHTMLParser(t, resp.Body).Find(`form[action^="/user2/repo1/settings/hooks/"]`) - for k, v := range payload { - if k == "_csrf" || k == "events" || v == "" { - // the 'events' is a radio input, which is buggy below - continue - } - assert.Equal(t, v, assertInput(t, htmlForm, k), "input %q did not contain value %q", k, v) + resp := session.MakeRequest(t, NewRequestWithValues(t, "POST", endpoint+"/"+name+"/new", payload), http.StatusUnprocessableEntity) + // check that the invalid form is pre-filled + htmlForm = NewHTMLParser(t, resp.Body).Find(`form[action^="` + endpoint + `/"]`) + for k, v := range payload { + if k == "_csrf" || k == "events" || v == "" { + // the 'events' is a radio input, which is buggy below + continue } - if t.Failed() { - t.Log(invalidPatch) - } - }) - } + assert.Equal(t, v, assertInput(t, htmlForm, k), "input %q did not contain value %q", k, v) + } + if t.Failed() { + t.Log(invalidPatch) + } + }) } } } From c1f7c498048bcc9afdca829cd64243ff52a43e23 Mon Sep 17 00:00:00 2001 From: oliverpool Date: Tue, 9 Apr 2024 11:58:19 +0200 Subject: [PATCH 2/4] webhook: add admin-hooks new list test --- tests/integration/repo_webhook_test.go | 47 ++++++++++++++++++++------ 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/tests/integration/repo_webhook_test.go b/tests/integration/repo_webhook_test.go index 1898c3597..4e8788f0b 100644 --- a/tests/integration/repo_webhook_test.go +++ b/tests/integration/repo_webhook_test.go @@ -7,7 +7,6 @@ import ( "net/http" "net/http/httptest" "net/url" - "strings" "testing" gitea_context "code.gitea.io/gitea/services/context" @@ -37,24 +36,52 @@ func TestNewWebHookLink(t *testing.T) { for _, url := range tests { resp := session.MakeRequest(t, NewRequest(t, "GET", url), http.StatusOK) htmlDoc := NewHTMLParser(t, resp.Body) - menus := htmlDoc.doc.Find(".ui.top.attached.header .ui.dropdown .menu a") - menus.Each(func(i int, menu *goquery.Selection) { - url, exist := menu.Attr("href") - assert.True(t, exist) - assert.True(t, strings.HasPrefix(url, baseurl)) - }) - assert.Equal(t, webhooksLen, htmlDoc.Find(`a[href^="`+baseurl+`/"][href$="/new"]`).Length(), "not all webhooks are listed in the 'new' dropdown") + assert.Equal(t, + webhooksLen, + htmlDoc.Find(`a[href^="`+baseurl+`/"][href$="/new"]`).Length(), + "not all webhooks are listed in the 'new' dropdown") + csrfToken = htmlDoc.GetCSRF() } // ensure that the "failure" pages has the full dropdown as well resp := session.MakeRequest(t, NewRequestWithValues(t, "POST", baseurl+"/gitea/new", map[string]string{"_csrf": csrfToken}), http.StatusUnprocessableEntity) htmlDoc := NewHTMLParser(t, resp.Body) - assert.Equal(t, webhooksLen, htmlDoc.Find(`a[href^="`+baseurl+`/"][href$="/new"]`).Length(), "not all webhooks are listed in the 'new' dropdown on failure") + assert.Equal(t, + webhooksLen, + htmlDoc.Find(`a[href^="`+baseurl+`/"][href$="/new"]`).Length(), + "not all webhooks are listed in the 'new' dropdown on failure") resp = session.MakeRequest(t, NewRequestWithValues(t, "POST", baseurl+"/1", map[string]string{"_csrf": csrfToken}), http.StatusUnprocessableEntity) htmlDoc = NewHTMLParser(t, resp.Body) - assert.Equal(t, webhooksLen, htmlDoc.Find(`a[href^="`+baseurl+`/"][href$="/new"]`).Length(), "not all webhooks are listed in the 'new' dropdown on failure") + assert.Equal(t, + webhooksLen, + htmlDoc.Find(`a[href^="`+baseurl+`/"][href$="/new"]`).Length(), + "not all webhooks are listed in the 'new' dropdown on failure") + + adminSession := loginUser(t, "user1") + t.Run("org3", func(t *testing.T) { + baseurl := "/org/org3/settings/hooks" + resp := adminSession.MakeRequest(t, NewRequest(t, "GET", baseurl), http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + assert.Equal(t, + webhooksLen, + htmlDoc.Find(`a[href^="`+baseurl+`/"][href$="/new"]`).Length(), + "not all webhooks are listed in the 'new' dropdown") + }) + t.Run("admin", func(t *testing.T) { + baseurl := "/admin/hooks" + resp := adminSession.MakeRequest(t, NewRequest(t, "GET", baseurl), http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + assert.Equal(t, + webhooksLen, + htmlDoc.Find(`a[href^="/admin/default-hooks/"][href$="/new"]`).Length(), + "not all webhooks are listed in the 'new' dropdown for default-hooks") + assert.Equal(t, + webhooksLen, + htmlDoc.Find(`a[href^="/admin/system-hooks/"][href$="/new"]`).Length(), + "not all webhooks are listed in the 'new' dropdown for system-hooks") + }) } func TestWebhookForms(t *testing.T) { From e0b5f2d59bd3b9ec3796c867982c07c76c4a4428 Mon Sep 17 00:00:00 2001 From: oliverpool Date: Tue, 9 Apr 2024 11:58:44 +0200 Subject: [PATCH 3/4] webhook: fix admin-hooks new dropdowns --- routers/web/admin/hooks.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/routers/web/admin/hooks.go b/routers/web/admin/hooks.go index 8d59fbb85..91857d275 100644 --- a/routers/web/admin/hooks.go +++ b/routers/web/admin/hooks.go @@ -11,6 +11,7 @@ import ( "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/services/context" + webhook_service "code.gitea.io/gitea/services/webhook" ) const ( @@ -38,6 +39,7 @@ func DefaultOrSystemWebhooks(ctx *context.Context) { sys["Webhooks"], err = webhook.GetSystemWebhooks(ctx, optional.None[bool]()) sys["BaseLink"] = setting.AppSubURL + "/admin/hooks" sys["BaseLinkNew"] = setting.AppSubURL + "/admin/system-hooks" + sys["WebhookList"] = webhook_service.List() if err != nil { ctx.ServerError("GetWebhooksAdmin", err) return @@ -48,6 +50,7 @@ func DefaultOrSystemWebhooks(ctx *context.Context) { def["Webhooks"], err = webhook.GetDefaultWebhooks(ctx) def["BaseLink"] = setting.AppSubURL + "/admin/hooks" def["BaseLinkNew"] = setting.AppSubURL + "/admin/default-hooks" + def["WebhookList"] = webhook_service.List() if err != nil { ctx.ServerError("GetWebhooksAdmin", err) return From 9a94019db4f5604a1923baa9b874a22adc4e7c5e Mon Sep 17 00:00:00 2001 From: oliverpool Date: Tue, 9 Apr 2024 11:59:10 +0200 Subject: [PATCH 4/4] webhook: add admin-hooks tests --- models/webhook/webhook_system.go | 20 ++++++++-------- routers/api/v1/admin/hooks.go | 3 +-- routers/web/admin/hooks.go | 3 +-- services/webhook/webhook.go | 2 +- tests/integration/repo_webhook_test.go | 32 +++++++++++++++++++++----- 5 files changed, 40 insertions(+), 20 deletions(-) diff --git a/models/webhook/webhook_system.go b/models/webhook/webhook_system.go index a2a9ee321..62e828620 100644 --- a/models/webhook/webhook_system.go +++ b/models/webhook/webhook_system.go @@ -8,15 +8,11 @@ import ( "fmt" "code.gitea.io/gitea/models/db" - "code.gitea.io/gitea/modules/optional" ) // GetDefaultWebhooks returns all admin-default webhooks. func GetDefaultWebhooks(ctx context.Context) ([]*Webhook, error) { - webhooks := make([]*Webhook, 0, 5) - return webhooks, db.GetEngine(ctx). - Where("repo_id=? AND owner_id=? AND is_system_webhook=?", 0, 0, false). - Find(&webhooks) + return getAdminWebhooks(ctx, false) } // GetSystemOrDefaultWebhook returns admin system or default webhook by given ID. @@ -34,15 +30,21 @@ func GetSystemOrDefaultWebhook(ctx context.Context, id int64) (*Webhook, error) } // GetSystemWebhooks returns all admin system webhooks. -func GetSystemWebhooks(ctx context.Context, isActive optional.Option[bool]) ([]*Webhook, error) { +func GetSystemWebhooks(ctx context.Context, onlyActive bool) ([]*Webhook, error) { + return getAdminWebhooks(ctx, true, onlyActive) +} + +func getAdminWebhooks(ctx context.Context, systemWebhooks bool, onlyActive ...bool) ([]*Webhook, error) { webhooks := make([]*Webhook, 0, 5) - if !isActive.Has() { + if len(onlyActive) > 0 && onlyActive[0] { return webhooks, db.GetEngine(ctx). - Where("repo_id=? AND owner_id=? AND is_system_webhook=?", 0, 0, true). + Where("repo_id=? AND owner_id=? AND is_system_webhook=? AND is_active = ?", 0, 0, systemWebhooks, true). + OrderBy("id"). Find(&webhooks) } return webhooks, db.GetEngine(ctx). - Where("repo_id=? AND owner_id=? AND is_system_webhook=? AND is_active = ?", 0, 0, true, isActive.Value()). + Where("repo_id=? AND owner_id=? AND is_system_webhook=?", 0, 0, systemWebhooks). + OrderBy("id"). Find(&webhooks) } diff --git a/routers/api/v1/admin/hooks.go b/routers/api/v1/admin/hooks.go index 4c168b55b..b246cb61b 100644 --- a/routers/api/v1/admin/hooks.go +++ b/routers/api/v1/admin/hooks.go @@ -8,7 +8,6 @@ import ( "net/http" "code.gitea.io/gitea/models/webhook" - "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" @@ -38,7 +37,7 @@ func ListHooks(ctx *context.APIContext) { // "200": // "$ref": "#/responses/HookList" - sysHooks, err := webhook.GetSystemWebhooks(ctx, optional.None[bool]()) + sysHooks, err := webhook.GetSystemWebhooks(ctx, false) if err != nil { ctx.Error(http.StatusInternalServerError, "GetSystemWebhooks", err) return diff --git a/routers/web/admin/hooks.go b/routers/web/admin/hooks.go index 91857d275..c1f42c006 100644 --- a/routers/web/admin/hooks.go +++ b/routers/web/admin/hooks.go @@ -8,7 +8,6 @@ import ( "code.gitea.io/gitea/models/webhook" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/services/context" webhook_service "code.gitea.io/gitea/services/webhook" @@ -36,7 +35,7 @@ func DefaultOrSystemWebhooks(ctx *context.Context) { sys["Title"] = ctx.Tr("admin.systemhooks") sys["Description"] = ctx.Tr("admin.systemhooks.desc") - sys["Webhooks"], err = webhook.GetSystemWebhooks(ctx, optional.None[bool]()) + sys["Webhooks"], err = webhook.GetSystemWebhooks(ctx, false) sys["BaseLink"] = setting.AppSubURL + "/admin/hooks" sys["BaseLinkNew"] = setting.AppSubURL + "/admin/system-hooks" sys["WebhookList"] = webhook_service.List() diff --git a/services/webhook/webhook.go b/services/webhook/webhook.go index dc68cae84..cf4f2fdfd 100644 --- a/services/webhook/webhook.go +++ b/services/webhook/webhook.go @@ -243,7 +243,7 @@ func PrepareWebhooks(ctx context.Context, source EventSource, event webhook_modu } // Add any admin-defined system webhooks - systemHooks, err := webhook_model.GetSystemWebhooks(ctx, optional.Some(true)) + systemHooks, err := webhook_model.GetSystemWebhooks(ctx, true) if err != nil { return fmt.Errorf("GetSystemWebhooks: %w", err) } diff --git a/tests/integration/repo_webhook_test.go b/tests/integration/repo_webhook_test.go index 4e8788f0b..9a278e706 100644 --- a/tests/integration/repo_webhook_test.go +++ b/tests/integration/repo_webhook_test.go @@ -87,7 +87,7 @@ func TestNewWebHookLink(t *testing.T) { func TestWebhookForms(t *testing.T) { defer tests.PrepareTestEnv(t)() - session := loginUser(t, "user2") + session := loginUser(t, "user1") t.Run("forgejo/required", testWebhookForms("forgejo", session, map[string]string{ "payload_url": "https://forgejo.example.com", @@ -299,7 +299,9 @@ func assertInput(t testing.TB, form *goquery.Selection, name string) string { t.Helper() input := form.Find(`input[name="` + name + `"]`) if input.Length() != 1 { - t.Log(form.Html()) + form.Find("input").Each(func(i int, s *goquery.Selection) { + t.Logf("found ", s.AttrOr("name", "")) + }) t.Errorf("field found %d times, expected once", name, input.Length()) } switch input.AttrOr("type", "") { @@ -321,6 +323,12 @@ func testWebhookForms(name string, session *TestSession, validFields map[string] t.Run("org3", func(t *testing.T) { testWebhookFormsShared(t, "/org/org3/settings/hooks", name, session, validFields, invalidPatches...) }) + t.Run("system", func(t *testing.T) { + testWebhookFormsShared(t, "/admin/system-hooks", name, session, validFields, invalidPatches...) + }) + t.Run("default", func(t *testing.T) { + testWebhookFormsShared(t, "/admin/default-hooks", name, session, validFields, invalidPatches...) + }) } } @@ -345,17 +353,29 @@ func testWebhookFormsShared(t *testing.T, endpoint, name string, session *TestSe // create the webhook (this redirects back to the hook list) resp = session.MakeRequest(t, NewRequestWithValues(t, "POST", endpoint+"/"+name+"/new", payload), http.StatusSeeOther) assertHasFlashMessages(t, resp, "success") + listEndpoint := resp.Header().Get("Location") + updateEndpoint := endpoint + "/" + if endpoint == "/admin/system-hooks" || endpoint == "/admin/default-hooks" { + updateEndpoint = "/admin/hooks/" + } // find last created hook in the hook list // (a bit hacky, but the list should be sorted) - resp = session.MakeRequest(t, NewRequest(t, "GET", endpoint), http.StatusOK) + resp = session.MakeRequest(t, NewRequest(t, "GET", listEndpoint), http.StatusOK) htmlDoc := NewHTMLParser(t, resp.Body) - editFormURL := htmlDoc.Find(`a[href^="`+endpoint+`/"]`).Last().AttrOr("href", "") + selector := `a[href^="` + updateEndpoint + `"]` + if endpoint == "/admin/system-hooks" { + // system-hooks and default-hooks are listed on the same page + // add a specifier to select the latest system-hooks + // (the default-hooks are at the end, so no further specifier needed) + selector = `.admin-setting-content > div:first-of-type ` + selector + } + editFormURL := htmlDoc.Find(selector).Last().AttrOr("href", "") assert.NotEmpty(t, editFormURL) // edit webhook form resp = session.MakeRequest(t, NewRequest(t, "GET", editFormURL), http.StatusOK) - htmlForm = NewHTMLParser(t, resp.Body).Find(`form[action^="` + endpoint + `/"]`) + htmlForm = NewHTMLParser(t, resp.Body).Find(`form[action^="` + updateEndpoint + `"]`) editPostURL := htmlForm.AttrOr("action", "") assert.NotEmpty(t, editPostURL) @@ -375,7 +395,7 @@ func testWebhookFormsShared(t *testing.T, endpoint, name string, session *TestSe // check the updated webhook resp = session.MakeRequest(t, NewRequest(t, "GET", editFormURL), http.StatusOK) - htmlForm = NewHTMLParser(t, resp.Body).Find(`form[action^="` + endpoint + `/"]`) + htmlForm = NewHTMLParser(t, resp.Body).Find(`form[action^="` + updateEndpoint + `"]`) for k, v := range validFields { assert.Equal(t, v, assertInput(t, htmlForm, k), "input %q did not contain value %q", k, v) }