package http
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/andrew/go-computer/internal/auth"
"github.com/andrew/go-computer/internal/signals"
"github.com/emersion/go-imap/backend/memory"
imapserver "github.com/emersion/go-imap/server"
"github.com/golang-jwt/jwt/v5"
)
// Covers routes that previously had no coverage at any level (CODE_REVIEW
// TEST-1) plus signout revocation (AUTH-5) and the background scheduler
// (TEST-2).
func (a apiClient) getJSON(path string, status int) []map[string]any {
a.t.Helper()
res, e := a.c.Get(a.base + path)
if e != nil {
a.t.Fatal(e)
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
if res.StatusCode != status {
a.t.Fatalf("GET %s: wanted %d, got %d: %s", path, status, res.StatusCode, raw)
}
var v []map[string]any
if e := json.Unmarshal(raw, &v); e != nil {
a.t.Fatalf("GET %s: not a JSON array: %v: %s", path, e, raw)
}
return v
}
func (a apiClient) getText(path string, status int) string {
a.t.Helper()
res, e := a.c.Get(a.base + path)
if e != nil {
a.t.Fatal(e)
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
if res.StatusCode != status {
a.t.Fatalf("GET %s: wanted %d, got %d: %s", path, status, res.StatusCode, raw)
}
return string(raw)
}
func newAPIUser(t *testing.T, srv *httptest.Server, email string) apiClient {
t.Helper()
jar, _ := cookiejar.New(nil)
c := apiClient{t, &http.Client{Jar: jar}, srv.URL}
creds := map[string]any{"email": email, "password": "coverage-test-password"}
c.request("POST", "/api/signup", creds, 201)
c.request("POST", "/api/auth/login", creds, 200)
return c
}
func TestPreviouslyUncoveredRoutes(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "coverage-alice@computer.test")
newAPIUser(t, srv, "coverage-searchable@computer.test")
// GET /api/preview — handler plumbing (auth + query + parse).
html := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
io.WriteString(w, `
Preview Targetx`)
}))
defer html.Close()
anon, _ := cookiejar.New(nil)
anonymous := apiClient{t, &http.Client{Jar: anon}, srv.URL}
anonymous.request("GET", "/api/preview?url="+html.URL, nil, 401)
if v := alice.request("GET", "/api/preview?url="+html.URL, nil, 200); v["title"] != "Preview Target" || v["description"] != "Described" {
t.Fatal("preview handler:", v)
}
// GET /api/bookmarks/{id}/markdown — content, not just the link element.
b := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://example.org/coverage", "title": "Coverage Bookmark", "notes": "# Coverage notes"}, 201)
id := b["id"].(string)
md := alice.getText("/api/bookmarks/"+id+"/markdown", 200)
if !strings.Contains(md, "https://example.org/coverage") || !strings.Contains(md, "Coverage notes") {
t.Fatal("bookmark markdown:", md)
}
// GET /api/users/search — name substring, exact email, masked output,
// no email-substring enumeration, 2-rune guard.
users := alice.getJSON("/api/users/search?q=coverage-sea", 200)
if len(users) != 1 || users[0]["email"] != "c***@computer.test" {
t.Fatal("users/search name match:", users)
}
if users := alice.getJSON("/api/users/search?q=coverage-searchable@computer.test", 200); len(users) != 1 {
t.Fatal("users/search exact email:", users)
}
// "able@computer.test" is a substring of the target's email but not a
// full email and not part of any name: must not match.
if users := alice.getJSON("/api/users/search?q=able%40computer.test", 200); len(users) != 0 {
t.Fatal("users/search must not substring-match emails:", users)
}
if users := alice.getJSON("/api/users/search?q=c", 200); len(users) != 0 {
t.Fatal("users/search short-query guard:", users)
}
// Review SEC-3: LIKE wildcards are escaped, so "%%"/"_%" cannot enumerate
// the whole user base. "%%" matches a literal percent-pair (2 runes, so
// it passes the minimum-length guard); a name literally containing "%%"
// still matches, but nothing else does.
if users := alice.getJSON("/api/users/search?q=%25%25", 200); len(users) != 0 {
t.Fatalf("users/search wildcard enumeration: %v", users)
}
// PATCH /api/kanban/columns/{id} and PUT /api/kanban/columns happy paths.
col := alice.request("POST", "/api/kanban/columns", map[string]any{"label": "Coverage"}, 201)["id"].(string)
alice.request("PATCH", "/api/kanban/columns/"+col, map[string]any{"label": "Renamed", "color": "#00ff00"}, 200)
cols := alice.getJSON("/api/kanban/columns", 200)
var found bool
order := make([]string, 0, len(cols))
for _, c := range cols {
order = append(order, c["id"].(string))
if c["id"] == col {
found = c["label"] == "Renamed" && c["color"] == "#00ff00"
}
}
if !found {
t.Fatal("column patch not applied:", cols)
}
reversed := make([]string, 0, len(order))
for i := len(order) - 1; i >= 0; i-- {
reversed = append(reversed, order[i])
}
alice.request("PUT", "/api/kanban/columns", map[string]any{"order": reversed}, 200)
after := alice.getJSON("/api/kanban/columns", 200)
if after[0]["id"] != reversed[0] || after[len(after)-1]["id"] != reversed[len(reversed)-1] {
t.Fatal("column reorder not applied")
}
// PUT /api/pages reorder.
p1 := alice.request("POST", "/api/pages", map[string]any{"title": "P1"}, 201)["id"].(string)
p2 := alice.request("POST", "/api/pages", map[string]any{"title": "P2"}, 201)["id"].(string)
p3 := alice.request("POST", "/api/pages", map[string]any{"title": "P3"}, 201)["id"].(string)
alice.request("PUT", "/api/pages", map[string]any{"order": []string{p3, p1, p2}}, 200)
pages := alice.getJSON("/api/pages", 200)
if pages[0]["id"] != p3 || pages[1]["id"] != p1 || pages[2]["id"] != p2 {
t.Fatal("pages reorder not applied:", pages)
}
// DELETE /api/tags/{id} owner success + BookmarkTag cascade.
tag := alice.request("POST", "/api/tags", map[string]any{"name": "DeleteMe"}, 201)["id"].(string)
putBookmark(alice, id, map[string]any{"tagIds": []string{tag}}, 200)
alice.request("DELETE", "/api/tags/"+tag, nil, 200)
if v := alice.request("GET", "/api/bookmarks/"+id, nil, 200); len(v["tags"].([]any)) != 0 {
t.Fatal("tag delete did not cascade bookmark_tags")
}
for _, tg := range alice.getJSON("/api/tags", 200) {
if tg["id"] == tag {
t.Fatal("deleted tag still listed")
}
}
// DELETE /api/signal-sources/{id} owner success.
source := alice.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"type": "email", "config": map[string]any{"subject": "x"}}, 201)["id"].(string)
alice.request("DELETE", "/api/signal-sources/"+source, nil, 200)
if v := alice.request("GET", "/api/bookmarks/"+id+"/signals", nil, 200); len(v["sources"].([]any)) != 0 {
t.Fatal("signal source still present after delete")
}
}
// Signout must revoke every issued cookie JWT, not just clear this browser's
// cookie: a token copied before signout stops working.
func TestSignoutRevokesIssuedTokens(t *testing.T) {
srv := testServer(t)
u := newAPIUser(t, srv, "revoke@computer.test")
creds, _ := json.Marshal(map[string]any{"email": "revoke@computer.test", "password": "coverage-test-password"})
req, _ := http.NewRequest("POST", srv.URL+"/api/auth/login", bytes.NewReader(creds))
req.Header.Set("Content-Type", "application/json")
res, e := u.c.Do(req)
if e != nil {
t.Fatal(e)
}
res.Body.Close()
var stale string
for _, c := range res.Cookies() {
if c.Name == "computer_session" {
stale = c.Value
}
}
if stale == "" {
t.Fatal("login did not set a session cookie")
}
u.request("POST", "/api/auth/signout", map[string]any{}, 200)
// Replay the pre-signout token directly (no jar): must be rejected.
replay := func(path string) int {
rq, _ := http.NewRequest("GET", srv.URL+path, nil)
rq.AddCookie(&http.Cookie{Name: "computer_session", Value: stale})
rs, e := u.c.Do(rq)
if e != nil {
t.Fatal(e)
}
defer rs.Body.Close()
io.Copy(io.Discard, rs.Body)
return rs.StatusCode
}
if code := replay("/api/me"); code != 401 {
t.Fatalf("stale cookie accepted on /api/me: %d", code)
}
if code := replay("/api/bookmarks"); code != 401 {
t.Fatalf("stale cookie accepted on data route: %d", code)
}
rq, _ := http.NewRequest("GET", srv.URL+"/api/auth/session", nil)
rq.AddCookie(&http.Cookie{Name: "computer_session", Value: stale})
rs, e := u.c.Do(rq)
if e != nil {
t.Fatal(e)
}
defer rs.Body.Close()
raw, _ := io.ReadAll(rs.Body)
if !bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
t.Fatalf("stale cookie still resolves a session: %s", raw)
}
}
// One scheduler tick creates signals and marks sources checked (TEST-2).
func TestSignalSchedulerRunOnce(t *testing.T) {
srv, d := testServerDB(t)
alice := newAPIUser(t, srv, "scheduler@computer.test")
listener, e := net.Listen("tcp", "127.0.0.1:0")
if e != nil {
t.Fatal(e)
}
mail := imapserver.New(memory.New())
mail.AllowInsecureAuth = true
go mail.Serve(listener)
defer mail.Close()
host, port, _ := net.SplitHostPort(listener.Addr().String())
pn, _ := strconv.Atoi(port)
alice.request("POST", "/api/settings/imap", map[string]any{"host": host, "port": pn, "tls": false, "username": "username", "password": "password", "folder": "INBOX"}, 200)
id := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://example.org/scheduler", "imapQuery": "little"}, 201)["id"].(string)
alice.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"type": "email", "config": map[string]any{"from": "contact@example.org", "subject": "little"}}, 201)
signals.RunOnce(context.Background(), d)
v := alice.request("GET", "/api/bookmarks/"+id+"/signals", nil, 200)
if len(v["signals"].([]any)) == 0 {
t.Fatal("scheduler tick created no signals")
}
sources := v["sources"].([]any)
if len(sources) != 1 {
t.Fatalf("expected 1 source, got %d", len(sources))
}
src := sources[0].(map[string]any)
if src["lastStatus"] != "Checked" || src["lastCheckedAt"] == nil {
t.Fatal("scheduler did not mark source checked:", src)
}
// A second tick must not duplicate signals (dedup under the scheduler path).
before := len(v["signals"].([]any))
signals.RunOnce(context.Background(), d)
v = alice.request("GET", "/api/bookmarks/"+id+"/signals", nil, 200)
if len(v["signals"].([]any)) != before {
t.Fatal("scheduler tick duplicated signals")
}
}
// Review CR-16: mailbox failures are scheduler failures. A pass whose mailbox
// cannot even load must record the per-source status, answer manual checks
// with the structured outcome (failed count, not a blanket 500), and drive
// the per-user backoff so the next ticks are skipped.
func TestSchedulerBacksOffAfterMailboxFailure(t *testing.T) {
srv, d := testServerDB(t)
alice := newAPIUser(t, srv, "backoff@computer.test")
id := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://example.org/backoff"}, 201)["id"].(string)
alice.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"type": "email", "config": map[string]any{"subject": "anything"}}, 201)
// Deliberately no saved IMAP configuration: every pass fails in LoadMailbox.
v := alice.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"action": "check"}, 200)
if v["checked"] != float64(1) || v["failed"] != float64(1) {
t.Fatal("manual check did not report the structured failure outcome:", v)
}
src := func() map[string]any {
got := alice.request("GET", "/api/bookmarks/"+id+"/signals", nil, 200)
return got["sources"].([]any)[0].(map[string]any)
}
if status, _ := src()["lastStatus"].(string); !strings.HasPrefix(status, "Error:") {
t.Fatal("per-source failure status not recorded:", status)
}
s := signals.NewScheduler()
ctx := context.Background()
s.RunOnce(ctx, d) // tick 1: runs and fails → failures=1
s.RunOnce(ctx, d) // tick 2: 2%2==0 → runs and fails → failures=2
after := src()["lastCheckedAt"]
s.RunOnce(ctx, d) // tick 3: 3%4!=0 → backoff must skip it
if got := src()["lastCheckedAt"]; got != after {
t.Fatalf("backoff did not skip tick 3: lastCheckedAt changed %v → %v", after, got)
}
}
// Page saves enforce optimistic concurrency (review DATA-01/DATA-05): a stale
// base version is rejected with 409 and a missing version with 400, both
// leaving the row untouched; content saves bump the revision, and metadata
// patches neither require nor bump it. The list projection omits content
// bodies (review PERF-01).
func TestPageVersionAndListProjection(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "pagever@computer.test")
created := alice.request("POST", "/api/pages", map[string]any{"title": "Doc", "content": "first"}, 201)
id := created["id"].(string)
if created["version"].(float64) != 1 {
t.Fatalf("new page did not start at version 1: %v", created["version"])
}
alice.request("PATCH", "/api/pages/"+id, map[string]any{"content": "stale tab", "version": 99}, 409)
if v := alice.request("GET", "/api/pages/"+id, nil, 200); v["content"] != "first" {
t.Fatal("rejected stale write still modified content")
}
saved := alice.request("PATCH", "/api/pages/"+id, map[string]any{"content": "second", "version": 1}, 200)
if saved["version"].(float64) != 2 {
t.Fatalf("content save did not bump version: %v", saved["version"])
}
// Review DATA-05: omitting the version cannot bypass concurrency — the
// write is rejected and the row is untouched. Metadata patches stay
// version-free (they do not bump the revision either).
alice.request("PATCH", "/api/pages/"+id, map[string]any{"content": "unversioned clobber"}, 400)
if v := alice.request("GET", "/api/pages/"+id, nil, 200); v["content"] != "second" || v["version"].(float64) != 2 {
t.Fatalf("version-less content write mutated the page: %v", v)
}
pin := alice.request("PATCH", "/api/pages/"+id, map[string]any{"pinned": true}, 200)
if pin["version"].(float64) != 2 {
t.Fatalf("metadata patch changed the revision: %v", pin["version"])
}
alice.request("PATCH", "/api/pages/"+id, map[string]any{"content": "third", "version": 1}, 409)
alice.request("PATCH", "/api/pages/"+id, map[string]any{"content": "x", "version": -1}, 400)
found := false
for _, p := range alice.getJSON("/api/pages", 200) {
if p["id"] != id {
continue
}
found = true
if _, ok := p["content"]; ok {
t.Fatal("list projection still includes content")
}
if p["title"] != "Doc" || p["_count"] == nil {
t.Fatal("list projection lost metadata:", p)
}
}
if !found {
t.Fatal("page missing from list")
}
}
// Review CR-18: a content-save acknowledgement must describe the revision
// THIS write produced. Concurrent writers racing on the same base: exactly
// one wins and its response reports base+1 (the snapshot is read inside the
// write transaction), every loser gets 409 — no acknowledgement can carry a
// competing writer's committed revision.
func TestConcurrentPageSaveAckReportsOwnWrite(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "pageack@computer.test")
id := alice.request("POST", "/api/pages", map[string]any{"title": "Ack", "content": "v1"}, 201)["id"].(string)
const writers = 8
type outcome struct {
status int
version float64
}
results := make(chan outcome, writers)
var wg sync.WaitGroup
for i := 0; i < writers; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
body, _ := json.Marshal(map[string]any{"content": fmt.Sprintf("writer-%d", n), "version": 1})
rq, _ := http.NewRequest("PATCH", srv.URL+"/api/pages/"+id, bytes.NewReader(body))
rq.Header.Set("Content-Type", "application/json")
rs, e := alice.c.Do(rq)
if e != nil {
results <- outcome{status: -1}
return
}
defer rs.Body.Close()
raw, _ := io.ReadAll(rs.Body)
out := outcome{status: rs.StatusCode}
var v map[string]any
if json.Unmarshal(raw, &v) == nil {
out.version, _ = v["version"].(float64)
}
results <- out
}(i)
}
wg.Wait()
close(results)
wins := 0
for o := range results {
switch o.status {
case 200:
wins++
if o.version != 2 {
t.Fatalf("winning acknowledgement reported version %v, want exactly base+1 = 2", o.version)
}
case 409:
default:
t.Fatalf("concurrent writer got unexpected status %d", o.status)
}
}
if wins != 1 {
t.Fatalf("expected exactly one winning writer, got %d", wins)
}
}
// Review CR-04: bookmark updates enforce optimistic concurrency — a missing
// version is 400, a stale base is 409 with the row untouched, success bumps
// the revision, and tag replacement rides the same contract.
func TestBookmarkVersionContract(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "bmver@computer.test")
created := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://example.org/ver", "title": "v0"}, 201)
id := created["id"].(string)
if created["version"].(float64) != 0 {
t.Fatalf("new bookmark did not start at version 0: %v", created["version"])
}
alice.request("PUT", "/api/bookmarks/"+id, map[string]any{"title": "no version"}, 400)
alice.request("PUT", "/api/bookmarks/"+id, map[string]any{"title": "stale", "version": 5}, 409)
if v := alice.request("GET", "/api/bookmarks/"+id, nil, 200); v["title"] != "v0" || v["version"].(float64) != 0 {
t.Fatalf("rejected writes modified the bookmark: %v", v)
}
saved := alice.request("PUT", "/api/bookmarks/"+id, map[string]any{"title": "v1", "version": 0}, 200)
if saved["version"].(float64) != 1 {
t.Fatalf("successful update did not bump the version: %v", saved["version"])
}
// A second writer holding the same base must not both succeed.
alice.request("PUT", "/api/bookmarks/"+id, map[string]any{"title": "late", "version": 0}, 409)
tag := alice.request("POST", "/api/tags", map[string]any{"name": "versioned"}, 201)["id"].(string)
out := alice.request("PUT", "/api/bookmarks/"+id, map[string]any{"tagIds": []string{tag}, "version": 1}, 200)
if out["version"].(float64) != 2 {
t.Fatalf("tag replacement did not advance the version: %v", out["version"])
}
if tags, ok := out["tags"].([]any); !ok || len(tags) != 1 {
t.Fatalf("tag replacement lost tags: %v", out["tags"])
}
}
// Assertions ride the tokenVersion (review SEC-02): "sign out everywhere"
// must revoke NextAuth-backed Bearer assertions, not just Go cookie sessions.
func TestAssertionRevocationFollowsTokenVersion(t *testing.T) {
srv, d := testServerDB(t)
u := newAPIUser(t, srv, "assert-revoke@computer.test")
uid := u.request("GET", "/api/me", nil, 200)["user"].(map[string]any)["id"].(string)
var ver int
if e := d.QueryRow(`SELECT "tokenVersion" FROM "User" WHERE id=$1`, uid).Scan(&ver); e != nil {
t.Fatal(e)
}
sign := func(v int) string {
now := time.Now()
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, auth.SessionClaims{
UserID: uid, Type: auth.TokenTypeAssertion, Ver: v,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "computer",
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(60 * time.Second)),
},
})
s, e := tok.SignedString([]byte("integration-test-secret"))
if e != nil {
t.Fatal(e)
}
return s
}
bearer := func(token string) int {
rq, _ := http.NewRequest("GET", srv.URL+"/api/me", nil)
rq.Header.Set("Authorization", "Bearer "+token)
rs, e := u.c.Do(rq)
if e != nil {
t.Fatal(e)
}
defer rs.Body.Close()
io.Copy(io.Discard, rs.Body)
return rs.StatusCode
}
if code := bearer(sign(ver)); code != 200 {
t.Fatalf("current-version assertion rejected: %d", code)
}
if code := bearer(sign(0)); code != 401 {
t.Fatalf("version-less assertion accepted: %d", code)
}
u.request("POST", "/api/auth/signout", map[string]any{}, 200)
if code := bearer(sign(ver)); code != 401 {
t.Fatalf("assertion minted before the revocation bump still accepted: %d", code)
}
var bumped int
if e := d.QueryRow(`SELECT "tokenVersion" FROM "User" WHERE id=$1`, uid).Scan(&bumped); e != nil {
t.Fatal(e)
}
if code := bearer(sign(bumped)); code != 200 {
t.Fatalf("assertion with the post-bump version rejected: %d", code)
}
}