Files
verdande/internal/httpapi/notes_share_test.go
T
kristianwindandClaude Opus 5 91671a4f97 feat(notes): share a note with a person, not only through a project
Until now a note was shared exactly one way: file it in a shared project, and
everyone who could read the project could read the note. That is right when the
note belongs with a body of work and wrong when it is just "let Sofie see this" —
it made you stand up a project to hand over a single page.

A note can now be shared with named people directly, at viewer or editor — the
same two roles a project membership uses, so there is one idea of a role in the
program and not two. The paths stack: a note can be loose, filed, shared with
people, or any mix, and access is the most any of them allow.

The store:
  - note_shares (migration 0030): note ↔ user ↔ role, one row per person, owner
    recorded. Visibility (AllNotes, and search via mayTouchNote) widens to include
    notes shared directly with the reader.
  - the effective role folds a direct share on top of the owner and project paths.

The rules, drawn where they protect the owner:
  - read = viewer, edit text = editor, on any path.
  - MOVE a note (change its project) is owner-only: an editor filing it into a
    project the owner is not in would take the owner's own note away from them.
  - DELETE is owner or project-editor — a direct share, however high its role,
    does not reach to throwing the note away.
  - SHARE and UNSHARE are the owner's alone, like a project's invitations.
  - the picker is everyone on the instance (a closed, invited set) minus who is
    already on the note; a made-up id and the owner themselves are refused.

The list:
  - a "Delt med mig" group at the top, collapsible, that appears only when it has
    something in it — the same rule Favoritter follows — so it costs nothing when
    you share nothing. A shared note lives there, not also in a time bucket.
  - every shared note carries a small chip in its owner's avatar colour, the
    project mark's shape said for a person.
  - no star or archive button on a note somebody else owns: those change the note
    itself, and it is not yours to put away.
  - the editor grows a "Del med personer" panel for the owner: who can see it, at
    what role, and an add row drawn from the instance's other people.

The old comment in the notes footer argued against a note having an access list
of its own. That was the right call until the case it did not cover — sharing
without a project — turned out to be the one actually asked for. Updated to match.

Person now carries json tags, so a note's owner serialises as id/name/avatar_color
like every other person in the API rather than as Go field names.

Tests: a shared note reaches the other person marked and owned; a viewer reads but
cannot write; an editor writes but cannot re-share or delete; unshare removes
access and is owner-only; the candidate list excludes who is already on and the
owner; and a made-up id and self are refused. OpenAPI covers the three new routes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 07:36:55 +02:00

200 lines
7.3 KiB
Go

