Grams alone put dry and fresh in the same total, so the log could not show that fresh had been creeping up or that a soft stomach followed a switch. A meal can now carry a kind the user names themselves. The whole thing is optional, and that constraint shaped most of it. "No kind" is a real value rather than a missing one: it is what every meal already logged carries, so nothing needed migrating; it is always offered in the picker; and with no kinds defined the picker, the legend and the split are all absent, so the app is byte-for-byte the one it was for anyone who never wants this. The checks cover that case specifically, because it is the one nobody would notice breaking. Kinds are a third synced collection beside events and exercises, with the same contract — uuid ids, per-item last-write-wins, tombstoned deletes — so renaming a kind updates the meals logged as it, and deleting one leaves them readable under the name the tombstone kept. FoodKindStore duplicates ExerciseStore closely; Store and ExerciseStore were already near-twins, so a third in that shape is this file's pattern and leaves two working collections untouched. Folding all three into one store over a table name is the tidier end state and a separate job. Two decisions worth naming. The default kind is a flag on the kind rather than a profile field: the profile is last-write-wins across the whole row, and this codebase already carries a special case for pedigree_id because that dropped a value once — per-item LWW means two devices that each choose a default resolve to the newer instead. And each kind keeps a colorIndex fixed at creation, so deleting one never repaints the charts of the kinds around it. The bars stack by kind with a line fitted per kind. Each line sits at that kind's own daily amount rather than at the top of its segment: the segment's height is what the kind ate, but its position is an accident of what is stacked beneath it. So a line can cross a segment it does not belong to — dashed and in the kind's colour, with the figures named underneath either way. One sentence per kind would grow with the list, so only kinds whose move beats their own scatter get one and the rest fold into a clause. Both tests are ones foodTrend already applied; nothing new is being claimed. A guest labels a meal with a kind that exists but cannot add, rename or delete one, exactly as with the exercise library.
803 lines
25 KiB
Go
803 lines
25 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))
|
|
}
|
|
}
|
|
|
|
// Settings shows every live link's URL so it can be re-sent, which means a
|
|
// listing has to carry the same secret the link was created with — and that
|
|
// secret has to still work.
|
|
func TestListSharesReturnsAWorkingURL(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)
|
|
}
|
|
links, err := a.listShares(ownerID)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if len(links) != 1 {
|
|
t.Fatalf("listed %d link(s), want 1", len(links))
|
|
}
|
|
if links[0].Token != link.Token {
|
|
t.Fatalf("listing returned %q, want the issued secret %q", links[0].Token, link.Token)
|
|
}
|
|
if _, _, ok := a.redeemShare(links[0].Token); !ok {
|
|
t.Error("the secret handed back by the listing does not open the link")
|
|
}
|
|
}
|
|
|
|
// The lookup column stays a hash even though the secret is kept beside it, so
|
|
// the token in a URL is never what is matched against directly.
|
|
func TestLookupIsStillByHash(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 != hashToken(link.Token) {
|
|
t.Error("the lookup column is not the hash of the issued token")
|
|
}
|
|
}
|
|
|
|
// A link made before secrets were kept has no URL to show. It must still work
|
|
// and still be revocable — only the copy-again affordance is unavailable.
|
|
func TestLinkWithoutAStoredSecretStillWorks(t *testing.T) {
|
|
a := testAuth(t)
|
|
ownerID := testOwner(t, a)
|
|
link, err := a.createShare(ownerID, "Legacy", hoursAhead(24))
|
|
if err != nil {
|
|
t.Fatalf("create share: %v", err)
|
|
}
|
|
// What the migration leaves behind for a pre-existing row.
|
|
if _, err := a.db.Exec(`UPDATE share_links SET secret = '' WHERE id = ?`, link.ID); err != nil {
|
|
t.Fatalf("clear secret: %v", err)
|
|
}
|
|
links, err := a.listShares(ownerID)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if len(links) != 1 || links[0].Token != "" {
|
|
t.Fatalf("want the link listed with an empty token, got %+v", links)
|
|
}
|
|
if _, _, ok := a.redeemShare(link.Token); !ok {
|
|
t.Error("a link whose secret was never stored stopped working")
|
|
}
|
|
if err := a.revokeShare(ownerID, link.ID); err != nil {
|
|
t.Errorf("could not revoke it: %v", err)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
// The food kinds are the owner's library, like the exercise list: a guest
|
|
// labels a meal with a kind that exists but does not invent or rename one.
|
|
func TestGuestCannotChangeFoodKinds(t *testing.T) {
|
|
a := testAuth(t)
|
|
kinds := newFoodKindStore(a.db)
|
|
ownerID := testOwner(t, a)
|
|
|
|
if _, err := kinds.sync(ownerID, []FoodKind{
|
|
{ID: "k1", Name: "Dry", IsDefault: true, UpdatedAt: 1000},
|
|
}); err != nil {
|
|
t.Fatalf("owner sync: %v", err)
|
|
}
|
|
|
|
// What the route hands the store for a guest: nothing incoming, everything
|
|
// back. Mirrors the exercises guard in main.go.
|
|
merged, err := kinds.sync(ownerID, nil)
|
|
if err != nil {
|
|
t.Fatalf("guest sync: %v", err)
|
|
}
|
|
if len(merged) != 1 || merged[0].Name != "Dry" {
|
|
t.Fatalf("a guest should still receive the library: %+v", merged)
|
|
}
|
|
if !merged[0].IsDefault {
|
|
t.Error("the default flag did not survive the round trip")
|
|
}
|
|
|
|
// And the owner can still rename it, which is the other half of the rule.
|
|
renamed, err := kinds.sync(ownerID, []FoodKind{
|
|
{ID: "k1", Name: "Dry kibble", IsDefault: true, UpdatedAt: 2000},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("owner rename: %v", err)
|
|
}
|
|
if renamed[0].Name != "Dry kibble" {
|
|
t.Errorf("owner could not rename a kind: %q", renamed[0].Name)
|
|
}
|
|
}
|
|
|
|
// A meal's kind rides the event sync like any other field, and an older client
|
|
// that doesn't know about kinds must not wipe one.
|
|
func TestFoodKindOnAnEventSurvivesSync(t *testing.T) {
|
|
a := testAuth(t)
|
|
store := newStore(a.db)
|
|
ownerID := testOwner(t, a)
|
|
|
|
merged, err := store.sync(ownerID, "", "", []Event{
|
|
{ID: "e1", Type: "eat", At: 1000, Grams: 180, FoodKindID: "k1", UpdatedAt: 1000},
|
|
{ID: "e2", Type: "eat", At: 2000, Grams: 120, UpdatedAt: 2000}, // no kind, as before
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("sync: %v", err)
|
|
}
|
|
byID := map[string]Event{}
|
|
for _, e := range merged {
|
|
byID[e.ID] = e
|
|
}
|
|
if byID["e1"].FoodKindID != "k1" {
|
|
t.Errorf("the kind did not round-trip: %q", byID["e1"].FoodKindID)
|
|
}
|
|
if byID["e2"].FoodKindID != "" {
|
|
t.Errorf("a meal with no kind gained one: %q", byID["e2"].FoodKindID)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|