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), "") { 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, ``+ ``) 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+@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), "