package httpapi
import (
"net/http"
"testing"
)
// createNote makes a note as the current client and returns its id.
func (ts *testServer) createNote(t *testing.T, body string) string {
t.Helper()
resp, out := ts.do(t, "POST", "/api/v1/notes", map[string]any{"body": body})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create note: status %d", resp.StatusCode)
}
id, _ := out["id"].(string)
if id == "" {
t.Fatal("create note returned no id")
}
return id
}
func userID(t *testing.T, ts *testServer, email string) string {
t.Helper()
u, err := ts.db.UserByEmail(t.Context(), email)
if err != nil {
t.Fatalf("look up %s: %v", email, err)
}
return u.ID
}
// A note shared with a person shows up in their list, marked as theirs-from-
// somebody, and a viewer can read it but not change it.
func TestSharedNoteReachesTheOtherPerson(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
noteID := owner.createNote(t, "# Ferieplan\nuge 29")
// Before sharing, Sofie cannot see it at all.
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusNotFound {
t.Fatalf("an unshared note was reachable: %d", resp.StatusCode)
}
// Share it as a viewer.
resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "viewer"})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("share: status %d", resp.StatusCode)
}
// Now it is in Sofie's list, flagged as shared with her and carrying its owner.
_, list := other.do(t, "GET", "/api/v1/notes", nil)
notes, _ := list["notes"].([]any)
var found map[string]any
for _, raw := range notes {
n := raw.(map[string]any)
if n["id"] == noteID {
found = n
}
}
if found == nil {
t.Fatal("the shared note is not in the recipient's list")
}
if found["shared_with_me"] != true {
t.Errorf("the note is not marked shared_with_me: %v", found)
}
ownerObj, _ := found["owner"].(map[string]any)
if ownerObj == nil || ownerObj["name"] != "Kristian" {
t.Errorf("the owner is not carried on the shared note: %v", found["owner"])
}
// A viewer reads it.
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusOK {
t.Errorf("a viewer could not read the shared note: %d", resp.StatusCode)
}
// But cannot change it.
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "hijacked"}); resp.StatusCode != http.StatusNotFound {
t.Errorf("a viewer changed a shared note: %d", resp.StatusCode)
}
}
// An editor may change the text, but only the owner may delete the note or hand it
// to somebody else.
func TestSharedEditorCanWriteButNotGiveAway(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
noteID := owner.createNote(t, "# Delt\noprindelig")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusNoContent {
t.Fatalf("share as editor failed")
}
// The editor writes.
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Delt\nrettet"}); resp.StatusCode != http.StatusOK {
t.Errorf("an editor could not change a shared note: %d", resp.StatusCode)
}
// The editor cannot re-share it: giving access is the owner's alone.
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil); resp.StatusCode != http.StatusNotFound {
t.Errorf("an editor could read the share list: %d", resp.StatusCode)
}
// The editor cannot delete it.
if resp, _ := other.do(t, "DELETE", "/api/v1/notes/"+noteID, nil); resp.StatusCode == http.StatusNoContent {
t.Error("an editor deleted the owner's note")
}
// The note still exists for the owner.
if resp, _ := owner.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusOK {
t.Errorf("the note is gone after the editor's delete attempt: %d", resp.StatusCode)
}
}
// Unsharing takes access away again, and only the owner can do it.
func TestUnshareRemovesAccess(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
noteID := owner.createNote(t, "# Hemmelig")
owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "viewer"})
// A non-owner cannot unshare.
if resp, _ := other.do(t, "DELETE", "/api/v1/notes/"+noteID+"/shares/"+sofie, nil); resp.StatusCode != http.StatusNotFound {
t.Errorf("a non-owner unshared a note: %d", resp.StatusCode)
}
// The owner can.
if resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+noteID+"/shares/"+sofie, nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("owner unshare: %d", resp.StatusCode)
}
// And access is gone.
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusNotFound {
t.Errorf("the note was still reachable after unsharing: %d", resp.StatusCode)
}
}
// The share list is owner-only and offers the instance's other people as
// candidates, minus those already on the note.
func TestShareListShowsCandidatesAndIsOwnerOnly(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
owner.newUser(t, "sofie@example.dk", "Sofie")
owner.newUser(t, "anders@example.dk", "Anders")
sofie := userID(t, owner, "sofie@example.dk")
noteID := owner.createNote(t, "# Plan")
owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "viewer"})
_, out := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil)
shares, _ := out["shares"].([]any)
if len(shares) != 1 {
t.Fatalf("shares = %d, want 1", len(shares))
}
cands, _ := out["candidates"].([]any)
// Anders is a candidate; Sofie is already on the note and so is not.
names := map[string]bool{}
for _, c := range cands {
names[c.(map[string]any)["name"].(string)] = true
}
if !names["Anders"] {
t.Errorf("Anders is not offered as a candidate: %v", names)
}
if names["Sofie"] {
t.Error("somebody already on the note is offered as a candidate")
}
if names["Kristian"] {
t.Error("the owner is offered as a candidate to share with themselves")
}
}
// Sharing refuses a made-up person and the owner themselves.
func TestShareRejectsUnknownAndSelf(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
me := userID(t, owner, "kristian@example.dk")
noteID := owner.createNote(t, "# Note")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": "does-not-exist", "role": "viewer"}); resp.StatusCode != http.StatusNotFound {
t.Errorf("sharing with an unknown id: %d, want 404", resp.StatusCode)
}
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": me, "role": "viewer"}); resp.StatusCode != http.StatusUnprocessableEntity {
t.Errorf("sharing with oneself: %d, want 422", resp.StatusCode)
}
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": me, "role": "owner"}); resp.StatusCode == http.StatusNoContent {
t.Error("a note was shared at the owner role")
}
}