Handing a dog sitter the ability to log a pee meant handing them the account password: permanent, total control, revocable only by changing it. Settings → Guest access now mints a URL that does the one thing instead. A link is a session, not an account. Opening /guest/<token> inserts an ordinary session row against the owner's user_id, tagged with the link it came from, so every data path downstream — sync, photos, the profile — stays scoped by user_id exactly as before and needed no changes at all. Only the capability checks differ by role, which is what kept this from touching the sync contract. Redemption is a plain GET so tapping the link in a message works, and the 303 to / leaves the token out of the address bar, bookmarks and the PWA start URL. What a guest cannot change is enforced in the upsert, not in the UI. The WHERE clause gains a logged_by_share test: an owner (empty share id) may change anything, a guest only rows carrying their own link's id. A sitter can fix up their own entries and cannot rewrite or delete one of the owner's, including everything logged before this existed, since those rows carry the empty id too. Deletes come along free, being tombstones. The test is on the link id rather than its label because two links can easily both be "Sitter", and the id is also why /api/me hands the guest its share id: the client needs it to know what to grey out. The exercise library is the owner's on the same reasoning — a guest trains against it but the server drops any exercise a guest sends. Attribution is stamped from the session on insert and left out of DO UPDATE SET, so it is decided once by whoever logged the event and survives every later edit. It never comes off the wire, so it cannot be forged — a guest re-POSTs the owner's whole event list on every sync, but those rows already exist and keep their stored values. Expiry is a date the owner picks; the link dies at the end of that day in their own timezone, which the client computes because the server has no way to know it. Sessions are capped at the link's own end, and every request re-checks the link is live rather than trusting the session row, so revoking kicks a guest out on their next request instead of whenever their session happens to lapse. Only the token hash is stored, as with session tokens, so the URL is shown once at creation and cannot be read back. The client side follows from that. A guest opening someone else's entry gets the edit dialog read-only rather than a form that would silently discard what they typed, and mergeSynced takes the server's copy for anything they may not change — otherwise a refused write would sit in their cache forever showing an edit that never happened. An ended link wipes their cached copy of someone else's history and says so, rather than offering a sign-in form they have no password for.
648 lines
20 KiB
Go
648 lines
20 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(¬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")
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|