Stop 500ing when a service account acts on an incident
Every incident-mutation handler read userFromContext(ctx) and wrote the result's .ID into acknowledged_by/incident_events.user_id without checking the ok bool. For a team-scoped service-account caller this returned a zero-value user id, which violated the users(id) FK and 500'd on acknowledge, unacknowledge, resolve, snooze, unsnooze and create-note. handleDeleteNote didn't crash but silently matched zero rows instead (WHERE user_id = 0), so a service account could never delete its own note. Add acknowledged_by_service_account_id (incidents) and service_account_id (incident_events) as nullable FKs to service_accounts(id), parallel to and mutually exclusive with the existing human columns (migration 015, with a CHECK enforcing the exclusion). Route every one of the six handlers plus delete-note through a new callerActorIDs() helper that branches on Caller.AsHuman()/ServiceAccountID() instead of assuming a human, and thread a serviceAccountID parameter through logEvent and the new acknowledgeIncidentAs (acknowledgeIncident itself is untouched: its only other caller, the push-notification Acknowledge button, is always human). Render the new actor distinctly from both a human and "the server acted" in the web UI's incident timeline and facts card. handleIncidentAssign, handleIncidentArchive and handleIncidentUnarchive are deliberately not touched here — they track no actor at all today, for anyone, which is a separate pre-existing gap (follow-up issue to come). Fixes #25
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||
)
|
||||
|
||||
// amAlert builds one alert of a webhook payload.
|
||||
@@ -642,6 +643,106 @@ func TestIncident_ArchiveRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service accounts (terdut-server#25)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestServiceAccount_CanActOnItsTeamsIncidents is #25's regression test.
|
||||
// Before the fix: acknowledge/resolve/snooze/create-note each 500'd (writing
|
||||
// acknowledged_by/user_id = 0, violating the users(id) FK for a service
|
||||
// account), and delete-note silently matched zero rows (WHERE user_id = 0)
|
||||
// instead of deleting.
|
||||
func TestServiceAccount_CanActOnItsTeamsIncidents(t *testing.T) {
|
||||
s := newTS(t)
|
||||
instanceKey := createServiceAccount(t, s, s.key, "operator", models.ServiceAccountScopeInstance, 0)
|
||||
teamA := createTeamAs(t, s, instanceKey, "team-a")
|
||||
keyA := createServiceAccount(t, s, instanceKey, "team-a-sa", models.ServiceAccountScopeTeam, teamA)
|
||||
|
||||
var integration struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
decode(t, s.reqAs(t, keyA, http.MethodPost, "/api/teams/"+id64(teamA)+"/integrations",
|
||||
map[string]string{"name": "test"}), &integration)
|
||||
postToIntegration(t, s, integration.Key, "fp-sa", "SAIncident") // incident 1
|
||||
|
||||
// Acknowledge.
|
||||
resp := s.reqAs(t, keyA, http.MethodPost, "/api/incidents/1/acknowledge", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("service account acknowledge: %d", resp.StatusCode)
|
||||
}
|
||||
var inc map[string]any
|
||||
decode(t, resp, &inc)
|
||||
if inc["acknowledged_by_service_account_id"] == nil {
|
||||
t.Error("expected acknowledged_by_service_account_id to be set")
|
||||
}
|
||||
if inc["acknowledged_by_id"] != nil {
|
||||
t.Errorf("expected acknowledged_by_id to stay nil for a service-account actor, got %v", inc["acknowledged_by_id"])
|
||||
}
|
||||
|
||||
// Unacknowledge.
|
||||
resp = s.reqAs(t, keyA, http.MethodDelete, "/api/incidents/1/acknowledge", nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("service account unacknowledge: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Snooze, then unsnooze.
|
||||
resp = s.reqAs(t, keyA, http.MethodPost, "/api/incidents/1/snooze",
|
||||
map[string]string{"duration": "1h"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("service account snooze: %d", resp.StatusCode)
|
||||
}
|
||||
resp = s.reqAs(t, keyA, http.MethodDelete, "/api/incidents/1/snooze", nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("service account unsnooze: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Create, then delete, a note.
|
||||
var note map[string]any
|
||||
decode(t, s.reqAs(t, keyA, http.MethodPost, "/api/incidents/1/notes",
|
||||
map[string]string{"content": "looking into it"}), ¬e)
|
||||
if note["service_account_id"] == nil {
|
||||
t.Error("expected service_account_id on the note event")
|
||||
}
|
||||
if note["user_id"] != nil {
|
||||
t.Errorf("expected no user_id on a service-account note, got %v", note["user_id"])
|
||||
}
|
||||
noteID := int(note["id"].(float64))
|
||||
delResp := s.reqAs(t, keyA, http.MethodDelete, fmt.Sprintf("/api/incidents/1/notes/%d", noteID), nil)
|
||||
delResp.Body.Close()
|
||||
if delResp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("service account deleting its own note: %d", delResp.StatusCode)
|
||||
}
|
||||
|
||||
// Resolve.
|
||||
resp = s.reqAs(t, keyA, http.MethodPost, "/api/incidents/1/resolve", nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("service account resolve: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression guard: a human actor must still write only the human columns,
|
||||
// unaffected by the service-account branch added above.
|
||||
func TestIncident_AcknowledgeStillWritesOnlyHumanColumn(t *testing.T) {
|
||||
s := newTS(t)
|
||||
postWebhook(t, s, []map[string]any{
|
||||
amAlert("fp-human-ack", "Z", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||
})
|
||||
|
||||
var inc map[string]any
|
||||
decode(t, s.req(t, http.MethodPost, "/api/incidents/1/acknowledge", nil), &inc)
|
||||
if inc["acknowledged_by_id"] == nil {
|
||||
t.Error("expected acknowledged_by_id to be set for a human actor")
|
||||
}
|
||||
if inc["acknowledged_by_service_account_id"] != nil {
|
||||
t.Errorf("expected acknowledged_by_service_account_id to stay nil for a human actor, got %v",
|
||||
inc["acknowledged_by_service_account_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSweeper_ArchivesResolvedIncidents(t *testing.T) {
|
||||
s := newTS(t)
|
||||
postWebhook(t, s, []map[string]any{
|
||||
|
||||
Reference in New Issue
Block a user