Files
puppy-tracker/server/auth_test.go
T
Alexander Heldt da68b733e4 Let a day be left out of the stats
Every logged day was treated as equally trustworthy, and they aren't. A day
someone else had the puppy leaves a thin record that reads exactly like a real
one — five hours of sleep, two pees, no walk — and then drags down the average,
widens the longest gap in the Timing panel and puts a trough in every chart
that never happened. "Not counted", in the overview panel's heading, takes the
day you are looking at out of everything that aggregates across days.

Nothing is deleted or hidden. The day's own overview, history and sleep & wake
list are exactly as they were, dimmed and labelled; navigate to it and it is
all still there. Only the cross-day views stop seeing it, and weight and notes
keep counting wherever they fall — a weigh-in and a vet note are facts you
recorded, not behaviour a sparse logger distorts.

The mark is an ordinary event, the way a training session is. That was the
whole reason to do it this way: a set of marks that sync per-item with
last-write-wins and tombstones is exactly what the event contract already
provides, so un-marking is a delete, offline works, and two devices marking the
same day resolve themselves. An excluded_days table would have meant a table,
an endpoint, a request/response pair and a client cache to re-derive semantics
already in hand. Every renderer selects events by type, so a new type is inert
everywhere it isn't wanted; only the History log has to filter it out, being
the one view that shows whatever it is handed.

render() already computed the event list once and fanned it out, which made the
seam a single place: day-scoped panels keep the full list, weight and notes
keep it too, and the seven cross-day renderers take a counted one.

Filtering alone gets two things wrong, and those are most of the diff.

An empty slot lies. A marked day with no events draws a zero bar, which reads
as "the puppy barely slept" — precisely the misreading the mark exists to
prevent. So weeklyData zeroes the day's figures and flags it, and the four bar
charts, both actograms and the training grid paint a hatch in the slot instead.
Zeroing centrally rather than in each chart means every axis maximum, total and
tooltip downstream is already right. The slot stays: dropping it would make
consecutive bars stop being consecutive days.

Gaps balloon. gapsBetween subtracts consecutive events, so with a day's events
gone Tuesday's last pee sits next to Thursday's first and the subtraction
invents thirty hours — worse for the panel than the sparse day ever was. Any
gap whose interval touches a marked day is therefore discarded rather than
measured. Sleep and walk durations need no such care: sleepMsInRange and
walkMsInRange already clip to the day being measured, so a nap running in from
a marked day contributes only its counted part.

Both trend charts skip marked days explicitly rather than leaning on their
existing "any sleep at all" guard, which would have let a nap crossing midnight
give a marked day a non-zero total and sneak it back into the average.

Owner-only, alongside the rest of what a guest may not decide: a sitter should
not be able to rule their own thin day out, nor quietly take a good one out of
the averages. The server drops day-excluded events arriving on a guest session;
the client hides the control to match.
2026-09-07 19:01:06 +00:00

691 lines
21 KiB
Go

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(&note, &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)
}
}
}