86215f1eb1
Three connected features for the deployment-strategy demo: 1. Visitor identity cookie (v3). A new visitors table backs a first-party gb_visitor cookie: middleware on /api checks the cookie's UUID against the database on every request and regenerates + persists it when the id is missing, malformed, or unknown (fresh browser, pruned identity, or a reset database). The identity remembers the name a visitor signed with (pre-filled next time, used for unsigned posts), replaces the standalone reaction cookie for reaction de-dupe, and feeds a live "N visiting" counter (identities seen in the last 5 minutes) shown on the wall, in /api/info, the admin overview, and the presence/tick SSE events. The admin console gains Visitors/Visiting tiles and a visitors table, plus a GET /api/me endpoint and GET /api/admin/visitors. 2. guestbook reset command. Wipes entries (including soft-deleted rows), reactions and visitor identities under the schema advisory lock, with --dry-run, --hard (also reset wall settings) and --reseed flags; meant to run as a one-off Miabi Job. After a reset every outstanding cookie is invalidated, so browsers get a fresh identity on their next request. 3. cleanup learns --prune-visitors (default 30 days): the cron job now also forgets idle identities together with their reactions. Docs (README, DEMO) updated: act 8 covers reset, the visitor-identity talking point is in act 3, and the API tables list the new endpoints. Akili-Task: tsk_ptdi5ufndrzwlljk4v5g Akili-Agent: goldfish
211 lines
6.6 KiB
Go
211 lines
6.6 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jkaninda/okapi"
|
|
)
|
|
|
|
func TestVisitorLifecycle(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := newTestStore(t)
|
|
|
|
if !ValidVisitorID(NewVisitorID()) {
|
|
t.Fatal("a freshly minted id should validate")
|
|
}
|
|
for _, bad := range []string{"", "not-a-uuid", "550e8400-e29b-41d4-a716-44665544000z"} {
|
|
if ValidVisitorID(bad) {
|
|
t.Fatalf("invalid id accepted: %q", bad)
|
|
}
|
|
}
|
|
|
|
// A cookie the database does not know about must be regenerated.
|
|
if _, found, err := s.FindVisitor(ctx, NewVisitorID()); err != nil || found {
|
|
t.Fatalf("unknown visitor: found=%v err=%v", found, err)
|
|
}
|
|
|
|
id := NewVisitorID()
|
|
v, err := s.CreateVisitor(ctx, id)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if v.ID != id || v.Name != "" || v.Signatures != 0 {
|
|
t.Fatalf("new visitor = %+v", v)
|
|
}
|
|
if _, found, err := s.FindVisitor(ctx, id); err != nil || !found {
|
|
t.Fatalf("known visitor: found=%v err=%v", found, err)
|
|
}
|
|
|
|
s.VisitorSigned(ctx, id, "Ada")
|
|
got, found, _ := s.FindVisitor(ctx, id)
|
|
if !found || got.Name != "Ada" || got.Signatures != 1 {
|
|
t.Fatalf("after signing: %+v", got)
|
|
}
|
|
|
|
// Freshness drives the "visiting now" number (2 minutes ago is still fresh).
|
|
s.db.Model(&Visitor{}).Where("id = ?", id).Update("last_seen", time.Now().Add(-2*time.Minute))
|
|
if n, _ := s.VisitorsSeenAfter(ctx, time.Now().Add(-visitorFreshWindow)); n != 1 {
|
|
t.Fatalf("active visitors = %d, want 1", n)
|
|
}
|
|
stale := time.Now().Add(-48 * time.Hour)
|
|
s.db.Model(&Visitor{}).Where("id = ?", id).Update("last_seen", stale)
|
|
if n, _ := s.VisitorsSeenAfter(ctx, time.Now().Add(-visitorFreshWindow)); n != 0 {
|
|
t.Fatalf("active visitors after going stale = %d, want 0", n)
|
|
}
|
|
|
|
// Pruning removes the identity and its reactions.
|
|
e, _ := s.Create(ctx, "ada", "hello", "r1", "")
|
|
if _, err := s.React(ctx, uint64(e.ID), "heart", id); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n, _ := s.PurgeVisitors(ctx, time.Now().Add(-24*time.Hour), true); n != 1 {
|
|
t.Fatalf("dry-run prune = %d, want 1", n)
|
|
}
|
|
if n, _ := s.PurgeVisitors(ctx, time.Now().Add(-24*time.Hour), false); n != 1 {
|
|
t.Fatalf("prune = %d, want 1", n)
|
|
}
|
|
if _, found, _ := s.FindVisitor(ctx, id); found {
|
|
t.Fatal("pruned visitor still found")
|
|
}
|
|
if n, _ := s.ReactionTotals(ctx); n != 0 {
|
|
t.Fatalf("reactions of the pruned visitor = %d, want 0", n)
|
|
}
|
|
}
|
|
|
|
// TestVisitorCookieFlow exercises the middleware over HTTP: no cookie → one is
|
|
// issued and persisted; a known cookie is kept; a cookie the database no
|
|
// longer knows about is regenerated.
|
|
func TestVisitorCookieFlow(t *testing.T) {
|
|
s := newTestStore(t)
|
|
h := &Handler{store: s, broker: NewBroker("test", "dev", nil), auth: NewAdminAuth("")}
|
|
app := okapi.New()
|
|
api := app.Group("/api", h.identifyVisitor)
|
|
api.Get("/me", h.VisitorMe)
|
|
|
|
call := func(cookie string) (*httptest.ResponseRecorder, map[string]any) {
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
|
|
if cookie != "" {
|
|
req.Header.Set("Cookie", visitorCookie+"="+cookie)
|
|
}
|
|
app.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
var body map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return rec, body
|
|
}
|
|
|
|
// First visit: the backend mints an identity, persists it and sets the cookie.
|
|
rec, first := call("")
|
|
issued := ""
|
|
for _, c := range rec.Result().Cookies() {
|
|
if c.Name == visitorCookie {
|
|
issued = c.Value
|
|
}
|
|
}
|
|
if issued == "" || first["id"] != issued {
|
|
t.Fatalf("first visit: cookie %q, body %v", issued, first)
|
|
}
|
|
if _, found, _ := s.FindVisitor(context.Background(), issued); !found {
|
|
t.Fatal("issued identity was not persisted")
|
|
}
|
|
|
|
// Second visit with the cookie: recognised, identity unchanged.
|
|
_, second := call(issued)
|
|
if second["id"] != issued {
|
|
t.Fatalf("known cookie should keep its identity: %v vs %v", second["id"], issued)
|
|
}
|
|
|
|
// A cookie the database does not know (e.g. after a reset) is regenerated.
|
|
rec, third := call(NewVisitorID())
|
|
regenerated := third["id"].(string)
|
|
if !ValidVisitorID(regenerated) || regenerated == issued {
|
|
t.Fatalf("unknown cookie should be regenerated, got %v", third)
|
|
}
|
|
if !cookieSetTo(rec, visitorCookie, regenerated) {
|
|
t.Fatalf("regenerated identity was not written back to the cookie: %v", rec.Header())
|
|
}
|
|
|
|
// A malformed cookie never reaches the database and gets a fresh identity.
|
|
_, fourth := call("not-a-uuid")
|
|
if !ValidVisitorID(fourth["id"].(string)) {
|
|
t.Fatalf("malformed cookie should yield a fresh identity, got %v", fourth)
|
|
}
|
|
}
|
|
|
|
func cookieSetTo(rec *httptest.ResponseRecorder, name, value string) bool {
|
|
for _, c := range rec.Result().Cookies() {
|
|
if c.Name == name {
|
|
return c.Value == value
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestReset(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := newTestStore(t)
|
|
|
|
e, _ := s.Create(ctx, "ada", "hello", "r1", "")
|
|
gone, _ := s.Create(ctx, "gone", "deleted", "r1", "")
|
|
_, _ = s.Delete(ctx, uint64(gone.ID))
|
|
who, _ := s.CreateVisitor(ctx, NewVisitorID())
|
|
if _, err := s.React(ctx, uint64(e.ID), "tada", who.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = s.SaveSettings(ctx, Settings{SigningPaused: true, Banner: "hi"})
|
|
|
|
// Dry run reports but changes nothing.
|
|
dry, err := s.Reset(ctx, false, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if dry.Entries != 2 || dry.Reactions != 1 || dry.Visitors != 1 || dry.Settings != 0 {
|
|
t.Fatalf("dry run = %+v", dry)
|
|
}
|
|
if n, _ := s.Count(ctx); n != 1 {
|
|
t.Fatalf("dry run removed entries: %d left", n)
|
|
}
|
|
|
|
// A soft reset keeps the settings, and soft-deleted rows are gone for good.
|
|
stats, err := s.Reset(ctx, false, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stats.Entries != 2 || stats.Reactions != 1 || stats.Visitors != 1 {
|
|
t.Fatalf("reset = %+v", stats)
|
|
}
|
|
if n, _ := s.Count(ctx); n != 0 {
|
|
t.Fatalf("entries after reset = %d, want 0", n)
|
|
}
|
|
if n, _ := s.ReactionTotals(ctx); n != 0 {
|
|
t.Fatalf("reactions after reset = %d, want 0", n)
|
|
}
|
|
if n, _ := s.VisitorCount(ctx); n != 0 {
|
|
t.Fatalf("visitors after reset = %d, want 0", n)
|
|
}
|
|
if n, _ := s.Purge(ctx, time.Now().Add(time.Hour), false); n != 0 {
|
|
t.Fatalf("purged after reset = %d, want 0 (soft-deleted rows wiped too)", n)
|
|
}
|
|
if st, _ := s.Settings(ctx); !st.SigningPaused || st.Banner != "hi" {
|
|
t.Fatalf("settings should survive a soft reset, got %+v", st)
|
|
}
|
|
|
|
// Hard reset drops the settings as well.
|
|
if _, err := s.Reset(ctx, true, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if st, _ := s.Settings(ctx); st.SigningPaused || st.Banner != "" {
|
|
t.Fatalf("settings should be cleared by a hard reset, got %+v", st)
|
|
}
|
|
}
|