package api import ( "encoding/json" "net/http" "strconv" "testing" ) func shareLinkPath(gid int64) string { return "/api/v1/gardens/" + strconv.FormatInt(gid, 10) + "/share-link" } func publicGardenPath(token string) string { return "/api/v1/public/gardens/" + token } // TestPublicShareLinkAPI checks the HTTP surface: owner-only link management, and // an unauthenticated public read that returns 200 (never a redirect) for a live // token and 404 for an unknown/rotated/disabled one — without a session cookie. func TestPublicShareLinkAPI(t *testing.T) { r := authEngine(t, localCfg()) owner := registerAndCookie(t, r, "owner@example.com") other := registerAndCookie(t, r, "other@example.com") gid := createGardenAPI(t, r, owner, "Owner's yard") // Seed a bed so the public payload has visible content. if w := doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{"kind": "bed", "xCm": 500, "yCm": 500, "widthCm": 200, "heightCm": 200}, owner); w.Code != http.StatusCreated { t.Fatalf("seed bed = %d, body %s", w.Code, w.Body.String()) } // Disabled by default. w := doJSON(t, r, http.MethodGet, shareLinkPath(gid), nil, owner) if w.Code != http.StatusOK { t.Fatalf("get link = %d, body %s", w.Code, w.Body.String()) } if decodeMap(t, w.Body.Bytes())["enabled"] != false { t.Fatalf("new garden link should be disabled: %s", w.Body.String()) } // A non-owner can't manage the link (garden invisible to them → 404 masked). if w := doJSON(t, r, http.MethodPost, shareLinkPath(gid), nil, other); w.Code != http.StatusNotFound { t.Errorf("non-owner enable = %d, want 404", w.Code) } // Owner enables → a token. w = doJSON(t, r, http.MethodPost, shareLinkPath(gid), nil, owner) if w.Code != http.StatusOK { t.Fatalf("enable = %d, body %s", w.Code, w.Body.String()) } body := decodeMap(t, w.Body.Bytes()) if body["enabled"] != true { t.Fatalf("enable body = %s, want enabled", w.Body.String()) } tok, _ := body["token"].(string) if tok == "" { t.Fatalf("no token in enable body: %s", w.Body.String()) } // Public read works with NO cookie — the whole point is no login/redirect. w = doJSON(t, r, http.MethodGet, publicGardenPath(tok), nil, nil) if w.Code != http.StatusOK { t.Fatalf("public read = %d, body %s", w.Code, w.Body.String()) } var full struct { Garden map[string]any `json:"garden"` Objects []map[string]any `json:"objects"` } if err := json.Unmarshal(w.Body.Bytes(), &full); err != nil { t.Fatalf("decode public: %v", err) } if int64(full.Garden["id"].(float64)) != gid { t.Errorf("public garden id mismatch: %s", w.Body.String()) } if len(full.Objects) != 1 { t.Errorf("public objects = %d, want 1", len(full.Objects)) } if _, leaked := full.Garden["publicToken"]; leaked { t.Errorf("public_token leaked into the garden payload: %s", w.Body.String()) } // Rotate → a new token; the old one stops resolving. w = doJSON(t, r, http.MethodPost, shareLinkPath(gid), map[string]any{"rotate": true}, owner) newTok, _ := decodeMap(t, w.Body.Bytes())["token"].(string) if newTok == "" || newTok == tok { t.Fatalf("rotate token = %q (old %q), want a fresh token", newTok, tok) } if w := doJSON(t, r, http.MethodGet, publicGardenPath(tok), nil, nil); w.Code != http.StatusNotFound { t.Errorf("old token after rotate = %d, want 404", w.Code) } if w := doJSON(t, r, http.MethodGet, publicGardenPath(newTok), nil, nil); w.Code != http.StatusOK { t.Errorf("new token = %d, want 200", w.Code) } // Disable → the link is off and the token 404s. if w := doJSON(t, r, http.MethodDelete, shareLinkPath(gid), nil, owner); w.Code != http.StatusOK { t.Errorf("disable = %d, body %s", w.Code, w.Body.String()) } if w := doJSON(t, r, http.MethodGet, publicGardenPath(newTok), nil, nil); w.Code != http.StatusNotFound { t.Errorf("token after disable = %d, want 404", w.Code) } // An unknown token is a plain 404, never a redirect to login. if w := doJSON(t, r, http.MethodGet, publicGardenPath("nope"), nil, nil); w.Code != http.StatusNotFound { t.Errorf("unknown token = %d, want 404", w.Code) } }