Compare commits

2 Commits
Author SHA1 Message Date
sneak f1304da780 Name the item and what is lost in each delete prompt (closes #400)
check / check (push) Successful in 3m18s
The webhook, entrypoint and target delete prompts on the webhook page
now name the item and say what deleting it loses: the webhook's stored
events (with the count from the statistics pane) and their deliveries,
while its archive files are kept; an entrypoint's URL, which stops
working for good; a target's future deliveries, while its past ones
stay in the event log. They stay the browser's own prompts, so they
work without the page's scripts, and each name is escaped for the
script so quotes and backslashes show as typed.

Model: opus-5-5
2026-10-03 01:04:23 +00:00
clawbot bcdd4791ec Say what a database or log target's attempt did, without a status (closes #388)
check / check (push) Successful in 3m14s
In the event log and on an event's page, every attempt by a `database` or `log` target read "success  Status: — (no response)". Those targets make no HTTP request, so there is no response to have, and "no response" reads like a failed connection, which is what it means for an `http` target. A successful `database` attempt now reads "archived" and a successful `log` attempt "written to the log", with no status shown; a failed one keeps "failure" and its error line, also with no status. `http` and `slack` attempts are unchanged. The change is in the one shared attempt template.

Model: opus-5-5
2026-10-03 02:41:17 +02:00
4 changed files with 112 additions and 10 deletions
+4
View File
@@ -1899,6 +1899,10 @@ retries) is individually logged for full observability.
| `error` | string | Error message (on failure) | | `error` | string | Error message (on failure) |
| `duration` | integer | Request duration in milliseconds | | `duration` | integer | Request duration in milliseconds |
A `database` or `log` target sends no HTTP request, so in the event log and
on the event's page its attempts show no status: a successful one reads
"archived" or "written to the log".
**Relations:** Belongs to Delivery. **Relations:** Belongs to Delivery.
#### EventTotals, TargetTotals and EntrypointTotals #### EventTotals, TargetTotals and EntrypointTotals
@@ -0,0 +1,89 @@
package handlers_test
import (
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
)
// TestDeliveryAttempts_ReadInTheTargetTypesOwnTerms proves, on the
// event's page and in the event log, that an http or slack attempt
// shows its status as before, while a database or log attempt, which
// sends no HTTP request, says what it did and shows no status.
func TestDeliveryAttempts_ReadInTheTargetTypesOwnTerms(t *testing.T) {
t.Parallel()
cases := []struct {
targetType database.TargetType
success bool
statusCode int
errText string
outcome string
status string // "" when the attempt must show no status
}{
{
database.TargetTypeHTTP, false, 0, "",
"failure", "Status: — (no response)",
},
{
database.TargetTypeSlack, true, http.StatusOK, "",
"success", "Status: 200",
},
{database.TargetTypeDatabase, true, 0, "", "archived", ""},
{
database.TargetTypeDatabase, false, 0,
"opening archive database: disk full", "failure", "",
},
{database.TargetTypeLog, true, 0, "", "written to the log", ""},
}
for _, tc := range cases {
t.Run(string(tc.targetType), func(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
target := seedTarget(t, f.db, f.webhook.ID, tc.targetType)
event := f.event(t, contentTypeJSON, "{}", time.Now())
dlv := f.delivery(
t, event, target.ID, database.DeliveryStatusDelivered,
)
require.NoError(t, f.webhookDB.Omit(clause.Associations).Create(
&database.DeliveryResult{
DeliveryID: dlv.ID,
AttemptNum: 1,
Success: tc.success,
StatusCode: tc.statusCode,
Error: tc.errText,
},
).Error)
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
require.Equal(t, http.StatusOK, w.Code)
pages := []string{
w.Body.String(),
renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID),
}
for _, page := range pages {
assert.Contains(t, page, ">"+tc.outcome+"</span>")
if tc.errText != "" {
assert.Contains(t, page, "Error: "+tc.errText)
}
if tc.status == "" {
assert.NotContains(t, page, "Status:")
} else {
assert.Contains(t, page, tc.status)
}
}
})
}
}
+14 -9
View File
@@ -279,9 +279,10 @@ func TestHandleSourceDetail_FitsWideAndNarrowWindows(t *testing.T) {
// TestHandleSourceDetail_DeletePromptsNameWhatIsLost checks that each // TestHandleSourceDetail_DeletePromptsNameWhatIsLost checks that each
// delete prompt on the webhook page names the webhook, entrypoint or // delete prompt on the webhook page names the webhook, entrypoint or
// target and says what deleting it loses, that the webhook's gives its // target and says what deleting it loses, that the webhook's gives its
// number of stored events, and that an entrypoint with no description // number of stored events (5 received, 2 removed by retention, so 3,
// is named by its URL. The template writes the slashes after http: as // the statistics pane's "Within retention" figure), and that an
// \/, which the browser reads as /. // entrypoint with no description is named by its URL. The template
// writes the slashes after http: as \/, which the browser reads as /.
func TestHandleSourceDetail_DeletePromptsNameWhatIsLost(t *testing.T) { func TestHandleSourceDetail_DeletePromptsNameWhatIsLost(t *testing.T) {
t.Parallel() t.Parallel()
@@ -302,7 +303,7 @@ func TestHandleSourceDetail_DeletePromptsNameWhatIsLost(t *testing.T) {
webhookDB, err := dbMgr.GetDB(wh.ID) webhookDB, err := dbMgr.GetDB(wh.ID)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, database.AddEventTotals( require.NoError(t, database.AddEventTotals(
webhookDB, database.EventTotals{Events: 3}, webhookDB, database.EventTotals{Events: 5, EventsRemoved: 2},
)) ))
unnamed := seedEntrypoint(t, db, wh.ID) unnamed := seedEntrypoint(t, db, wh.ID)
@@ -336,9 +337,12 @@ func TestHandleSourceDetail_DeletePromptsNameWhatIsLost(t *testing.T) {
} }
// TestHandleSourceDetail_DeletePromptKeepsQuotesInName checks that a // TestHandleSourceDetail_DeletePromptKeepsQuotesInName checks that a
// webhook name with quotes and a backslash reaches its delete prompt // webhook name with quotes, a backslash, a closing script tag and a
// escaped for the script, each quote as a \u escape and the backslash // newline reaches its delete prompt escaped for the script, which the
// doubled, which the browser reads back as the name typed. // browser reads back as the name typed: each quote and angle bracket
// as a \u escape, the slash as \/, the newline as \n and the backslash
// doubled. An unescaped newline would break the prompt's script, and
// the form would then submit without asking.
func TestHandleSourceDetail_DeletePromptKeepsQuotesInName(t *testing.T) { func TestHandleSourceDetail_DeletePromptKeepsQuotesInName(t *testing.T) {
t.Parallel() t.Parallel()
@@ -355,7 +359,7 @@ func TestHandleSourceDetail_DeletePromptKeepsQuotesInName(t *testing.T) {
wh := &database.Webhook{ wh := &database.Webhook{
UserID: deleteTestUserID, UserID: deleteTestUserID,
Name: `Bob's "best" \ hook`, Name: "Bob's \"best\" \\ hook</script>\nline two",
} }
require.NoError( require.NoError(
t, db.DB().Omit(clause.Associations).Create(wh).Error, t, db.DB().Omit(clause.Associations).Create(wh).Error,
@@ -364,5 +368,6 @@ func TestHandleSourceDetail_DeletePromptKeepsQuotesInName(t *testing.T) {
body := renderSourceDetailPage(t, h, sess, wh.ID) body := renderSourceDetailPage(t, h, sess, wh.ID)
assert.Contains(t, body, assert.Contains(t, body,
"Delete webhook &quot;Bob\\u0027s \\u0022best\\u0022 \\\\ hook&quot;?") "Delete webhook &quot;Bob\\u0027s \\u0022best\\u0022 \\\\ hook"+
"\\u003c\\/script\\u003e\\nline two&quot;?")
} }
+5 -1
View File
@@ -5,11 +5,15 @@
<p class="text-xs text-gray-500">{{.AttemptsOmitted}} attempt{{if ne .AttemptsOmitted 1}}s{{end}} omitted between the first and last shown.</p> <p class="text-xs text-gray-500">{{.AttemptsOmitted}} attempt{{if ne .AttemptsOmitted 1}}s{{end}} omitted between the first and last shown.</p>
{{end}} {{end}}
{{range .Results}} {{range .Results}}
{{/* Inside this loop, dot is one attempt and $ the whole delivery. */}}
<div class="rounded-md bg-white border border-gray-200 p-2"> <div class="rounded-md bg-white border border-gray-200 p-2">
<div class="flex flex-wrap items-center gap-3 text-xs"> <div class="flex flex-wrap items-center gap-3 text-xs">
<span class="text-gray-500">Attempt {{.AttemptNum}}</span> <span class="text-gray-500">Attempt {{.AttemptNum}}</span>
<span class="{{if .Success}}text-green-600{{else}}text-red-600{{end}}">{{if .Success}}success{{else}}failure{{end}}</span> <span class="{{if .Success}}text-green-600{{else}}text-red-600{{end}}">{{if not .Success}}failure{{else if eq $.Target.Type "database"}}archived{{else if eq $.Target.Type "log"}}written to the log{{else}}success{{end}}</span>
{{/* A database or log target sends no HTTP request, so its attempts have no status code. */}}
{{if not (eq $.Target.Type "database" "log")}}
<span class="text-gray-500">Status: {{if .HasStatusCode}}{{.StatusCode}}{{else}}&mdash; (no response){{end}}</span> <span class="text-gray-500">Status: {{if .HasStatusCode}}{{.StatusCode}}{{else}}&mdash; (no response){{end}}</span>
{{end}}
<span class="text-gray-500">Duration: {{.DurationMS}} ms</span> <span class="text-gray-500">Duration: {{.DurationMS}} ms</span>
</div> </div>
{{if .Error}} {{if .Error}}