Files
Admin 3a2da52b7a [refactor](alarm): end alerts by disarm or expiry, not a ring timer
An alert now rings from trigger until the owner disarms it; a subscriber silences only its own device. The owner instead sets a persistence cap (default 24h, hard cap 14 days) and the sweeper destroys expired alerts. Disarm ends an armed alert too, and every ending (delivered, expired, disarmed, deleted) converges on the one destruction path so their event-log reasons cannot drift.
2026-10-19 02:26:31 +01:00

145 lines
5.3 KiB
Go

// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// TestRoutesConstruct builds exactly the wiring main() builds and fails on a
// panic. http.ServeMux panics at registration time when the same pattern is
// registered twice, which kills the process before it binds :8080 — and
// go build/vet/gofmt/go test cannot see it unless a test constructs the mux.
// This test exists because a duplicate /auth/logout registration shipped and
// only a real launch caught it.
func TestRoutesConstruct(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("route registration panicked — the service would not boot: %v", r)
}
}()
apiMux := newAPIMux()
if apiMux == nil {
t.Fatal("newAPIMux returned nil")
}
outer := newOuterMux(apiMux)
if outer == nil {
t.Fatal("newOuterMux returned nil")
}
}
// TestWebSocketPatternIsFree proves main.go leaves /ws/alarm to the realtime
// layer: registering it on the outer mux must not conflict with anything
// main.go registered (realtime.Register does exactly this at boot).
func TestWebSocketPatternIsFree(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("/ws/alarm conflicts with a pattern registered by main.go: %v", r)
}
}()
outer := newOuterMux(newAPIMux())
outer.Handle("/ws/alarm", http.NotFoundHandler())
}
// TestManifestThroughOuterChain serves GET /_manifest through the real outer
// mux (logging + CORS chain included), which is what a caller hits.
func TestManifestThroughOuterChain(t *testing.T) {
outer := newOuterMux(newAPIMux())
rec := httptest.NewRecorder()
outer.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/_manifest", nil))
if rec.Code != http.StatusOK {
t.Fatalf("GET /_manifest = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
t.Fatalf("GET /_manifest Content-Type = %q, want application/json", ct)
}
if body := rec.Body.String(); !strings.Contains(body, `"name": "Alarm"`) && !strings.Contains(body, `"name":"Alarm"`) {
t.Fatalf("GET /_manifest body does not name the service: %s", body)
}
}
// TestLogoutServesBothMethods pins the method-aware registration: GET (bodyless
// no-op) and POST (revoke the refresh token) must both be routed, and any other
// verb must be rejected with 405. The handler proxies to dcntrlized-auth, so the
// requests carry a short deadline: the assertion is about routing, and the test
// must not depend on (or wait for) a live auth service.
func TestLogoutServesBothMethods(t *testing.T) {
outer := newOuterMux(newAPIMux())
do := func(method string) int {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
req := httptest.NewRequest(method, "/auth/logout", nil).WithContext(ctx)
rec := httptest.NewRecorder()
outer.ServeHTTP(rec, req)
return rec.Code
}
for _, method := range []string{http.MethodGet, http.MethodPost} {
if code := do(method); code == http.StatusMethodNotAllowed || code == http.StatusNotFound {
t.Fatalf("%s /auth/logout = %d, want the method to be routed", method, code)
}
}
if code := do(http.MethodPut); code != http.StatusMethodNotAllowed {
t.Fatalf("PUT /auth/logout = %d, want 405", code)
}
}
// TestRouteLevelErrorsCarryMessages walks the error paths main.go registers
// itself — the /action/ verb guard and the per-method guards on /auth/* — and
// asserts the user's invariant: an error status never comes with an empty body.
// The /action/ dispatch errors are plain text, the auth guards are plain text
// too; both must carry a readable message.
func TestRouteLevelErrorsCarryMessages(t *testing.T) {
outer := newOuterMux(newAPIMux())
cases := []struct {
method string
path string
body string
want int
}{
{http.MethodPatch, "/action/createAlert", "", http.StatusMethodNotAllowed},
{http.MethodGet, "/action/nope", "", http.StatusNotImplemented},
{http.MethodPost, "/action/getAlert", `{"unknown":true}`, http.StatusBadRequest},
{http.MethodPut, "/auth/login", "", http.StatusMethodNotAllowed},
{http.MethodDelete, "/auth/me", "", http.StatusMethodNotAllowed},
}
for _, tc := range cases {
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
rec := httptest.NewRecorder()
outer.ServeHTTP(rec, req)
if rec.Code != tc.want {
t.Fatalf("%s %s = %d, want %d (body: %q)", tc.method, tc.path, rec.Code, tc.want, rec.Body.String())
}
if body := strings.TrimSpace(rec.Body.String()); body == "" {
t.Fatalf("%s %s: status %d with an empty body — every error must carry a message", tc.method, tc.path, rec.Code)
}
}
}