feat(api): one API for curl and MCP, REST reads with keys, origin badge with key name
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m16s
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>
This commit is contained in:
1 parent
0ca715ba64
commit
b883315914
36 files changed
+1119
-563
No files matched your search
@@ -0,0 +1,128 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user