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>
200 lines
7.3 KiB
Go
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")
|
|
}
|
|
}
|