MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m16s
API keys reach tasks and lists over plain REST too (GET /task-lists, GET /tasks with
status/due filters, PATCH/DELETE /thoughts/{id}, GET /lists, list items), guarded by a route
table; notes and settings stay closed. Rights hold for every access alike (webhook:task also
allows create_task over MCP, tasks:write also task delivery).
Tasks, thoughts and list items record which key created them and which one changed them last
(migration 13 replaces agent_mark; origin stays after the user's own edits and survives the
triage into a list item). OriginBadge shows the key name with a webhook or robot icon, details
on hover and tap, and replaces the Webhook and KI badges everywhere, now also on list items.
Settings: category "API" with rights grouped by tasks, lists and inbox and two tabs
(curl / smart home with a Home Assistant sensor, AI agents over MCP). Docs, help, README and
changelog updated; v0.30.0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
129 lines
5.2 KiB
Go
129 lines
5.2 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/sandrodach/mindsift/internal/auth"
|
|
"github.com/sandrodach/mindsift/internal/domain"
|
|
)
|
|
|
|
func keyRequest(t *testing.T, router http.Handler, key, method, path, body string) (int, []byte) {
|
|
t.Helper()
|
|
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
|
req.Header.Set("X-API-Key", key)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
return rec.Code, rec.Body.Bytes()
|
|
}
|
|
|
|
// API keys reach tasks and lists over plain REST with the right scope, and nothing else.
|
|
func TestAPIKeyRESTAccess(t *testing.T) {
|
|
srv, store, token := setupTestServer(t)
|
|
router := srv.Router()
|
|
claims, _ := auth.ValidateToken(testJWTSecret, token)
|
|
reader, _ := store.CreateAPIKey(claims.UserID, "Home Assistant", []string{"tasks:read", "lists:read"})
|
|
writer, _ := store.CreateAPIKey(claims.UserID, "Skript", []string{"tasks:write", "lists:write"})
|
|
inbox, _ := store.CreateAPIKey(claims.UserID, "Nur Inbox", nil)
|
|
|
|
task, _ := srv.createTask(claims.UserID, "Müll rausbringen", "", "manual", time.Now().Format("2006-01-02"), "", "", nil)
|
|
list, _ := store.CreateList(claims.UserID, "Einkauf", "", "shopping")
|
|
|
|
if code, body := keyRequest(t, router, reader.RawKey, http.MethodGet, "/api/v1/tasks?due=today&status=open", ""); code != http.StatusOK || !strings.Contains(string(body), "Müll rausbringen") {
|
|
t.Fatalf("reader GET /tasks: %d %s", code, body)
|
|
}
|
|
if code, _ := keyRequest(t, router, reader.RawKey, http.MethodGet, "/api/v1/lists/"+list.ID, ""); code != http.StatusOK {
|
|
t.Fatalf("reader GET list: %d", code)
|
|
}
|
|
// Reading does not allow writing, and notes stay closed
|
|
if code, _ := keyRequest(t, router, reader.RawKey, http.MethodPatch, "/api/v1/thoughts/"+task.ID, `{"isCompleted":true}`); code != http.StatusForbidden {
|
|
t.Fatalf("reader must not change tasks: %d", code)
|
|
}
|
|
if code, _ := keyRequest(t, router, reader.RawKey, http.MethodGet, "/api/v1/documents", ""); code != http.StatusForbidden {
|
|
t.Fatalf("keys must not reach notes: %d", code)
|
|
}
|
|
if code, _ := keyRequest(t, router, inbox.RawKey, http.MethodGet, "/api/v1/tasks", ""); code != http.StatusForbidden {
|
|
t.Fatalf("inbox key must not read tasks: %d", code)
|
|
}
|
|
if code, _ := keyRequest(t, router, reader.RawKey, http.MethodGet, "/api/v1/tasks?due=morgen", ""); code != http.StatusBadRequest {
|
|
t.Fatalf("bad due filter should be 400: %d", code)
|
|
}
|
|
|
|
// Writing over REST records the key as origin
|
|
if code, body := keyRequest(t, router, writer.RawKey, http.MethodPatch, "/api/v1/thoughts/"+task.ID, `{"isCompleted":true}`); code != http.StatusOK {
|
|
t.Fatalf("writer PATCH: %d %s", code, body)
|
|
}
|
|
th, _ := store.GetThoughtByID(claims.UserID, task.ID)
|
|
if !th.IsCompleted || th.Origin == nil || th.Origin.ChangedVia != "api" || th.Origin.ChangedKey != "Skript" {
|
|
t.Fatalf("REST change by key: %+v %+v", th.IsCompleted, th.Origin)
|
|
}
|
|
code, body := keyRequest(t, router, writer.RawKey, http.MethodPost, "/api/v1/lists/"+list.ID+"/items", `{"content":"Milch"}`)
|
|
var item domain.ListItem
|
|
_ = json.Unmarshal(body, &item)
|
|
if code != http.StatusCreated || item.Origin == nil || item.Origin.CreatedKey != "Skript" {
|
|
t.Fatalf("writer adds item: %d %s", code, body)
|
|
}
|
|
|
|
// Webhook-delivered thoughts keep their origin when the triage turns them into a list item
|
|
code, body = keyRequest(t, router, inbox.RawKey, http.MethodPost, "/api/v1/webhooks/ingest", `{"title":"Kaffee kaufen"}`)
|
|
var ingested struct {
|
|
Thought domain.Thought `json:"thought"`
|
|
}
|
|
_ = json.Unmarshal(body, &ingested)
|
|
if code != http.StatusCreated || ingested.Thought.Origin == nil || ingested.Thought.Origin.CreatedKey != "Nur Inbox" {
|
|
t.Fatalf("ingest origin: %d %s", code, body)
|
|
}
|
|
if _, err := store.UpdateThoughtTriage(claims.UserID, ingested.Thought.ID, "keep", "list_item", list.ID, "", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
items, _ := store.GetListItems(list.ID)
|
|
found := false
|
|
for _, it := range items {
|
|
if it.Content == "Kaffee kaufen" {
|
|
found = it.Origin != nil && it.Origin.CreatedVia == "api" && it.Origin.CreatedKey == "Nur Inbox"
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("triaged list item lost its origin: %+v", items)
|
|
}
|
|
}
|
|
|
|
// The day filters count in the server's time zone, whatever the time of day.
|
|
func TestFilterTasks(t *testing.T) {
|
|
now := time.Date(2026, 11, 1, 23, 30, 0, 0, time.Local)
|
|
tasks := []domain.Thought{
|
|
{ID: "a", DueDate: "2026-11-01T09:00"},
|
|
{ID: "b", DueDate: "2026-10-30"},
|
|
{ID: "c", DueDate: "2026-10-29", IsCompleted: true},
|
|
{ID: "d", DueDate: "2026-11-02"},
|
|
{ID: "e"},
|
|
}
|
|
ids := func(ts []domain.Thought) string {
|
|
out := []string{}
|
|
for _, x := range ts {
|
|
out = append(out, x.ID)
|
|
}
|
|
return strings.Join(out, ",")
|
|
}
|
|
for _, tc := range []struct{ status, due, want string }{
|
|
{"", "", "a,b,c,d,e"},
|
|
{"", "today", "a"},
|
|
{"", "overdue", "b"},
|
|
{"open", "2026-11-01", "a,b"},
|
|
{"done", "", "c"},
|
|
} {
|
|
got, err := filterTasks(tasks, tc.status, tc.due, now)
|
|
if err != nil || ids(got) != tc.want {
|
|
t.Errorf("status=%q due=%q: got %s (%v), want %s", tc.status, tc.due, ids(got), err, tc.want)
|
|
}
|
|
}
|
|
if _, err := filterTasks(tasks, "offen", "", now); err == nil {
|
|
t.Error("unknown status should fail")
|
|
}
|
|
}
|