Files
verdande/internal/httpapi/integrations_test.go
T

1725 lines
59 KiB
Go
Raw Normal View History

package httpapi
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/kristianwind/verdande/internal/config"
"github.com/kristianwind/verdande/internal/store"
)
// --- MCP -----------------------------------------------------------------------------
// rpc sends one JSON-RPC message to the MCP endpoint.
func (ts *testServer) rpc(t *testing.T, method string, params any) map[string]any {
t.Helper()
body := map[string]any{"jsonrpc": "2.0", "id": 1, "method": method}
if params != nil {
body["params"] = params
}
_, decoded := ts.do(t, "POST", "/api/v1/mcp", body)
return decoded
}
func TestMCPHandshake(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
init := ts.rpc(t, "initialize", map[string]any{
"protocolVersion": "2024-11-05",
"clientInfo": map[string]any{"name": "claude", "version": "1"},
})
result, _ := init["result"].(map[string]any)
if result == nil {
t.Fatalf("initialize returned no result: %v", init)
}
if result["protocolVersion"] == nil {
t.Error("no protocol version was announced")
}
caps, _ := result["capabilities"].(map[string]any)
if _, ok := caps["tools"]; !ok {
t.Errorf("tools were not announced as a capability: %v", caps)
}
listed := ts.rpc(t, "tools/list", nil)
result, _ = listed["result"].(map[string]any)
tools, _ := result["tools"].([]any)
if len(tools) == 0 {
t.Fatal("no tools were listed")
}
// The tools the brief names have to be there, each with a schema a model can
// actually call from.
names := map[string]bool{}
for _, raw := range tools {
tool := raw.(map[string]any)
name := tool["name"].(string)
names[name] = true
if tool["description"] == "" {
t.Errorf("tool %q has no description", name)
}
if _, ok := tool["inputSchema"].(map[string]any); !ok {
t.Errorf("tool %q has no input schema", name)
}
}
for _, want := range []string{
"search_tasks", "create_task", "update_task", "complete_task",
"list_projects", "add_comment",
} {
if !names[want] {
t.Errorf("the %q tool is missing", want)
}
}
}
// A notification has no id, and the spec says to answer with nothing at all.
func TestMCPNotificationGetsNoResponse(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, _ := ts.do(t, "POST", "/api/v1/mcp", map[string]any{
"jsonrpc": "2.0", "method": "notifications/initialized",
})
if resp.StatusCode != http.StatusAccepted {
t.Errorf("a notification got status %d, want 202 with no body", resp.StatusCode)
}
}
func TestMCPToolsDoTheWork(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// create_task, through the natural-language path a model is told to prefer.
created := ts.callTool(t, "create_task", map[string]any{
"text": "betal moms i morgen kl 10 p1",
})
if created["id"] == nil {
t.Fatalf("create_task returned no id: %v", created)
}
if created["priority"] != float64(1) {
t.Errorf("priority = %v, want 1 — the text was not parsed", created["priority"])
}
taskID := created["id"].(string)
// search_tasks, with the filter language.
found := ts.callTool(t, "search_tasks", map[string]any{"query": "p1"})
tasks, _ := found["tasks"].([]any)
if len(tasks) != 1 {
t.Fatalf("search_tasks found %d tasks, want 1", len(tasks))
}
// list_projects
projects := ts.callTool(t, "list_projects", map[string]any{})
if got, _ := projects["projects"].([]any); len(got) == 0 {
t.Error("list_projects returned nothing")
}
// update_task
updated := ts.callTool(t, "update_task", map[string]any{
"task_id": taskID, "priority": 3,
})
if updated["priority"] != float64(3) {
t.Errorf("priority = %v after update, want 3", updated["priority"])
}
// add_comment
comment := ts.callTool(t, "add_comment", map[string]any{
"task_id": taskID, "body": "sagt af Claude",
})
if comment["id"] == nil {
t.Errorf("add_comment returned no id: %v", comment)
}
// complete_task
done := ts.callTool(t, "complete_task", map[string]any{"task_id": taskID})
if done["completed"] != true {
t.Errorf("complete_task did not complete: %v", done)
}
}
// A tool called wrongly must produce a readable tool error, not a protocol error —
// a model can recover from the first and can do nothing with the second.
func TestMCPToolErrorsAreReadable(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
response := ts.rpc(t, "tools/call", map[string]any{
"name": "complete_task",
"arguments": map[string]any{"task_id": "findes-ikke"},
})
if response["error"] != nil {
t.Errorf("a failed tool call produced a JSON-RPC error rather than a tool result: %v", response["error"])
}
result, _ := response["result"].(map[string]any)
if result["isError"] != true {
t.Fatalf("the result is not marked as an error: %v", result)
}
content, _ := result["content"].([]any)
if len(content) == 0 {
t.Fatal("the error has no content for the model to read")
}
text, _ := content[0].(map[string]any)["text"].(string)
if !strings.Contains(text, "findes-ikke") {
t.Errorf("the message does not say what was wrong: %q", text)
}
}
// An MCP connector reaches exactly what its owner reaches, and nothing else.
func TestMCPCannotReachAnotherUsersTasks(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "anden@example.dk", "Anden")
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "min private opgave"})
found := other.callTool(t, "search_tasks", map[string]any{"text": "private"})
if tasks, _ := found["tasks"].([]any); len(tasks) != 0 {
t.Errorf("another user's MCP session found %d of the owner's tasks", len(tasks))
}
}
// callTool runs one tool and returns its decoded result.
func (ts *testServer) callTool(t *testing.T, name string, args map[string]any) map[string]any {
t.Helper()
response := ts.rpc(t, "tools/call", map[string]any{"name": name, "arguments": args})
if response["error"] != nil {
t.Fatalf("%s: protocol error %v", name, response["error"])
}
result, _ := response["result"].(map[string]any)
if result == nil {
t.Fatalf("%s: no result", name)
}
if result["isError"] == true {
content, _ := result["content"].([]any)
text := ""
if len(content) > 0 {
text, _ = content[0].(map[string]any)["text"].(string)
}
t.Fatalf("%s failed: %s", name, text)
}
content, _ := result["content"].([]any)
if len(content) == 0 {
t.Fatalf("%s: no content", name)
}
text, _ := content[0].(map[string]any)["text"].(string)
var decoded map[string]any
if err := json.Unmarshal([]byte(text), &decoded); err != nil {
t.Fatalf("%s: the result is not JSON: %v\n%s", name, err, text)
}
return decoded
}
// --- CalDAV ---------------------------------------------------------------------------
// dav sends a WebDAV request with Basic auth, as a real client would.
func (ts *testServer) dav(t *testing.T, method, path, token, email, body string) *http.Response {
t.Helper()
req, err := http.NewRequest(method, ts.URL+path, strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
req.SetBasicAuth(email, token)
if body != "" {
req.Header.Set("Content-Type", "application/xml")
}
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
t.Cleanup(func() { resp.Body.Close() })
return resp
}
// apiToken mints a personal token, which is what CalDAV clients authenticate with.
func (ts *testServer) apiToken(t *testing.T, userID string) string {
t.Helper()
token, _, err := ts.db.CreateAPIToken(t.Context(), userID, "caldav", nil)
if err != nil {
t.Fatal(err)
}
return token
}
// The promise of sharing has to hold at every door, not just the API's.
//
// TestViewerCannotMutateThroughTheAPI asserts a viewer cannot write, and it
// asserts it against /api/v1. CalDAV is a second door into the same tasks, and it
// authorised differently: it checked the caller's standing in the project *named
// in the URL* and then took the task id from the URL as well. Nothing tied the
// two together.
//
// So a viewer on somebody else's project could name a project of their own —
// their Inbox, which everybody owns and everybody may edit — and put the other
// project's task id in the filename. The project check passed on the Inbox, and
// the write landed on a task in a project they may only read.
func TestAViewerCannotWriteThroughCalDAV(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.newUser(t, "viewer@example.dk", "Viewer")
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
projectID := project["id"].(string)
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "en opgave", "project_id": projectID,
})
taskID := task["id"].(string)
if resp, body := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
"email": "viewer@example.dk", "role": "viewer",
}); resp.StatusCode != http.StatusOK {
t.Fatalf("invite: %d %v", resp.StatusCode, body)
}
them, err := ts.db.UserByEmail(t.Context(), "viewer@example.dk")
if err != nil {
t.Fatal(err)
}
token := ts.apiToken(t, them.ID)
// Their own Inbox: a project they own and may edit, and the one every account
// has. It is what makes the URL check pass.
projects, err := ts.db.ListProjects(t.Context(), them.ID, false)
if err != nil {
t.Fatal(err)
}
var inbox string
for _, p := range projects {
if p.IsInbox {
inbox = p.ID
}
}
if inbox == "" {
t.Fatal("the viewer has no inbox")
}
vtodo := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:" + taskID +
"\r\nSUMMARY:kapret\r\nSTATUS:COMPLETED\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"
resp := ts.dav(t, "PUT", "/caldav/"+them.ID+"/"+inbox+"/"+taskID+".ics",
token, them.Email, vtodo)
if resp.StatusCode < 400 {
t.Errorf("a viewer wrote through CalDAV: status %d", resp.StatusCode)
}
// And the task is as its owner left it. The status alone is not the assertion:
// the question is whether anything moved.
_, after := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
if after["content"] != "en opgave" {
t.Errorf("the task was renamed by a viewer: %v", after["content"])
}
if after["completed"] != false {
t.Error("the task was completed by a viewer")
}
}
// A task may only be addressed under the project it is actually in.
//
// The same hole seen from the other side: an editor on two projects could reach a
// task in one of them through the other's URL. Nothing is escalated by it — they
// may edit both — but it is the check that was missing, and a URL that lies about
// where a resource lives is how the viewer case got in.
func TestCalDAVRefusesATaskFromAnotherProject(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
token := ts.apiToken(t, user.ID)
_, one := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Et"})
_, two := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "To"})
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "hører til Et", "project_id": one["id"].(string),
})
taskID := task["id"].(string)
vtodo := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:" + taskID +
"\r\nSUMMARY:flyttet i smug\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"
resp := ts.dav(t, "PUT", "/caldav/"+user.ID+"/"+two["id"].(string)+"/"+taskID+".ics",
token, user.Email, vtodo)
if resp.StatusCode < 400 {
t.Errorf("a task was written under the wrong project: status %d", resp.StatusCode)
}
_, after := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
if after["content"] != "hører til Et" {
t.Errorf("the task changed: %v", after["content"])
}
}
func TestCalDAVRequiresCredentials(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, err := (&http.Client{}).Get(ts.URL + "/caldav/")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("status %d, want 401", resp.StatusCode)
}
// Without a challenge, a client has nothing to prompt with.
if resp.Header.Get("WWW-Authenticate") == "" {
t.Error("no WWW-Authenticate header was sent")
}
}
// The account password must not work here — a client stores what it is given, and
// what it is given should not be able to sign in to the app.
func TestCalDAVRejectsTheAccountPassword(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp := ts.dav(t, "PROPFIND", "/caldav/", "syv heste over marken", "kristian@example.dk", "")
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("the account password was accepted: status %d", resp.StatusCode)
}
}
func TestCalDAVDiscovery(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
token := ts.apiToken(t, user.ID)
// OPTIONS is what a client reads first. Without calendar-access in the DAV
// header, Apple Reminders decides this is plain WebDAV and stops.
options := ts.dav(t, "OPTIONS", "/caldav/", token, user.Email, "")
if dav := options.Header.Get("DAV"); !strings.Contains(dav, "calendar-access") {
t.Errorf("DAV header = %q, want calendar-access in it", dav)
}
principal := ts.dav(t, "PROPFIND", "/caldav/", token, user.Email, "")
if principal.StatusCode != http.StatusMultiStatus {
t.Fatalf("PROPFIND on the root: status %d, want 207", principal.StatusCode)
}
body, _ := io.ReadAll(principal.Body)
if !strings.Contains(string(body), "calendar-home-set") {
t.Errorf("the principal response does not point at a calendar home:\n%s", body)
}
// The home lists one collection per project.
home := ts.dav(t, "PROPFIND", "/caldav/"+user.ID+"/", token, user.Email, "")
if home.StatusCode != http.StatusMultiStatus {
t.Fatalf("PROPFIND on the home: status %d", home.StatusCode)
}
homeBody, _ := io.ReadAll(home.Body)
if !strings.Contains(string(homeBody), "<cal:comp name=\"VTODO\"/>") {
t.Errorf("the collections do not declare VTODO support:\n%s", homeBody)
}
if !strings.Contains(string(homeBody), "getctag") {
t.Error("no ctag was returned; clients would re-sync everything every time")
}
}
// The half that makes CalDAV two-way: a client creating and completing a task.
func TestCalDAVReadWrite(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
token := ts.apiToken(t, user.ID)
inbox, err := ts.db.InboxID(t.Context(), user.ID)
if err != nil {
t.Fatal(err)
}
// A client PUTs a VTODO it made up an id for.
vtodo := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\n" +
"UID:fra-reminders\r\nSUMMARY:Købt af Apple Reminders\r\n" +
"DUE;VALUE=DATE:20260315\r\nPRIORITY:1\r\nSTATUS:NEEDS-ACTION\r\n" +
"END:VTODO\r\nEND:VCALENDAR\r\n"
put := ts.dav(t, "PUT", "/caldav/"+user.ID+"/"+inbox+"/fra-reminders.ics",
token, user.Email, vtodo)
if put.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(put.Body)
t.Fatalf("PUT: status %d, %s", put.StatusCode, body)
}
// It is a real task now.
task, err := ts.db.GetTask(t.Context(), "fra-reminders", user.ID)
if err != nil {
t.Fatalf("the task was not created: %v", err)
}
if task.Content != "Købt af Apple Reminders" {
t.Errorf("content = %q", task.Content)
}
if task.DueDate != "2026-03-15" {
t.Errorf("due_date = %q", task.DueDate)
}
if task.Priority != 1 {
t.Errorf("priority = %d, want 1 (iCalendar's 1)", task.Priority)
}
// And it reads back as a VTODO.
get := ts.dav(t, "GET", "/caldav/"+user.ID+"/"+inbox+"/fra-reminders.ics", token, user.Email, "")
if get.StatusCode != http.StatusOK {
t.Fatalf("GET: status %d", get.StatusCode)
}
body, _ := io.ReadAll(get.Body)
if !strings.Contains(string(body), "BEGIN:VTODO") {
t.Errorf("the response is not a VTODO:\n%s", body)
}
if !strings.Contains(string(body), "SUMMARY:Købt af Apple Reminders") {
t.Errorf("the summary is missing:\n%s", body)
}
// Ticking it off in the client arrives as a PUT with STATUS:COMPLETED.
completed := strings.Replace(vtodo, "STATUS:NEEDS-ACTION", "STATUS:COMPLETED", 1)
done := ts.dav(t, "PUT", "/caldav/"+user.ID+"/"+inbox+"/fra-reminders.ics",
token, user.Email, completed)
if done.StatusCode != http.StatusNoContent {
t.Fatalf("PUT to complete: status %d", done.StatusCode)
}
after, err := ts.db.GetTask(t.Context(), "fra-reminders", user.ID)
if err != nil {
t.Fatal(err)
}
if !after.Completed() {
t.Error("completing the task in a CalDAV client did not complete it here")
}
// A task completed in the app must stay visible to the client, or the client
// treats it as deleted and drops its own record of having done it.
report := ts.dav(t, "REPORT", "/caldav/"+user.ID+"/"+inbox+"/", token, user.Email,
`<?xml version="1.0"?><c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">`+
`<d:prop><d:getetag/><c:calendar-data/></d:prop></c:calendar-query>`)
if report.StatusCode != http.StatusMultiStatus {
t.Fatalf("REPORT: status %d", report.StatusCode)
}
reportBody, _ := io.ReadAll(report.Body)
if !strings.Contains(string(reportBody), "fra-reminders") {
t.Errorf("a completed task vanished from the collection:\n%s", reportBody)
}
}
// --- mail to task -------------------------------------------------------------------------
func TestMailToTask(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
projectID := project["id"].(string)
resp, body := ts.do(t, "GET", "/api/v1/mail-address", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("mail address: %d %v", resp.StatusCode, body)
}
address, _ := body["address"].(string)
if !strings.HasPrefix(address, "todo+") {
t.Fatalf("address = %q, want todo+<token>@domain", address)
}
// Delivered mail. The subject goes through the quick-add parser.
resp, created := ts.do(t, "POST", "/inbound/mail", map[string]any{
"to": address,
"from": "revisor@example.dk",
"subject": "Send årsregnskab p1 #Firma",
"body": "Vedhæftet er sidste års tal.",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("inbound mail: %d %v", resp.StatusCode, created)
}
_, list := ts.do(t, "GET", "/api/v1/tasks?project_id="+projectID, nil)
tasks, _ := list["tasks"].([]any)
if len(tasks) != 1 {
t.Fatalf("got %d tasks in #Firma, want the one from the mail", len(tasks))
}
task := tasks[0].(map[string]any)
if task["content"] != "Send årsregnskab" {
t.Errorf("content = %v", task["content"])
}
if task["priority"] != float64(1) {
t.Errorf("priority = %v, want 1", task["priority"])
}
if task["description"] != "Vedhæftet er sidste års tal." {
t.Errorf("the body did not become the description: %v", task["description"])
}
}
// An invented address must not create anything, and must not say whether the token
// merely does not exist — that would be an oracle for which addresses are live.
func TestInboundMailToAnUnknownAddress(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, _ := ts.do(t, "POST", "/inbound/mail", map[string]any{
"to": "todo+opfundet@example.dk", "subject": "hej", "body": "",
})
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status %d, want 404", resp.StatusCode)
}
malformed, _ := ts.do(t, "POST", "/inbound/mail", map[string]any{
"to": "ikke-en-adresse", "subject": "hej", "body": "",
})
if malformed.StatusCode != resp.StatusCode {
t.Errorf("a malformed address answers %d and an unknown one %d; they must match",
malformed.StatusCode, resp.StatusCode)
}
}
func TestRotatingTheMailAddress(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, first := ts.do(t, "GET", "/api/v1/mail-address", nil)
old := first["address"].(string)
_, second := ts.do(t, "POST", "/api/v1/mail-address/rotate", nil)
fresh := second["address"].(string)
if old == fresh {
t.Fatal("rotating produced the same address")
}
resp, _ := ts.do(t, "POST", "/inbound/mail", map[string]any{
"to": old, "subject": "til den gamle adresse", "body": "",
})
if resp.StatusCode != http.StatusNotFound {
t.Errorf("the old address still works: status %d", resp.StatusCode)
}
}
// --- AI ---------------------------------------------------------------------------------
// With no provider configured the AI features are off, not broken. That is the
// promise: the app works without them.
func TestAIDegradesWhenNotConfigured(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "GET", "/api/v1/ai/settings", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("ai settings: %d %v", resp.StatusCode, body)
}
if body["has_key"] != false {
t.Errorf("has_key = %v on a fresh instance", body["has_key"])
}
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "flyt"})
resp, _ = ts.do(t, "POST", "/api/v1/ai/tasks/"+task["id"].(string)+"/split", nil)
if resp.StatusCode != http.StatusConflict {
t.Errorf("split with no provider: status %d, want 409", resp.StatusCode)
}
resp, _ = ts.do(t, "POST", "/api/v1/ai/summary", nil)
if resp.StatusCode != http.StatusConflict {
t.Errorf("summary with no provider: status %d, want 409", resp.StatusCode)
}
}
// The API key is stored but never handed back — a settings page that repopulates a
// password field is one that will eventually leak it into a screenshot.
func TestAIKeyIsNeverReturned(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, _ := ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
"provider": "anthropic", "model": "claude-sonnet-5", "api_key": "sk-hemmelig-nøgle",
})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("save settings: status %d", resp.StatusCode)
}
_, body := ts.do(t, "GET", "/api/v1/ai/settings", nil)
if body["has_key"] != true {
t.Error("has_key is false after saving a key")
}
if body["api_key"] != nil && body["api_key"] != "" {
t.Errorf("the key was returned: %v", body["api_key"])
}
if body["model"] != "claude-sonnet-5" {
t.Errorf("model = %v", body["model"])
}
// Saving again without a key must keep the stored one, or changing the model
// would silently disconnect the provider.
ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
"provider": "anthropic", "model": "claude-opus-5",
})
_, after := ts.do(t, "GET", "/api/v1/ai/settings", nil)
if after["has_key"] != true {
t.Error("the stored key was cleared by a save that did not include one")
}
if after["model"] != "claude-opus-5" {
t.Errorf("model = %v after update", after["model"])
}
}
// Connecting something else must not delete the AI provider.
//
// Reported from use as "the AI settings are not saved": they were saved, and then
// taken. `user_settings` had `user_id` as its whole primary key, and the upsert
// renamed `scope` on conflict — so the `ai`, `gmail` and `calendar` scopes shared
// one row and each write deleted the other two. Every write returned 204.
//
// Through the handlers, because that is where it was seen. The shape is asserted
// against the store as well: see TestOnePersonCanHaveSettingsForMoreThanOneThing.
func TestTheAIProviderSurvivesConnectingSomethingElse(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
"provider": "anthropic", "model": "claude-sonnet-5", "api_key": "sk-hemmelig-nøgle",
})
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
// What the calendar and the mailbox handlers do when a handshake starts: write
// their own scope for the same person.
for _, scope := range []string{"calendar", "gmail"} {
if err := ts.db.SetUserSettings(t.Context(), user.ID, scope,
map[string]any{"pkce_state": "s-" + scope}); err != nil {
t.Fatalf("save %s: %v", scope, err)
}
}
_, body := ts.do(t, "GET", "/api/v1/ai/settings", nil)
if body["provider"] != "anthropic" {
t.Errorf("the provider is %v after connecting two other things, want anthropic", body["provider"])
}
if body["model"] != "claude-sonnet-5" {
t.Errorf("the model is %v", body["model"])
}
if body["has_key"] != true {
t.Error("the key was taken with it")
}
}
// Reported from use as "the AI settings are not saved" — the second time, and a
// different cause than the one above.
//
// The provider is called "OpenAI-kompatibel (Ollama, LM Studio, …)" and the
// address field suggests http://localhost:11434/v1. Both of those live on the
// private network, which safedial refuses — so the address the interface proposed
// was the address the server rejected, and the one provider that exists for
// running your own model could not be configured at all.
//
// It looked like a save that did nothing, because the 422 came back on a field
// with nowhere to show it. Two bugs wearing one coat.
func TestAdminMayPointAIAtAModelOnTheLAN(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// The three shapes a self-hosted model actually takes: another machine on the
// LAN, the loopback name, and the loopback address.
for _, addr := range []string{
"http://192.168.1.150:8080/v1",
"http://localhost:11434/v1",
"http://127.0.0.1:1234/v1",
} {
resp, body := ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
"provider": "compatible", "model": "gemma-4-26b-qat", "base_url": addr,
})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("save %s: status %d, body %v", addr, resp.StatusCode, body)
}
}
_, body := ts.do(t, "GET", "/api/v1/ai/settings", nil)
if body["base_url"] != "http://127.0.0.1:1234/v1" {
t.Errorf("base_url = %v after saving, want it to have survived", body["base_url"])
}
if body["provider"] != "compatible" {
t.Errorf("provider = %v", body["provider"])
}
}
// The wall stays up for everybody else.
//
// An admin on a self-hosted instance already owns the host and its network, so a
// private destination grants them nothing they did not have. An invited user is a
// different person, and for them the address field would be a way to find out
// what answers on the host's network.
func TestAnInvitedUserCannotPointAIAtThePrivateNetwork(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "andreas@example.dk", "Andreas")
resp, body := other.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
"provider": "compatible", "model": "llama3.1",
"base_url": "http://192.168.1.150:8080/v1",
})
if resp.StatusCode == http.StatusNoContent {
t.Fatal("an invited user was allowed to point the AI at the private network")
}
fields, _ := body["fields"].(map[string]any)
if fields["base_url"] == nil {
t.Errorf("the refusal did not name the field it was about: %v", body)
}
}
// A scheme that is not http or https is refused for everybody, admin or not.
func TestAISettingsRefuseANonHTTPAddress(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
"provider": "compatible", "model": "llama3.1", "base_url": "file:///etc/passwd",
})
if resp.StatusCode == http.StatusNoContent {
t.Fatal("file:// was accepted as a provider address")
}
fields, _ := body["fields"].(map[string]any)
if fields["base_url"] == nil {
t.Errorf("no error on base_url: %v", body)
}
}
func TestGmailSettings(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "GET", "/api/v1/gmail", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("gmail settings: %d %v", resp.StatusCode, body)
}
if body["connected"] != false {
t.Error("a fresh instance reports Gmail as connected")
}
resp, _ = ts.do(t, "PUT", "/api/v1/gmail", map[string]any{
"trigger": "starred", "label": "Til handling",
})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("save: status %d", resp.StatusCode)
}
_, after := ts.do(t, "GET", "/api/v1/gmail", nil)
if after["trigger"] != "starred" || after["label"] != "Til handling" {
t.Errorf("settings did not persist: %v", after)
}
}
// --- Gmail OAuth ------------------------------------------------------------------------
// Without an OAuth client registered by the operator there is nothing to authorise
// against, and saying so is more useful than a broken redirect to Google.
func TestGmailAuthorizeNeedsAnOAuthClient(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/gmail/authorize", nil)
if resp.StatusCode != http.StatusConflict {
t.Fatalf("status %d, want 409", resp.StatusCode)
}
if msg, _ := body["error"].(string); !strings.Contains(msg, "OAuth") {
t.Errorf("the message does not explain what is missing: %q", msg)
}
}
func TestGmailAuthorizeBuildsAConsentURL(t *testing.T) {
ts := newTestServerWith(t, func(cfg *config.Config) {
cfg.GmailClientID = "client-123"
cfg.GmailClientSecret = "secret"
})
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/gmail/authorize", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("authorize: %d %v", resp.StatusCode, body)
}
raw, _ := body["url"].(string)
parsed, err := url.Parse(raw)
if err != nil {
t.Fatalf("the URL does not parse: %v", err)
}
if parsed.Host != "accounts.google.com" {
t.Errorf("host = %q", parsed.Host)
}
q := parsed.Query()
if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" {
t.Error("the URL carries no PKCE challenge")
}
if q.Get("state") == "" {
t.Error("the URL carries no state")
}
// The verifier is kept server-side against the user, never sent to the browser.
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
settings, err := ts.db.UserSettings(t.Context(), user.ID, "gmail")
if err != nil {
t.Fatal(err)
}
verifier, _ := settings["pkce_verifier"].(string)
if verifier == "" {
t.Fatal("no verifier was stored")
}
if strings.Contains(raw, verifier) {
t.Error("the verifier was put in the URL handed to the browser")
}
if settings["pkce_state"] != q.Get("state") {
t.Error("the stored state does not match the one in the URL")
}
}
// The callback is where an attacker would try to plant somebody else's code. A
// mismatched, missing or expired state has to be refused.
func TestGmailCallbackChecksState(t *testing.T) {
ts := newTestServerWith(t, func(cfg *config.Config) {
cfg.GmailClientID = "client-123"
cfg.GmailClientSecret = "secret"
})
ts.bootstrap(t)
ts.do(t, "POST", "/api/v1/gmail/authorize", nil)
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
cases := []struct {
name, query, wantParam string
}{
{"no state", "?code=abc", "invalid"},
{"no code", "?state=abc", "invalid"},
{"wrong state", "?code=abc&state=forkert", "state"},
{"user declined", "?error=access_denied", "access_denied"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// A redirect, because the person is looking at a browser tab.
client := &http.Client{
Jar: ts.client.Jar,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
req, err := http.NewRequest("GET", ts.URL+"/oauth/gmail/callback"+tc.query, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Sec-Fetch-Site", "same-origin")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Fatalf("status %d, want a redirect", resp.StatusCode)
}
if loc := resp.Header.Get("Location"); !strings.Contains(loc, tc.wantParam) {
t.Errorf("redirected to %q, want it to mention %q", loc, tc.wantParam)
}
})
}
// And nothing was connected by any of that.
settings, err := ts.db.UserSettings(t.Context(), user.ID, "gmail")
if err != nil {
t.Fatal(err)
}
if settings["refresh_token"] != nil {
t.Error("a refresh token was stored by a rejected callback")
}
}
// A connected mailbox has to be pollable without a browser, which is what the
// background job does. With nothing connected it is a no-op rather than an error.
func TestGmailSyncIsANoOpWhenNotConnected(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/gmail/sync", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("sync: %d %v", resp.StatusCode, body)
}
if body["created"] != float64(0) {
t.Errorf("created = %v with nothing connected", body["created"])
}
}
// --- version ------------------------------------------------------------------------------
// The check is off unless the operator asked for it. A self-hosted app that reaches
// out without being told to has broken the deal its operator made.
func TestVersionCheckIsOffByDefault(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "GET", "/api/v1/version", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("version: %d %v", resp.StatusCode, body)
}
if body["disabled"] != true {
t.Errorf("disabled = %v, want true on a fresh instance", body["disabled"])
}
if body["update_available"] == true {
t.Error("an update was reported with checking turned off")
}
if body["current"] == nil {
t.Error("the current version is not reported")
}
}
// Only an administrator can do anything about an out-of-date server, so only an
// administrator is told.
func TestUpdateNoticeIsForAdministratorsOnly(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
member := ts.newUser(t, "medlem@example.dk", "Medlem")
_, body := member.do(t, "GET", "/api/v1/version", nil)
if body["update_available"] == true {
t.Error("an ordinary member was told the server is out of date")
}
if body["latest"] != nil && body["latest"] != "" {
t.Errorf("latest = %v for a non-admin", body["latest"])
}
// They still see which version they are on, which is what a bug report needs.
if body["current"] == nil {
t.Error("the current version is hidden from members")
}
}
// --- MCP with the key in the URL --------------------------------------------------
// Claude's custom-connector dialog takes a URL and nothing else — no bearer
// token — so this is the only shape that can be configured from it.
func TestMCPAcceptsAKeyInTheQuery(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
var userID string
err := ts.db.QueryRowContext(t.Context(), `SELECT id FROM users LIMIT 1`).Scan(&userID)
if err != nil {
t.Fatal(err)
}
key := ts.apiToken(t, userID)
body := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`
resp, err := http.Post(ts.URL+"/mcp?key="+key, "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status %d, want 200", resp.StatusCode)
}
var decoded map[string]any
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
t.Fatalf("the response was not JSON-RPC: %v", err)
}
result, _ := decoded["result"].(map[string]any)
if result == nil || result["protocolVersion"] == nil {
t.Errorf("no protocol version was announced: %v", decoded)
}
}
// The endpoint that broke it: /mcp used not to exist, so it fell through to the
// SPA fallback and answered 200 with the app shell. A connector pointed at it
// reported success and then failed to parse a page of HTML.
func TestMCPWithoutAKeyIsNotTheAppShell(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
for _, url := range []string{ts.URL + "/mcp", ts.URL + "/mcp?key=not-a-verdande-token"} {
resp, err := http.Post(url, "application/json", strings.NewReader(`{"jsonrpc":"2.0","id":1}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("%s: status %d, want 401", url, resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Errorf("%s: content-type %q — a connector cannot read that", url, ct)
}
}
}
// A revoked key stops working, like any other.
func TestMCPKeyStopsWorkingWhenRevoked(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
var userID string
if err := ts.db.QueryRowContext(t.Context(), `SELECT id FROM users LIMIT 1`).Scan(&userID); err != nil {
t.Fatal(err)
}
key := ts.apiToken(t, userID)
var id string
if err := ts.db.QueryRowContext(t.Context(), `SELECT id FROM api_tokens LIMIT 1`).Scan(&id); err != nil {
t.Fatal(err)
}
if err := ts.db.DeleteAPIToken(t.Context(), userID, id); err != nil {
t.Fatal(err)
}
resp, err := http.Post(ts.URL+"/mcp?key="+key, "application/json",
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("a revoked key still works: status %d", resp.StatusCode)
}
}
// The session cookie must NOT work here. This route sits outside the CSRF check,
// so a cookie-authenticated POST would be a cross-site request that acts as
// whoever is signed in — the exact hole the key-only rule exists to close.
func TestMCPWithKeyIgnoresTheSessionCookie(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// ts.client holds the session from bootstrap. No key in the URL.
resp, err := ts.client.Post(ts.URL+"/mcp", "application/json",
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("a signed-in cookie authenticated a POST outside the CSRF check: status %d",
resp.StatusCode)
}
}
// --- mail to task ------------------------------------------------------------------
// The address used to come out as todo+…@localhost on any instance without a
// mail server, because VERDANDE_SMTP_FROM defaults to verdande@localhost and the
// domain was taken from it unconditionally. That address looks real, cannot
// receive anything, and gives no hint which of the two it is.
func TestMailAddressDoesNotClaimToBeLocalhost(t *testing.T) {
ts := newTestServerWith(t, func(c *config.Config) {
c.BaseURL = "https://todo.example.dk"
// Left at the default, as an instance with no mail server has it.
c.SMTP.From = "verdande@localhost"
})
ts.bootstrap(t)
_, body := ts.do(t, "GET", "/api/v1/mail-address", nil)
address, _ := body["address"].(string)
if strings.HasSuffix(address, "@localhost") {
t.Errorf("address = %q — that can never receive mail", address)
}
if !strings.HasSuffix(address, "@todo.example.dk") {
t.Errorf("address = %q, want the public hostname", address)
}
if body["configured"] != false {
t.Error("no SMTP host is set, so configured should be false")
}
}
// A real sender address still wins: the mail server's own domain is the one that
// can actually route this, and the web address may be a tunnel hostname.
func TestMailAddressPrefersTheConfiguredSender(t *testing.T) {
ts := newTestServerWith(t, func(c *config.Config) {
c.BaseURL = "https://tunnel-abc123.example.net"
c.SMTP.Host = "mail.firma.dk"
c.SMTP.From = "verdande@firma.dk"
})
ts.bootstrap(t)
_, body := ts.do(t, "GET", "/api/v1/mail-address", nil)
address, _ := body["address"].(string)
if !strings.HasSuffix(address, "@firma.dk") {
t.Errorf("address = %q, want the sender's domain", address)
}
if body["configured"] != true {
t.Error("an SMTP host is set, so configured should be true")
}
}
// A 200 with the app shell is an answer that says "yes, that exists" to anything
// that asks. For /.well-known/ that is actively harmful: it is a reserved
// namespace, and a client probing it is asking a yes-or-no question.
//
// Claude asks /.well-known/oauth-authorization-server before connecting to an
// MCP server. It read the shell as "there is an authorization server here" and
// then failed trying to register with it, reporting a broken sign-in service on
// a server that has none and needs none.
func TestWellKnownDoesNotAnswerWithTheAppShell(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
for _, path := range []string{
"/.well-known/oauth-authorization-server",
"/.well-known/oauth-protected-resource",
"/.well-known/openid-configuration",
"/.well-known/anything-at-all",
} {
resp, err := ts.client.Get(ts.URL + path)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("%s: status %d, want 404", path, resp.StatusCode)
}
if strings.Contains(string(body), "<!doctype html") {
t.Errorf("%s: answered with the app shell", path)
}
}
}
// The one well-known path that is real still works — it is a route, not a
// fallthrough, so the rule above must not have swallowed it.
func TestWellKnownCalDAVStillRedirects(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
client := &http.Client{
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
resp, err := client.Get(ts.URL + "/.well-known/caldav")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
t.Error("CalDAV discovery was caught by the well-known rule")
}
}
2026-08-17 16:22:36 +02:00
// --- what the API accepts, it must actually do -------------------------------------
// project_id on an update used to be validated and then dropped: the destination
// was permission-checked, which reads as "understood", and the task never moved
// while the answer said OK. Both the REST path and the MCP tool did it.
func TestUpdateMovesTheTaskToAnotherProject(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
projectID := project["id"].(string)
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "betal moms"})
taskID := task["id"].(string)
if task["project_id"] == projectID {
t.Fatal("the task started in the destination; the test proves nothing")
}
resp, updated := ts.do(t, "PATCH", "/api/v1/tasks/"+taskID,
map[string]any{"project_id": projectID})
if resp.StatusCode != http.StatusOK {
t.Fatalf("patch: status %d, body %v", resp.StatusCode, updated)
}
if updated["project_id"] != projectID {
t.Errorf("project_id = %v, want %v — the move was accepted and not made",
updated["project_id"], projectID)
}
// And it really is there, not merely reported as there.
_, reread := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
if reread["project_id"] != projectID {
t.Errorf("after re-reading, project_id = %v", reread["project_id"])
}
}
func TestMCPUpdateMovesTheTaskToAnotherProject(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
projectID := project["id"].(string)
created := ts.callTool(t, "create_task", map[string]any{"text": "betal moms"})
taskID := created["id"].(string)
moved := ts.callTool(t, "update_task", map[string]any{
"task_id": taskID, "project_id": projectID,
})
if moved["project_id"] != projectID {
t.Errorf("project_id = %v, want %v", moved["project_id"], projectID)
}
}
// A project you cannot write to is refused rather than quietly ignored.
func TestUpdateRefusesAProjectYouCannotWriteTo(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "anden@example.dk", "Anden")
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Deres"})
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "min opgave"})
taskID := task["id"].(string)
mine := task["project_id"]
resp, _ := ts.do(t, "PATCH", "/api/v1/tasks/"+taskID,
map[string]any{"project_id": theirs["id"]})
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status %d, want 404", resp.StatusCode)
}
_, reread := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
if reread["project_id"] != mine {
t.Error("the task moved into somebody else's project")
}
}
// An unknown #project is still not an error — the thought is kept rather than
// thrown away over a typo — but it is no longer silent. It used to vanish from
// the title while the task went to the Inbox, with nothing saying why.
func TestQuickAddSaysWhenTheProjectDoesNotExist(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add",
map[string]any{"text": "Lav plakat #3Dekoration"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status %d, want 201 — the task should still be created", resp.StatusCode)
}
if body["unknown_project"] != "3Dekoration" {
t.Errorf("unknown_project = %v, want %q", body["unknown_project"], "3Dekoration")
}
if body["content"] != "Lav plakat" {
t.Errorf("content = %v", body["content"])
}
}
func TestQuickAddSaysNothingWhenTheProjectExists(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Dekoration"})
_, body := ts.do(t, "POST", "/api/v1/tasks/quick-add",
map[string]any{"text": "Lav plakat #Dekoration"})
if _, present := body["unknown_project"]; present {
t.Errorf("reported an unknown project for one that exists: %v", body)
}
}
func TestMCPCreateSaysWhenTheProjectDoesNotExist(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
created := ts.callTool(t, "create_task", map[string]any{"text": "Lav plakat #3Dekoration"})
note, _ := created["note"].(string)
if !strings.Contains(note, "3Dekoration") {
t.Errorf("note = %q, want it to name the project that does not exist", note)
}
}
// Sub-tasks were unreachable over MCP: create_task had no parent_id, so a parent
// with seven children arrived as eight unrelated tasks.
func TestMCPCreateMakesSubtasks(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
parent := ts.callTool(t, "create_task", map[string]any{
"content": "Runen v2", "project_id": project["id"],
})
parentID := parent["id"].(string)
child := ts.callTool(t, "create_task", map[string]any{
"content": "Tegn ikonet", "parent_id": parentID,
})
if child["parent_id"] != parentID {
t.Errorf("parent_id = %v, want %v", child["parent_id"], parentID)
}
// A sub-task joins its parent's project whatever else was asked for.
if child["project_id"] != project["id"] {
t.Errorf("project_id = %v, want the parent's %v", child["project_id"], project["id"])
}
_, listed := ts.do(t, "GET", "/api/v1/tasks?parent_id="+parentID, nil)
if tasks, _ := listed["tasks"].([]any); len(tasks) != 1 {
t.Errorf("the parent has %d sub-tasks, want 1", len(tasks))
}
}
func TestMCPCreateRefusesASecondLevelOfSubtask(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
parent := ts.callTool(t, "create_task", map[string]any{"content": "Runen v2"})
child := ts.callTool(t, "create_task", map[string]any{
"content": "Tegn ikonet", "parent_id": parent["id"],
})
// A tool that refuses reports it inside the result with isError, not as a
// JSON-RPC error — the call itself was well formed, the arguments were not.
response := ts.rpc(t, "tools/call", map[string]any{
"name": "create_task",
"arguments": map[string]any{"content": "Endnu dybere", "parent_id": child["id"]},
})
result, _ := response["result"].(map[string]any)
if result == nil || result["isError"] != true {
t.Errorf("a sub-task of a sub-task was accepted: %v", response)
}
}
// The Gmail OAuth client, enterable rather than deployed.
//
// The registration itself cannot be avoided — Google issues no Gmail access to an
// unregistered client — but having to edit a Rune's manifest and recreate the
// container to paste the result can be, and that is what this covers.
func TestTheGmailClientCanBeSetFromTheInterface(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, empty := ts.do(t, "GET", "/api/v1/gmail/client", nil)
if empty["client_id"] != "" || empty["has_secret"] != false {
t.Errorf("a fresh instance already has a client: %v", empty)
}
if empty["from_env"] != false {
t.Errorf("from_env = %v with nothing in the environment", empty["from_env"])
}
// The single most likely thing to be wrong, so it is spelled out rather than
// left for somebody to reconstruct.
if empty["redirect_uri"] != "http://localhost/oauth/gmail/callback" {
t.Errorf("redirect_uri = %v", empty["redirect_uri"])
}
resp, _ := ts.do(t, "PUT", "/api/v1/gmail/client", map[string]any{
"client_id": "1234.apps.googleusercontent.com", "client_secret": "hemmelig",
})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("save: status %d", resp.StatusCode)
}
_, saved := ts.do(t, "GET", "/api/v1/gmail/client", nil)
if saved["client_id"] != "1234.apps.googleusercontent.com" {
t.Errorf("client_id = %v", saved["client_id"])
}
if saved["has_secret"] != true {
t.Error("has_secret is false after saving one")
}
// Never read back. A settings page that repopulates a password field is one
// that will eventually leak into a screenshot.
if _, present := saved["client_secret"]; present {
t.Error("the secret came back in the response")
}
// Saving only the id must not clear the secret — otherwise correcting a typo
// silently breaks the connection.
ts.do(t, "PUT", "/api/v1/gmail/client", map[string]any{
"client_id": "5678.apps.googleusercontent.com",
})
_, again := ts.do(t, "GET", "/api/v1/gmail/client", nil)
if again["client_id"] != "5678.apps.googleusercontent.com" || again["has_secret"] != true {
t.Errorf("saving the id alone lost the secret: %v", again)
}
// And the authorise call now has something to build a URL with, which is the
// whole point: one click, and Google's consent screen.
resp, body := ts.do(t, "POST", "/api/v1/gmail/authorize", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("authorize with a stored client: status %d, body %v", resp.StatusCode, body)
}
if url, _ := body["url"].(string); !strings.Contains(url, "5678.apps.googleusercontent.com") {
t.Errorf("the consent URL does not carry the stored client: %v", body["url"])
}
}
// The environment wins, and says so. A value in the Rune's manifest is applied on
// every recreate, so a form that could quietly override it would make the manifest
// a lie.
func TestTheEnvironmentsGmailClientCannotBeOverwritten(t *testing.T) {
ts := newTestServerWith(t, func(c *config.Config) {
c.GmailClientID = "fra-miljøet.apps.googleusercontent.com"
c.GmailClientSecret = "også-fra-miljøet"
})
ts.bootstrap(t)
_, shown := ts.do(t, "GET", "/api/v1/gmail/client", nil)
if shown["from_env"] != true {
t.Errorf("from_env = %v with the environment set", shown["from_env"])
}
if shown["client_id"] != "fra-miljøet.apps.googleusercontent.com" {
t.Errorf("client_id = %v", shown["client_id"])
}
resp, _ := ts.do(t, "PUT", "/api/v1/gmail/client", map[string]any{
"client_id": "min-egen", "client_secret": "min-egen",
})
if resp.StatusCode != http.StatusConflict {
t.Fatalf("overwriting the environment: status %d, want 409", resp.StatusCode)
}
_, after := ts.do(t, "GET", "/api/v1/gmail/client", nil)
if after["client_id"] != "fra-miljøet.apps.googleusercontent.com" {
t.Errorf("the environment's client was replaced: %v", after["client_id"])
}
}
// Administrators only, and sessions only: this is the instance's identity to
// Google, and a leaked token must not be able to read or replace it.
func TestTheGmailClientIsAdminsOnlyAndSessionsOnly(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "anden@example.dk", "Anden")
for _, c := range []struct {
method string
body any
}{{"GET", nil}, {"PUT", map[string]any{"client_id": "x"}}} {
if resp, _ := other.do(t, c.method, "/api/v1/gmail/client", c.body); resp.StatusCode != http.StatusForbidden {
t.Errorf("%s as an ordinary user: status %d, want 403", c.method, resp.StatusCode)
}
}
admin, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
token := ts.apiToken(t, admin.ID)
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/gmail/client", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("with an admin's API token: status %d, want 403", resp.StatusCode)
}
}
// Rotating the mail-to-task address, which reported "something went wrong" in
// production with nothing in the error log — the combination that means the 500 did
// not come through `s.internal()`.
func TestTheMailAddressCanBeRotated(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, before := ts.do(t, "GET", "/api/v1/mail-address", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("get: status %d, body %v", resp.StatusCode, before)
}
first, _ := before["address"].(string)
if first == "" {
t.Fatal("no address to begin with")
}
resp, after := ts.do(t, "POST", "/api/v1/mail-address/rotate", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("rotate: status %d, body %v", resp.StatusCode, after)
}
second, _ := after["address"].(string)
if second == "" || second == first {
t.Errorf("the address did not change: %q → %q", first, second)
}
// And twice in a row, which is what somebody does when the first one appeared
// to fail.
resp, again := ts.do(t, "POST", "/api/v1/mail-address/rotate", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("second rotate: status %d, body %v", resp.StatusCode, again)
}
if third, _ := again["address"].(string); third == second {
t.Error("the second rotation returned the same address")
}
}
// "Der kommer ingen notifikationer" må kunne besvares.
//
// Alt om det var usynligt: indstillingssiden sagde "til for denne enhed", så snart
// der fandtes et abonnement, og en push, tjenesten afviste, gik i en Debug-linje,
// som ikke skrives på en instans, der kører på standardniveau. Man kunne ikke
// skelne "browseren tilmeldte sig aldrig" fra "tjenesten afviste den" fra "der er
// ikke noget forfaldent endnu" — tre forskellige problemer med ét udseende.
func TestATestNotificationSaysWhatHappenedToIt(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// Ingen enheder: svaret skal sige nul frem for at lade som om det gik godt.
resp, body := ts.do(t, "POST", "/api/v1/push/test", map[string]any{
"title": "Prøve", "body": "kom den frem?",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("status %d, body %v", resp.StatusCode, body)
}
if body["subscriptions"] != float64(0) {
t.Errorf("subscriptions = %v uden tilmeldte enheder", body["subscriptions"])
}
// En enhed, der peger på en tjeneste, som ikke findes. Den skal komme tilbage
// som et navngivent afslag og ikke som tavshed.
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
if err := ts.db.SavePushSubscription(t.Context(), &store.PushSubscription{
UserID: user.ID,
Endpoint: "https://push.example.invalid/abc",
P256dh: "BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U",
Auth: "aUZmY2xDS2FQUXJJVmJIeA",
}); err != nil {
t.Fatal(err)
}
_, body = ts.do(t, "POST", "/api/v1/push/test", map[string]any{"title": "Prøve"})
if body["subscriptions"] != float64(1) {
t.Fatalf("subscriptions = %v", body["subscriptions"])
}
failed, _ := body["failed"].([]any)
if len(failed) != 1 {
t.Fatalf("failed har %d poster, want 1 — afslaget blev slugt", len(failed))
}
first, _ := failed[0].(map[string]any)
if first["service"] != "push.example.invalid" {
t.Errorf("service = %v", first["service"])
}
if first["reason"] == "" || first["reason"] == nil {
t.Error("afslaget kom uden en grund")
}
// Og aldrig hele adressen: resten af den er den legitimation, tjenesten gav
// enheden, og den hører hverken i et svar, en log eller et skærmbillede.
if strings.Contains(fmt.Sprint(first), "/abc") {
t.Errorf("endepunktet lækkede med ud: %v", first)
}
}
// Et abonnement på en kalender, man kun har en adresse til.
//
// Bedt om som "abonnering af flere kalendere (ics og webcal?)". Google-vejen er
// OAuth og én konto pr. person; det her er en adresse, og man har flere. Det er
// også den eneste vej for en privat konto, som en Internal-registrering afviser
// med `org_internal`.
//
// Prøven går uden om handleren og ind i det, den kalder. Grunden er væggen:
// `httptest` lytter på loopback, og safedial nægter — både når adressen skrives
// ned og igen når den hentes. Det er rigtigt, og det skal ikke kunne slås fra fra
// en prøve. Så adressereglerne prøves for sig i TestWebcalIsRewrittenToHTTPS, og
// her prøves resten af vejen: hentning, format, navn, gitteret og afmeldingen.
func TestSubscribingToACalendarByAddress(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
day := time.Now().AddDate(0, 0, 2).Format("20060102")
feedBody := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nX-WR-CALNAME:Helligdage\r\n" +
"BEGIN:VEVENT\r\nUID:en@example.dk\r\n" +
"DTSTART;VALUE=DATE:" + day + "\r\nDTEND;VALUE=DATE:" + day + "\r\n" +
"SUMMARY:Grundlovsdag\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
var hits int
feed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
w.Header().Set("Content-Type", "text/calendar")
w.Write([]byte(feedBody))
}))
defer feed.Close()
ts.api.icsFetch = &http.Client{Timeout: 10 * time.Second}
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
// Tilføjet under sin adresse, som er alt der er at gå efter, før den er hentet.
if _, err := ts.db.SubscribeCalendar(t.Context(), user.ID, feed.URL, feed.URL); err != nil {
t.Fatal(err)
}
if _, err := ts.api.syncSubscriptions(t.Context(), user); err != nil {
t.Fatal(err)
}
if hits == 0 {
t.Fatal("adressen blev aldrig hentet")
}
// Navnet kommer fra filen. X-WR-CALNAME er det, alle udgivere bruger; der
// findes ingen standardegenskab for det.
_, status := ts.do(t, "GET", "/api/v1/calendar", nil)
subs, _ := status["subscriptions"].([]any)
if len(subs) != 1 {
t.Fatalf("%d abonnementer i svaret", len(subs))
}
first, _ := subs[0].(map[string]any)
if first["account"] != "Helligdage" {
t.Errorf("navnet er %v, want Helligdage", first["account"])
}
// Og begivenheden står i gitteret.
from := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
to := time.Now().AddDate(0, 0, 7).Format("2006-01-02")
_, events := ts.do(t, "GET", "/api/v1/calendar/events?from="+from+"&to="+to, nil)
list, _ := events["events"].([]any)
if len(list) != 1 {
t.Fatalf("%d begivenheder efter abonnementet", len(list))
}
if e, _ := list[0].(map[string]any); e["summary"] != "Grundlovsdag" {
t.Errorf("begivenheden er %v", e["summary"])
}
// Den samme adresse to gange er den samme kalender hentet to gange, og der er
// ingen måde at sige hvilken der gælder.
if _, err := ts.db.SubscribeCalendar(t.Context(), user.ID, feed.URL, ""); !errors.Is(err, store.ErrDuplicate) {
t.Errorf("den samme adresse blev accepteret to gange: %v", err)
}
// Fjernet igen, og begivenhederne går med.
resp, _ := ts.do(t, "DELETE", "/api/v1/calendar/subscriptions/"+first["id"].(string), nil)
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("unsubscribe: status %d", resp.StatusCode)
}
_, events = ts.do(t, "GET", "/api/v1/calendar/events?from="+from+"&to="+to, nil)
if list, _ := events["events"].([]any); len(list) != 0 {
t.Errorf("%d begivenheder blev tilbage efter afmeldingen", len(list))
}
}
// webcal:// er https med et andet skema foran. Det er ikke en protokol, noget
// taler — det er en konvention, der får et skrivebord til at give adressen til
// kalenderprogrammet i stedet for til browseren, og alle udgivere skriver deres
// links sådan. At afvise den ville være at afvise folk på en teknikalitet.
func TestWebcalIsRewrittenToHTTPS(t *testing.T) {
got, err := normaliseCalendarURL("webcal://example.dk/kalender.ics")
if err != nil {
t.Fatal(err)
}
if got != "https://example.dk/kalender.ics" {
t.Errorf("blev til %q", got)
}
// Og en adresse indad afvises, hvor den skrives — ikke først når den hentes.
if _, err := normaliseCalendarURL("http://127.0.0.1:8080/x.ics"); err == nil {
t.Error("en loopback-adresse blev godtaget")
}
if _, err := normaliseCalendarURL("file:///etc/passwd"); err == nil {
t.Error("et skema, der ikke er http, blev godtaget")
}
}