2026-08-17 07:53:53 +02:00
|
|
|
package httpapi
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
2026-08-20 21:26:13 +02:00
|
|
|
"errors"
|
2026-08-20 20:41:33 +02:00
|
|
|
"fmt"
|
2026-08-17 07:53:53 +02:00
|
|
|
"io"
|
|
|
|
|
"net/http"
|
2026-08-20 21:26:13 +02:00
|
|
|
"net/http/httptest"
|
2026-08-17 08:21:51 +02:00
|
|
|
"net/url"
|
2026-08-17 07:53:53 +02:00
|
|
|
"strings"
|
|
|
|
|
"testing"
|
2026-08-20 21:26:13 +02:00
|
|
|
"time"
|
2026-08-17 08:21:51 +02:00
|
|
|
|
|
|
|
|
"github.com/kristianwind/verdande/internal/config"
|
2026-08-20 20:41:33 +02:00
|
|
|
"github.com/kristianwind/verdande/internal/store"
|
2026-08-17 07:53:53 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// --- 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 15:15:28 +02:00
|
|
|
// 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"])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 07:53:53 +02:00
|
|
|
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"])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 14:56:48 +02:00
|
|
|
// 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")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-22 00:03:45 +02:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 07:53:53 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-17 08:21:51 +02:00
|
|
|
|
|
|
|
|
// --- 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")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-17 14:00:39 +02:00
|
|
|
|
|
|
|
|
// --- 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-17 14:32:42 +02:00
|
|
|
|
|
|
|
|
// --- 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")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-17 15:19:35 +02:00
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-18 09:05:18 +02:00
|
|
|
|
|
|
|
|
// 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.
|
2026-08-18 11:56:54 +02:00
|
|
|
if empty["redirect_uri"] != "http://localhost/oauth/gmail/callback" {
|
2026-08-18 09:05:18 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-18 13:21:56 +02:00
|
|
|
|
|
|
|
|
// 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")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-20 20:41:33 +02:00
|
|
|
|
|
|
|
|
// "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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-20 21:26:13 +02:00
|
|
|
|
|
|
|
|
// 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")
|
|
|
|
|
}
|
|
|
|
|
}
|