package main import ( "database/sql" "encoding/json" "net/http" "net/http/httptest" "path/filepath" "strconv" "strings" "testing" "time" ) func testAuth(t *testing.T) *Auth { t.Helper() dir := t.TempDir() db, err := openDB(filepath.Join(dir, "test.db")) if err != nil { t.Fatalf("open db: %v", err) } t.Cleanup(func() { db.Close() }) return newAuth(db, "letmein", false, filepath.Join(dir, "photos")) } // testOwner registers an account and returns its id. func testOwner(t *testing.T, a *Auth) string { t.Helper() u, err := a.createUser("owner@example.com", "hunter2hunter2") if err != nil { t.Fatalf("create user: %v", err) } return u.ID } // hoursAhead is an expiry that many hours from now, in Unix milliseconds — // what the client sends after the owner picks a date. func hoursAhead(h int) int64 { return time.Now().Add(time.Duration(h) * time.Hour).UnixMilli() } // guestToken mints a link and redeems it, returning the raw session token a // guest's cookie would carry. func guestToken(t *testing.T, a *Auth, ownerID, label string, hours int) string { t.Helper() link, err := a.createShare(ownerID, label, hoursAhead(hours)) if err != nil { t.Fatalf("create share: %v", err) } raw, _, ok := a.redeemShare(link.Token) if !ok { t.Fatal("redeem: fresh link was rejected") } return raw } // request builds a request carrying the given session cookie. func request(method, path, cookie, body string) *http.Request { r := httptest.NewRequest(method, path, strings.NewReader(body)) if cookie != "" { r.AddCookie(&http.Cookie{Name: sessionCookie, Value: cookie}) } return r } func TestRedeemedLinkActsAsTheOwner(t *testing.T) { a := testAuth(t) ownerID := testOwner(t, a) token := guestToken(t, a, ownerID, "Anna", 24) s, ok := a.sessionForToken(token) if !ok { t.Fatal("session for a fresh guest token was rejected") } if s.userID != ownerID { t.Errorf("guest session scoped to %q, want the owner %q", s.userID, ownerID) } if !s.guest() { t.Error("session from a share link does not report itself as a guest") } if s.label != "Anna" { t.Errorf("label = %q, want %q", s.label, "Anna") } } func TestOwnerSessionIsNotAGuest(t *testing.T) { a := testAuth(t) ownerID := testOwner(t, a) raw, err := a.startSession(ownerID, "", time.Now().Add(sessionValidity).UnixMilli()) if err != nil { t.Fatalf("start session: %v", err) } s, ok := a.sessionForToken(raw) if !ok { t.Fatal("owner session was rejected") } if s.guest() || s.label != "" { t.Errorf("owner session reports guest=%v label=%q, want false/empty", s.guest(), s.label) } } func TestUnusableTokensAreRejected(t *testing.T) { a := testAuth(t) ownerID := testOwner(t, a) if _, _, ok := a.redeemShare("not-a-real-token"); ok { t.Error("a garbage token was redeemed") } if _, _, ok := a.redeemShare(""); ok { t.Error("an empty token was redeemed") } // An expired link: backdate it past its own end. link, err := a.createShare(ownerID, "Stale", hoursAhead(12)) if err != nil { t.Fatalf("create share: %v", err) } if _, err := a.db.Exec( `UPDATE share_links SET expires = ? WHERE id = ?`, time.Now().Add(-time.Minute).UnixMilli(), link.ID); err != nil { t.Fatalf("backdate: %v", err) } if _, _, ok := a.redeemShare(link.Token); ok { t.Error("an expired link was redeemed") } // A revoked link. revoked, err := a.createShare(ownerID, "Revoked", hoursAhead(12)) if err != nil { t.Fatalf("create share: %v", err) } if err := a.revokeShare(ownerID, revoked.ID); err != nil { t.Fatalf("revoke: %v", err) } if _, _, ok := a.redeemShare(revoked.Token); ok { t.Error("a revoked link was redeemed") } } // Revocation has to bite on the next request, not whenever the guest's own // session row happens to lapse — that is the whole point of being able to // revoke. func TestRevokingKillsLiveSessions(t *testing.T) { a := testAuth(t) ownerID := testOwner(t, a) link, err := a.createShare(ownerID, "Anna", hoursAhead(24)) if err != nil { t.Fatalf("create share: %v", err) } token, _, ok := a.redeemShare(link.Token) if !ok { t.Fatal("redeem: fresh link was rejected") } if _, ok := a.sessionForToken(token); !ok { t.Fatal("session invalid before revoking") } if err := a.revokeShare(ownerID, link.ID); err != nil { t.Fatalf("revoke: %v", err) } if _, ok := a.sessionForToken(token); ok { t.Error("session still valid after its link was revoked") } } // A guest session must never outlive its link, however long the default // session validity is. func TestGuestSessionIsCappedAtTheLinkExpiry(t *testing.T) { a := testAuth(t) ownerID := testOwner(t, a) link, err := a.createShare(ownerID, "Anna", hoursAhead(12)) if err != nil { t.Fatalf("create share: %v", err) } _, sessionEnd, ok := a.redeemShare(link.Token) if !ok { t.Fatal("redeem: fresh link was rejected") } if sessionEnd != link.Expires { t.Errorf("session ends at %d, want the link's own %d", sessionEnd, link.Expires) } } func TestRevokeIsScopedToTheOwner(t *testing.T) { a := testAuth(t) ownerID := testOwner(t, a) other, err := a.createUser("other@example.com", "hunter2hunter2") if err != nil { t.Fatalf("create user: %v", err) } link, err := a.createShare(ownerID, "Anna", hoursAhead(24)) if err != nil { t.Fatalf("create share: %v", err) } if err := a.revokeShare(other.ID, link.ID); err == nil { t.Error("another account revoked a link it does not own") } if _, _, ok := a.redeemShare(link.Token); !ok { t.Error("link was revoked by an account that does not own it") } } func TestListSharesHidesRevokedAndExpired(t *testing.T) { a := testAuth(t) ownerID := testOwner(t, a) live, err := a.createShare(ownerID, "Live", hoursAhead(24)) if err != nil { t.Fatalf("create share: %v", err) } gone, err := a.createShare(ownerID, "Gone", hoursAhead(24)) if err != nil { t.Fatalf("create share: %v", err) } if err := a.revokeShare(ownerID, gone.ID); err != nil { t.Fatalf("revoke: %v", err) } stale, err := a.createShare(ownerID, "Stale", hoursAhead(24)) if err != nil { t.Fatalf("create share: %v", err) } if _, err := a.db.Exec( `UPDATE share_links SET expires = ? WHERE id = ?`, time.Now().Add(-time.Minute).UnixMilli(), stale.ID); err != nil { t.Fatalf("backdate: %v", err) } links, err := a.listShares(ownerID) if err != nil { t.Fatalf("list: %v", err) } if len(links) != 1 || links[0].ID != live.ID { t.Fatalf("listed %d link(s), want only the live one", len(links)) } if links[0].Token != "" { t.Error("listing leaked a raw token") } } // The raw token is only ever handed back once, at creation. func TestOnlyTheTokenHashIsStored(t *testing.T) { a := testAuth(t) ownerID := testOwner(t, a) link, err := a.createShare(ownerID, "Anna", hoursAhead(24)) if err != nil { t.Fatalf("create share: %v", err) } var stored string if err := a.db.QueryRow(`SELECT token FROM share_links WHERE id = ?`, link.ID).Scan(&stored); err != nil { t.Fatalf("read back: %v", err) } if stored == link.Token { t.Error("the raw token is stored in the clear") } if stored != hashToken(link.Token) { t.Error("stored token is not the hash of the issued one") } } func TestDeletingAnAccountDropsItsLinks(t *testing.T) { a := testAuth(t) ownerID := testOwner(t, a) link, err := a.createShare(ownerID, "Anna", hoursAhead(24)) if err != nil { t.Fatalf("create share: %v", err) } if err := a.deleteAccount(ownerID); err != nil { t.Fatalf("delete account: %v", err) } var n int if err := a.db.QueryRow(`SELECT COUNT(*) FROM share_links WHERE id = ?`, link.ID).Scan(&n); err != nil { t.Fatalf("count: %v", err) } if n != 0 { t.Error("the deleted account's guest links survived") } } // ---------- capability gating ---------- func TestRequireOwnerBlocksGuests(t *testing.T) { a := testAuth(t) ownerID := testOwner(t, a) guest := guestToken(t, a, ownerID, "Anna", 24) owner, err := a.startSession(ownerID, "", time.Now().Add(sessionValidity).UnixMilli()) if err != nil { t.Fatalf("start session: %v", err) } reached := false h := a.requireOwner(func(w http.ResponseWriter, r *http.Request) { reached = true }) w := httptest.NewRecorder() h(w, request(http.MethodPost, "/api/shares", guest, "{}")) if w.Code != http.StatusForbidden { t.Errorf("guest got %d, want %d", w.Code, http.StatusForbidden) } if reached { t.Error("the guarded handler ran for a guest") } w = httptest.NewRecorder() h(w, request(http.MethodPost, "/api/shares", owner, "{}")) if w.Code != http.StatusOK || !reached { t.Errorf("owner got %d and reached=%v, want 200 and true", w.Code, reached) } } // A guest must still be able to do the thing the link exists for. func TestRequireUserAllowsGuests(t *testing.T) { a := testAuth(t) ownerID := testOwner(t, a) guest := guestToken(t, a, ownerID, "Anna", 24) var sawUser, sawLabel string h := a.requireUser(func(w http.ResponseWriter, r *http.Request) { sawUser, sawLabel = userID(r), guestLabel(r) }) w := httptest.NewRecorder() h(w, request(http.MethodPost, "/api/events/sync", guest, "{}")) if w.Code != http.StatusOK { t.Fatalf("guest got %d on a shared route, want 200", w.Code) } if sawUser != ownerID { t.Errorf("handler saw user %q, want the owner %q", sawUser, ownerID) } if sawLabel != "Anna" { t.Errorf("handler saw label %q, want %q", sawLabel, "Anna") } } func TestHandleMeHidesTheOwnerFromAGuest(t *testing.T) { a := testAuth(t) ownerID := testOwner(t, a) guest := guestToken(t, a, ownerID, "Anna", 24) w := httptest.NewRecorder() a.requireUser(a.handleMe)(w, request(http.MethodGet, "/api/me", guest, "")) if w.Code != http.StatusOK { t.Fatalf("got %d, want 200", w.Code) } var u User if err := json.NewDecoder(w.Body).Decode(&u); err != nil { t.Fatalf("decode: %v", err) } if u.Email != "" { t.Errorf("a guest was told the owner's email (%q)", u.Email) } if u.Role != "guest" || u.Label != "Anna" { t.Errorf("role/label = %q/%q, want guest/Anna", u.Role, u.Label) } if u.ID != ownerID { t.Errorf("id = %q, want the owner's %q so the client scopes its cache right", u.ID, ownerID) } } // ---------- attribution ---------- func loggedBy(t *testing.T, db *sql.DB, id string) string { t.Helper() var by string if err := db.QueryRow(`SELECT logged_by FROM events WHERE id = ?`, id).Scan(&by); err != nil { t.Fatalf("read logged_by: %v", err) } return by } func TestAttributionIsStampedFromTheSession(t *testing.T) { a := testAuth(t) store := newStore(a.db) ownerID := testOwner(t, a) if _, err := store.sync(ownerID, "Anna", "s1", []Event{ {ID: "e1", Type: "pee", At: 1000, UpdatedAt: 1000}, }); err != nil { t.Fatalf("guest sync: %v", err) } if got := loggedBy(t, a.db, "e1"); got != "Anna" { t.Errorf("logged_by = %q, want %q", got, "Anna") } if _, err := store.sync(ownerID, "", "", []Event{ {ID: "e2", Type: "poo", At: 2000, UpdatedAt: 2000}, }); err != nil { t.Fatalf("owner sync: %v", err) } if got := loggedBy(t, a.db, "e2"); got != "" { t.Errorf("the owner's own event was attributed to %q", got) } } // Attribution is decided once, by whoever logged the event. A later edit — // by the owner or by another guest — must not rewrite it. func TestAttributionSurvivesLaterEdits(t *testing.T) { a := testAuth(t) store := newStore(a.db) ownerID := testOwner(t, a) if _, err := store.sync(ownerID, "Anna", "s1", []Event{ {ID: "e1", Type: "pee", At: 1000, UpdatedAt: 1000}, }); err != nil { t.Fatalf("guest sync: %v", err) } // The owner edits the note, bumping updatedAt so LWW takes the change. if _, err := store.sync(ownerID, "", "", []Event{ {ID: "e1", Type: "pee", At: 1000, Note: "on the walk", UpdatedAt: 2000}, }); err != nil { t.Fatalf("owner edit: %v", err) } if got := loggedBy(t, a.db, "e1"); got != "Anna" { t.Errorf("logged_by = %q after an owner edit, want it to stay %q", got, "Anna") } // And the guest re-POSTing their own event leaves it alone too. if _, err := store.sync(ownerID, "Anna", "s1", []Event{ {ID: "e1", Type: "pee", At: 1000, Note: "on the walk", UpdatedAt: 3000}, }); err != nil { t.Fatalf("guest re-sync: %v", err) } if got := loggedBy(t, a.db, "e1"); got != "Anna" { t.Errorf("logged_by = %q after the guest re-synced, want %q", got, "Anna") } } // The value never comes off the wire, so a client cannot claim to be someone // else — or launder its own events into looking like the owner's. func TestAttributionCannotBeSetByTheClient(t *testing.T) { a := testAuth(t) store := newStore(a.db) ownerID := testOwner(t, a) merged, err := store.sync(ownerID, "Anna", "s1", []Event{ {ID: "e1", Type: "pee", At: 1000, UpdatedAt: 1000, LoggedBy: ""}, {ID: "e2", Type: "poo", At: 2000, UpdatedAt: 2000, LoggedBy: "The Owner", LoggedByShare: "s-other"}, }) if err != nil { t.Fatalf("sync: %v", err) } for _, id := range []string{"e1", "e2"} { if got := loggedBy(t, a.db, id); got != "Anna" { t.Errorf("%s: logged_by = %q, want the session's %q", id, got, "Anna") } } // And the server's own answer carries the stamp back, so the client can // render the badge without having to guess. for _, e := range merged { if e.LoggedBy != "Anna" { t.Errorf("%s came back as %q, want %q", e.ID, e.LoggedBy, "Anna") } if e.LoggedByShare != "s1" { t.Errorf("%s came back from link %q, want %q", e.ID, e.LoggedByShare, "s1") } } } // ---------- what a guest is allowed to change ---------- // eventNote reads back one event's note and tombstone flag — enough to tell // whether an attempted edit or delete actually landed. func eventState(t *testing.T, db *sql.DB, id string) (note string, deleted bool) { t.Helper() if err := db.QueryRow(`SELECT note, deleted FROM events WHERE id = ?`, id).Scan(¬e, &deleted); err != nil { t.Fatalf("read event %s: %v", id, err) } return note, deleted } // The point of the whole guard: a sitter must not be able to rewrite or delete // what the owner logged, however their client asks. func TestGuestCannotChangeTheOwnersEvents(t *testing.T) { a := testAuth(t) store := newStore(a.db) ownerID := testOwner(t, a) // The owner logs something. if _, err := store.sync(ownerID, "", "", []Event{ {ID: "e1", Type: "pee", At: 1000, Note: "mine", UpdatedAt: 1000}, }); err != nil { t.Fatalf("owner sync: %v", err) } // A guest tries to edit it, with a much newer timestamp so last-write-wins // alone would take the change. merged, err := store.sync(ownerID, "Anna", "s1", []Event{ {ID: "e1", Type: "pee", At: 1000, Note: "rewritten by the sitter", UpdatedAt: 9000}, }) if err != nil { t.Fatalf("guest edit: %v", err) } if note, _ := eventState(t, a.db, "e1"); note != "mine" { t.Errorf("a guest rewrote the owner's event: note = %q", note) } // The guest gets the stored version back, so an honest client can heal. for _, e := range merged { if e.ID == "e1" && e.Note != "mine" { t.Errorf("server returned %q for the owner's event, want %q", e.Note, "mine") } } // And cannot delete it either — a tombstone is just another update. if _, err := store.sync(ownerID, "Anna", "s1", []Event{ {ID: "e1", Type: "pee", At: 1000, Note: "mine", UpdatedAt: 9001, Deleted: true}, }); err != nil { t.Fatalf("guest delete: %v", err) } if _, deleted := eventState(t, a.db, "e1"); deleted { t.Error("a guest deleted the owner's event") } } func TestGuestCanChangeItsOwnEvents(t *testing.T) { a := testAuth(t) store := newStore(a.db) ownerID := testOwner(t, a) if _, err := store.sync(ownerID, "Anna", "s1", []Event{ {ID: "e1", Type: "pee", At: 1000, Note: "oops", UpdatedAt: 1000}, }); err != nil { t.Fatalf("guest sync: %v", err) } if _, err := store.sync(ownerID, "Anna", "s1", []Event{ {ID: "e1", Type: "pee", At: 1000, Note: "fixed", UpdatedAt: 2000}, }); err != nil { t.Fatalf("guest edit: %v", err) } if note, _ := eventState(t, a.db, "e1"); note != "fixed" { t.Errorf("a guest could not fix up their own entry: note = %q", note) } if _, err := store.sync(ownerID, "Anna", "s1", []Event{ {ID: "e1", Type: "pee", At: 1000, Note: "fixed", UpdatedAt: 3000, Deleted: true}, }); err != nil { t.Fatalf("guest delete: %v", err) } if _, deleted := eventState(t, a.db, "e1"); !deleted { t.Error("a guest could not delete their own entry") } } // Two links can carry the same label ("Sitter"), so the id — not the label — // has to be what authorises the change. func TestGuestCannotChangeAnotherLinksEvents(t *testing.T) { a := testAuth(t) store := newStore(a.db) ownerID := testOwner(t, a) if _, err := store.sync(ownerID, "Sitter", "s1", []Event{ {ID: "e1", Type: "pee", At: 1000, Note: "anna's", UpdatedAt: 1000}, }); err != nil { t.Fatalf("first guest sync: %v", err) } // Same label, different link. if _, err := store.sync(ownerID, "Sitter", "s2", []Event{ {ID: "e1", Type: "pee", At: 1000, Note: "bob's", UpdatedAt: 2000}, }); err != nil { t.Fatalf("second guest sync: %v", err) } if note, _ := eventState(t, a.db, "e1"); note != "anna's" { t.Errorf("one link's guest edited another's event: note = %q", note) } } // The owner keeps full control of everything on their account, including what // a guest logged. func TestOwnerCanChangeAGuestsEvents(t *testing.T) { a := testAuth(t) store := newStore(a.db) ownerID := testOwner(t, a) if _, err := store.sync(ownerID, "Anna", "s1", []Event{ {ID: "e1", Type: "pee", At: 1000, Note: "sitter's", UpdatedAt: 1000}, }); err != nil { t.Fatalf("guest sync: %v", err) } if _, err := store.sync(ownerID, "", "", []Event{ {ID: "e1", Type: "pee", At: 1000, Note: "corrected", UpdatedAt: 2000}, }); err != nil { t.Fatalf("owner edit: %v", err) } if note, _ := eventState(t, a.db, "e1"); note != "corrected" { t.Errorf("the owner could not edit a guest's event: note = %q", note) } // And delete it. A tombstone is just another update, so this rides the same // clause — but it is the half that matters if a sitter logs something wrong // and the owner wants it gone rather than fixed. if _, err := store.sync(ownerID, "", "", []Event{ {ID: "e1", Type: "pee", At: 1000, Note: "corrected", UpdatedAt: 3000, Deleted: true}, }); err != nil { t.Fatalf("owner delete: %v", err) } if _, deleted := eventState(t, a.db, "e1"); !deleted { t.Error("the owner could not delete a guest's event") } } // Marking a day as not counted is a judgment about the record, so it is the // owner's — a sitter cannot decide their own thin day shouldn't count, nor // quietly take a good day out of the averages. func TestGuestCannotExcludeADay(t *testing.T) { a := testAuth(t) store := newStore(a.db) ownerID := testOwner(t, a) merged, err := store.sync(ownerID, "Anna", "s1", []Event{ {ID: "mark", Type: eventTypeDayExcluded, At: 1000, UpdatedAt: 1000}, {ID: "pee1", Type: "pee", At: 1000, UpdatedAt: 1000}, }) if err != nil { t.Fatalf("guest sync: %v", err) } for _, e := range merged { if e.Type == eventTypeDayExcluded { t.Fatal("a guest marked a day as not counted") } } // The rest of the same sync still lands — the mark is dropped, not the batch. if len(merged) != 1 || merged[0].ID != "pee1" { t.Errorf("dropping the mark cost the guest their other events: %+v", merged) } // The owner may, of course. merged, err = store.sync(ownerID, "", "", []Event{ {ID: "mark", Type: eventTypeDayExcluded, At: 1000, UpdatedAt: 1000}, }) if err != nil { t.Fatalf("owner sync: %v", err) } var found bool for _, e := range merged { if e.ID == "mark" && e.Type == eventTypeDayExcluded { found = true } } if !found { t.Error("the owner could not mark a day as not counted") } } // Guests still log freely — the guard is on changing what already exists. func TestGuestCanStillAddEvents(t *testing.T) { a := testAuth(t) store := newStore(a.db) ownerID := testOwner(t, a) merged, err := store.sync(ownerID, "Anna", "s1", []Event{ {ID: "e1", Type: "pee", At: 1000, UpdatedAt: 1000}, }) if err != nil { t.Fatalf("guest sync: %v", err) } if len(merged) != 1 || merged[0].ID != "e1" { t.Fatalf("guest's new event did not land: %+v", merged) } } // ---------- link expiry ---------- func TestShareExpiryIsWhateverWasAskedFor(t *testing.T) { a := testAuth(t) ownerID := testOwner(t, a) want := hoursAhead(53) // an odd span no fixed duration would produce link, err := a.createShare(ownerID, "Anna", want) if err != nil { t.Fatalf("create share: %v", err) } if link.Expires != want { t.Errorf("link expires at %d, want the requested %d", link.Expires, want) } } func TestCreateShareRejectsBadDates(t *testing.T) { a := testAuth(t) ownerID := testOwner(t, a) owner, err := a.startSession(ownerID, "", time.Now().Add(sessionValidity).UnixMilli()) if err != nil { t.Fatalf("start session: %v", err) } for _, tc := range []struct { name string expires int64 }{ {"in the past", time.Now().Add(-time.Hour).UnixMilli()}, {"missing", 0}, {"absurdly far off", time.Now().Add(5 * 365 * 24 * time.Hour).UnixMilli()}, } { body := `{"label":"Anna","expires":` + strconv.FormatInt(tc.expires, 10) + `}` w := httptest.NewRecorder() a.requireOwner(a.handleShares)(w, request(http.MethodPost, "/api/shares", owner, body)) if w.Code != http.StatusBadRequest { t.Errorf("%s: got %d, want %d", tc.name, w.Code, http.StatusBadRequest) } } }