mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 14:57:15 +00:00
ff9b9f3579
* fix(account): every account export read runs inside the export deadline, and an attribution failure aborts it (BUG-3659) The deadline covered only the collection and item reads. The workspace listing and the per-workspace access checks ran before it and the app attribution read after them, so a read held by a lock (on Postgres, an ACCESS EXCLUSIVE lock on app_installs) kept the request open with nothing bounding it. All three now run through withinExportDeadline: before the body a timeout answers 503 unavailable, after it the export aborts (the BUG-3626 door). The attribution read used to log its error and ship the items without it in a file that read as complete; that now aborts too. Seams for the three reads; TestBUG3659_EveryExportReadIsBoundedByTheDeadline stalls each, TestBUG3659_AttributionErrorAbortsTheExport injects a failure. All four red with the wrapping and the error propagation undone. Passing ctx into the queries themselves is BUG-3632, not this change. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(account): each owned workspace is exported from one snapshot that re-checks access (BUG-3659, codex r1 Mediums) Two findings on the first commit. Access was checked once before the body and the workspace read later, so access restricted in between still exported what it now hides; and collections and items were separate pool reads, so an item created between them appeared without its collection. Each owned workspace is now read by accountExportWorkspace from one read-only snapshot (the BUG-3654 BeginReadSnapshot): the access re-check, collections, items and app attribution all on that transaction, inside the deadline. A restriction found there aborts the export (the body has started). The preflight still answers 403 before any byte. Store: ListCollectionsQ, ListItemsQ (the plain listing; search, field filters and NonTerminal read through pool helpers, so it refuses them), ItemsCreatedViaAppQ, and BeginReadSnapshot exported. Server: visibleCollectionIDsQ. Tests: restriction applied after the preflight; a collection and item created between the collection and item reads. Both red with the pool reads and no re-check restored; both pass on SQLite and Postgres. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(account): the snapshot test fails if the export lacks its workspace (BUG-3659, codex r2 Low) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test: reclassify the census sites the BUG-3659 refactor moved, and pin the app ceiling on the export snapshot (BUG-3659, CI) Two call-site censuses failed on #2160. PLAN-3535: nonTerminalFilter now runs in listItemsQ (ListItems delegates; ListItemsQ refuses NonTerminal). TASK-3401: the store visibility call moved from visibleCollectionIDs into visibleCollectionIDsQ, which carries the app-ceiling branch verbatim and is reached by visibleCollectionIDs and by the account export's snapshot re-check. TestBUG3659_SnapshotAccessCheckAppliesTheAppCeiling pins it: with an app context whose ceiling hides the workspace's collection, the pool path and the snapshot path both read the caller as restricted and the snapshot refuses the workspace; red with the ceiling branch disabled. The full internal/models, internal/store and internal/server packages pass. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
849 lines
33 KiB
Go
849 lines
33 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"github.com/PerpetualSoftware/pad/internal/attachments"
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"image"
|
|
"image/png"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// TASK-3401 (SPEC-6 U6a): the app API's door, ceiling and read routes.
|
|
|
|
const task3401Schema = `{"fields":[
|
|
{"key":"status","label":"Status","type":"select","options":["open","done"],"default":"open"},
|
|
{"key":"size","label":"Size","type":"text"},
|
|
{"key":"owner","label":"Owner","type":"relation","collection":"private"}
|
|
]}`
|
|
|
|
type appAPIFix struct {
|
|
srv *Server
|
|
in testInstall
|
|
token string
|
|
ws *models.Workspace
|
|
companion, system, private *models.Collection
|
|
item, privateItem *models.Item
|
|
comment *models.Comment
|
|
// U6c: the blob store the app store writes through, and one attachment
|
|
// bound to item (its bytes are attachmentBytes).
|
|
blobs *attachments.FSStore
|
|
attachment string
|
|
attachmentBytes []byte
|
|
}
|
|
|
|
func appAPIFixture(t *testing.T, access string) appAPIFix {
|
|
t.Helper()
|
|
return appAPIFixtureOn(t, access, true)
|
|
}
|
|
|
|
// appAPIFixtureOn builds the fixture on Pad Cloud, or on a self-host with
|
|
// apps enabled (cloud=false).
|
|
func appAPIFixtureOn(t *testing.T, access string, cloud bool) appAPIFix {
|
|
t.Helper()
|
|
srv := appOAuthServer(t, cloud)
|
|
if !cloud {
|
|
if err := srv.store.SetPlatformSetting(settingAppsEnabled, "true"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
srv.store.SetEncryptionKey(bytes32ForTest())
|
|
in := newTestInstall(t, srv, "inst-api")
|
|
ws, err := srv.store.GetWorkspaceByID(in.wsID)
|
|
if err != nil || ws == nil {
|
|
t.Fatal(err)
|
|
}
|
|
f := appAPIFix{srv: srv, in: in, ws: ws}
|
|
mk := func(name, slug, schema string) *models.Collection {
|
|
c, err := srv.store.CreateCollection(ws.ID, models.CollectionCreate{Name: name, Slug: slug, Schema: schema})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return c
|
|
}
|
|
f.companion = mk("Requests", "requests", task3401Schema)
|
|
f.system = mk("Conventions App", "conventions-app", `{"fields":[]}`)
|
|
f.private = mk("Private", "private", `{"fields":[]}`)
|
|
exec := func(q string, args ...any) {
|
|
t.Helper()
|
|
if _, err := srv.store.DB().Exec(q, args...); err != nil {
|
|
t.Fatalf("%s: %v", q, err)
|
|
}
|
|
}
|
|
exec(`UPDATE collections SET via_app = ? WHERE id = ?`, in.id, f.companion.ID)
|
|
exec(`UPDATE collections SET is_system = 1 WHERE id = ?`, f.system.ID)
|
|
exec(`UPDATE app_installs SET bot_user_id = ?, service_access = ? WHERE id = ?`, in.bot.ID, access, in.id)
|
|
// The bot is a member with "all" access: the ceiling must not rely on
|
|
// U8b narrowing it.
|
|
exec(`INSERT INTO workspace_members (workspace_id, user_id, role, collection_access, created_at) VALUES (?, ?, 'editor', 'all', ?)`,
|
|
ws.ID, in.bot.ID, time.Now().UTC().Format(time.RFC3339))
|
|
|
|
human := createTestUserDirect(t, srv, "adjunct-3401@example.com")
|
|
// Every live workspace has an owner; the item-limit check reads it.
|
|
exec(`UPDATE workspaces SET owner_id = ? WHERE id = ?`, human.ID, ws.ID)
|
|
f.privateItem, err = srv.store.CreateItem(ws.ID, f.private.ID, models.ItemCreate{Title: "Secret", ActorUserID: human.ID})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// An item laden with every adjunct a DTO must never carry: a relation
|
|
// value, a parent, a lease, a reaction on its comment.
|
|
f.item, err = srv.store.CreateItem(ws.ID, f.companion.ID, models.ItemCreate{Title: "Login broken", Content: "It fails.",
|
|
Fields: `{"status":"open","size":"L","owner":"` + f.privateItem.ID + `","ghost":"undeclared"}`, ActorUserID: human.ID})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
exec(`UPDATE items SET via_app = ?, created_via_app = ? WHERE id = ?`, in.id, in.id, f.item.ID)
|
|
if _, err := srv.store.CreateItemLink(ws.ID, models.ItemLinkCreate{TargetID: f.privateItem.ID, LinkType: "parent"}, f.item.ID); err != nil {
|
|
t.Fatalf("parent link: %v", err)
|
|
}
|
|
if _, err := srv.store.ClaimItemLease(f.item.ID, "someone", "", human.ID, time.Hour); err != nil {
|
|
t.Fatalf("lease: %v", err)
|
|
}
|
|
f.comment, err = srv.store.CreateComment(ws.ID, f.item.ID, human.ID, models.CommentCreate{Body: "seen", Author: "Ada", CreatedBy: "user"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := srv.store.AddReaction(f.comment.ID, human.ID, "user", "👍"); err != nil {
|
|
t.Fatalf("reaction: %v", err)
|
|
}
|
|
// Attachment storage, wired before any app request resolves the app
|
|
// store (which reads it once).
|
|
blobs, err := attachments.NewFSStore(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reg := attachments.NewRegistry()
|
|
reg.Register(attachments.FSPrefix, blobs)
|
|
srv.SetAttachments(reg, 0)
|
|
f.blobs = blobs
|
|
f.attachmentBytes = testPNG(t)
|
|
f.attachment = seedAttachment(t, f, f.item.ID, f.attachmentBytes, "image/png", "screenshot.png")
|
|
f.token = mintServiceToken(t, srv, in)
|
|
return f
|
|
}
|
|
|
|
// testPNG is a small valid PNG.
|
|
func testPNG(t *testing.T) []byte {
|
|
t.Helper()
|
|
img := image.NewRGBA(image.Rect(0, 0, 3, 2))
|
|
var buf bytes.Buffer
|
|
if err := png.Encode(&buf, img); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// seedAttachment stores a blob and its row bound to itemID, as an earlier
|
|
// upload would have, and returns the row's id.
|
|
func seedAttachment(t *testing.T, f appAPIFix, itemID string, body []byte, mime, name string) string {
|
|
t.Helper()
|
|
sum := sha256.Sum256(body)
|
|
hash := hex.EncodeToString(sum[:])
|
|
key, err := f.blobs.Put(context.Background(), hash, mime, bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
id := "att-" + hash[:12] + "-" + itemID[:8]
|
|
if _, err := f.srv.store.DB().Exec(`INSERT INTO attachments (id, workspace_id, item_id, uploaded_by, storage_key, content_hash,
|
|
mime_type, size_bytes, filename, created_at, filename_source, imported, via_app)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'caller', 0, ?)`,
|
|
id, f.ws.ID, itemID, f.in.bot.ID, key, hash, mime, len(body), name, time.Now().UTC().Format(time.RFC3339), f.in.id); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func createTestUserDirect(t *testing.T, srv *Server, email string) *models.User {
|
|
t.Helper()
|
|
u, err := srv.store.CreateUser(models.UserCreate{Email: email, Name: "Ada", Password: "correct-horse-battery-staple"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return u
|
|
}
|
|
|
|
func appGet(srv *Server, path, token string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest("GET", path, nil)
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
req.RemoteAddr = "192.0.2.1:1234"
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
return rr
|
|
}
|
|
|
|
func (f appAPIFix) path(p string) string { return appAPIPrefix + "/workspaces/" + f.ws.ID + p }
|
|
|
|
// Every row of the route table is reachable with a valid token, and every
|
|
// response's key set is exactly its DTO's, nested keys included (the census).
|
|
func TestTask3401_RoutesAndDTOCensus(t *testing.T) {
|
|
f := appAPIFixture(t, "read")
|
|
paths := map[string]string{
|
|
"appListCollections": "/collections",
|
|
"appGetCollection": "/collections/requests",
|
|
"appListItems": "/collections/requests/items",
|
|
"appGetItem": "/items/" + f.item.ID,
|
|
"appListComments": "/items/" + f.item.ID + "/comments",
|
|
"appMe": "/me",
|
|
"appGetAttachment": "/attachments/" + f.attachment,
|
|
}
|
|
reads := 0
|
|
for _, rt := range appRoutes {
|
|
if rt.Access == "read" {
|
|
reads++
|
|
}
|
|
}
|
|
if len(paths) != reads {
|
|
t.Fatalf("the read census covers %d routes, the table has %d read rows", len(paths), reads)
|
|
}
|
|
for _, rt := range appRoutes {
|
|
if rt.Access != "read" {
|
|
continue // the write rows: TestTask3401b_WriteCensus
|
|
}
|
|
p, ok := paths[rt.Name]
|
|
if !ok {
|
|
t.Errorf("route %s has no census path", rt.Name)
|
|
continue
|
|
}
|
|
rr := appGet(f.srv, f.path(p), f.token)
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("%s: %d %s", rt.Name, rr.Code, rr.Body.String())
|
|
continue
|
|
}
|
|
if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
|
t.Errorf("%s: Content-Type %q", rt.Name, ct)
|
|
}
|
|
var body any
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("%s: %v", rt.Name, err)
|
|
}
|
|
checkAppDTOKeys(t, rt.Name, body)
|
|
}
|
|
}
|
|
|
|
// The key sets of each DTO, nested ones included. A field added to a DTO
|
|
// without a spec amendment fails here.
|
|
var appDTOKeys = map[string][]string{
|
|
"collection": {"icon", "is_system", "name", "schema", "slug"},
|
|
"schema": {"fields"},
|
|
"schema_field": {"key", "label", "options", "required", "type", "default"}, // options/default only when set
|
|
"item": {"collection", "content", "created_at", "created_by_display", "etag", "fields", "id", "title", "updated_at", "via_app"},
|
|
"comment": {"author_display", "author_kind", "body", "created_at", "deleted", "edited", "id", "item_id", "parent_comment_id", "updated_at"},
|
|
"me": {"display_name", "is_app", "role", "user_id"},
|
|
"attachment": {"created_at", "filename", "id", "item_id", "mime_type", "size", "variant"}, // variant null on an original
|
|
}
|
|
|
|
func appKeysOf(m map[string]any) []string {
|
|
out := make([]string, 0, len(m))
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func assertKeysWithin(t *testing.T, where string, got map[string]any, dto string, required []string) {
|
|
t.Helper()
|
|
allowed := map[string]bool{}
|
|
for _, k := range appDTOKeys[dto] {
|
|
allowed[k] = true
|
|
}
|
|
for _, k := range appKeysOf(got) {
|
|
if !allowed[k] {
|
|
t.Errorf("%s: key %q is not in the %s DTO", where, k, dto)
|
|
}
|
|
}
|
|
for _, k := range required {
|
|
if _, ok := got[k]; !ok {
|
|
t.Errorf("%s: key %q missing from the %s DTO", where, k, dto)
|
|
}
|
|
}
|
|
}
|
|
|
|
func checkCollection(t *testing.T, where string, c map[string]any) {
|
|
assertKeysWithin(t, where, c, "collection", appDTOKeys["collection"])
|
|
schema, _ := c["schema"].(map[string]any)
|
|
assertKeysWithin(t, where+".schema", schema, "schema", appDTOKeys["schema"])
|
|
fields, _ := schema["fields"].([]any)
|
|
for _, fd := range fields {
|
|
m := fd.(map[string]any)
|
|
assertKeysWithin(t, where+".schema.fields[]", m, "schema_field", []string{"key", "label", "required", "type"})
|
|
if m["type"] == "relation" || m["type"] == "multi_relation" {
|
|
t.Errorf("%s: a relation field is in the schema projection", where)
|
|
}
|
|
}
|
|
}
|
|
|
|
func checkItem(t *testing.T, where string, it map[string]any) {
|
|
assertKeysWithin(t, where, it, "item", appDTOKeys["item"])
|
|
fields, _ := it["fields"].(map[string]any)
|
|
for k := range fields {
|
|
if k != "status" && k != "size" {
|
|
t.Errorf("%s.fields carries %q: only declared non-relation keys may appear", where, k)
|
|
}
|
|
}
|
|
}
|
|
|
|
func checkAppDTOKeys(t *testing.T, route string, body any) {
|
|
t.Helper()
|
|
m, _ := body.(map[string]any)
|
|
switch route {
|
|
case "appListCollections":
|
|
if strings.Join(appKeysOf(m), ",") != "collections" {
|
|
t.Errorf("%s: top-level keys %v", route, appKeysOf(m))
|
|
}
|
|
for _, c := range m["collections"].([]any) {
|
|
checkCollection(t, route+".collections[]", c.(map[string]any))
|
|
}
|
|
case "appGetCollection":
|
|
checkCollection(t, route, m)
|
|
case "appListItems":
|
|
if strings.Join(appKeysOf(m), ",") != "has_more,items,next_offset" {
|
|
t.Errorf("%s: top-level keys %v", route, appKeysOf(m))
|
|
}
|
|
for _, it := range m["items"].([]any) {
|
|
checkItem(t, route+".items[]", it.(map[string]any))
|
|
}
|
|
case "appGetItem":
|
|
checkItem(t, route, m)
|
|
case "appGetAttachment", "appUploadAttachment":
|
|
assertKeysWithin(t, route, m, "attachment", appDTOKeys["attachment"])
|
|
case "appListComments":
|
|
for _, c := range m["comments"].([]any) {
|
|
assertKeysWithin(t, route+".comments[]", c.(map[string]any), "comment", appDTOKeys["comment"])
|
|
}
|
|
case "appMe":
|
|
assertKeysWithin(t, route, m, "me", appDTOKeys["me"])
|
|
case "appRedeemContext":
|
|
if strings.Join(appKeysOf(m), ",") != "action_key,item" {
|
|
t.Errorf("%s: top-level keys %v", route, appKeysOf(m))
|
|
}
|
|
checkItem(t, route+".item", m["item"].(map[string]any))
|
|
}
|
|
}
|
|
|
|
// The request DTO shapes are reflected, so a Go field added without a JSON
|
|
// key, or with an unlisted one, fails here too.
|
|
func TestTask3401_ResponseDTOFieldSets(t *testing.T) {
|
|
jsonKeys := func(v any) []string {
|
|
var out []string
|
|
tp := reflect.TypeOf(v)
|
|
for i := 0; i < tp.NumField(); i++ {
|
|
name := strings.Split(tp.Field(i).Tag.Get("json"), ",")[0]
|
|
out = append(out, name)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
check := func(name string, v any, want []string) {
|
|
w := append([]string(nil), want...)
|
|
sort.Strings(w)
|
|
if got := jsonKeys(v); strings.Join(got, ",") != strings.Join(w, ",") {
|
|
t.Errorf("%s fields = %v, want %v", name, got, w)
|
|
}
|
|
}
|
|
check("AppCollection", AppCollection{}, appDTOKeys["collection"])
|
|
check("AppSchemaField", AppSchemaField{}, appDTOKeys["schema_field"])
|
|
check("AppItem", AppItem{}, appDTOKeys["item"])
|
|
check("AppComment", AppComment{}, appDTOKeys["comment"])
|
|
check("AppMe", AppMe{}, appDTOKeys["me"])
|
|
}
|
|
|
|
// The ceiling: a bot with "all" membership reads only its companions and the
|
|
// system collections, wherever it asks.
|
|
func TestTask3401_TheCeiling(t *testing.T) {
|
|
f := appAPIFixture(t, "read")
|
|
rr := appGet(f.srv, f.path("/collections"), f.token)
|
|
var list struct {
|
|
Collections []AppCollection `json:"collections"`
|
|
}
|
|
_ = json.Unmarshal(rr.Body.Bytes(), &list)
|
|
var slugs []string
|
|
for _, c := range list.Collections {
|
|
slugs = append(slugs, c.Slug)
|
|
}
|
|
sort.Strings(slugs)
|
|
if strings.Join(slugs, ",") != "conventions-app,requests" {
|
|
t.Errorf("collections = %v, want the companion and the system collection only", slugs)
|
|
}
|
|
for _, p := range []string{"/collections/private", "/collections/private/items", "/items/" + f.privateItem.ID, "/items/" + f.privateItem.ID + "/comments"} {
|
|
if rr := appGet(f.srv, f.path(p), f.token); rr.Code != http.StatusNotFound {
|
|
t.Errorf("%s outside the ceiling: %d %s, want 404", p, rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
if rr := appGet(f.srv, f.path("/collections/requests/items"), f.token); !strings.Contains(rr.Body.String(), f.item.ID) {
|
|
t.Errorf("the companion item is not listed: %s", rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// The door: every refusal before a handler runs.
|
|
func TestTask3401_TheDoorRefuses(t *testing.T) {
|
|
f := appAPIFixture(t, "read")
|
|
me := f.path("/me")
|
|
if rr := appGet(f.srv, me, ""); rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("no token: %d", rr.Code)
|
|
}
|
|
if rr := appGet(f.srv, me, "garbage"); rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("garbage token: %d", rr.Code)
|
|
}
|
|
// An MCP token and an MCP refresh token are not app tokens.
|
|
sess := newOAuthSession(t, f.srv)
|
|
mcpTok, code := mintWithResource(t, f.srv, sess, testCanonicalAudience)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("mcp mint: %d", code)
|
|
}
|
|
for name, tok := range map[string]string{"an MCP token": mcpTok, "a refresh token": lastRefresh} {
|
|
if rr := appGet(f.srv, me, tok); rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("%s: %d, want 401", name, rr.Code)
|
|
}
|
|
}
|
|
// A slug in {ws}, or another workspace's id: the workspace 404.
|
|
if rr := appGet(f.srv, appAPIPrefix+"/workspaces/"+f.ws.Slug+"/me", f.token); rr.Code != http.StatusNotFound {
|
|
t.Errorf("{ws} as a slug: %d, want 404", rr.Code)
|
|
}
|
|
if rr := appGet(f.srv, appAPIPrefix+"/workspaces/00000000-0000-0000-0000-000000000000/me", f.token); rr.Code != http.StatusNotFound {
|
|
t.Errorf("another {ws}: %d, want 404", rr.Code)
|
|
}
|
|
// An app token never reaches the human API.
|
|
if rr := appGet(f.srv, "/api/v1/workspaces", f.token); rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("an app token on /api/v1: %d, want 401", rr.Code)
|
|
}
|
|
// No service access, a delegated binding, a stale epoch: refused.
|
|
exec := func(q string, args ...any) {
|
|
if _, err := f.srv.store.DB().Exec(q, args...); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
exec(`UPDATE app_installs SET service_access = NULL WHERE id = ?`, f.in.id)
|
|
if rr := appGet(f.srv, me, f.token); rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("no service access: %d, want 401", rr.Code)
|
|
}
|
|
exec(`UPDATE app_installs SET service_access = 'read' WHERE id = ?`, f.in.id)
|
|
exec(`UPDATE app_token_bindings SET auth_kind = 'delegated' WHERE install_id = ?`, f.in.id)
|
|
if rr := appGet(f.srv, me, f.token); rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("a delegated binding before TASK-3399: %d, want 401", rr.Code)
|
|
}
|
|
exec(`UPDATE app_token_bindings SET auth_kind = 'service' WHERE install_id = ?`, f.in.id)
|
|
if rr := appGet(f.srv, me, f.token); rr.Code != http.StatusOK {
|
|
t.Fatalf("control: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
exec(`UPDATE app_installs SET auth_epoch = auth_epoch + 1 WHERE id = ?`, f.in.id)
|
|
if rr := appGet(f.srv, me, f.token); rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("a stale epoch: %d, want 401", rr.Code)
|
|
}
|
|
}
|
|
|
|
// A write row refuses a read token before anything is resolved: the access
|
|
// step runs first, on every write row (U6b adds them).
|
|
func TestTask3401_AWriteRowRefusesAReadToken(t *testing.T) {
|
|
f := appAPIFixture(t, "read")
|
|
reached := false
|
|
h := f.srv.requireAppAccess("write", http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true }))
|
|
req := httptest.NewRequest("POST", "/x", nil)
|
|
req = req.WithContext(withAppContextForTest(req, &appContext{Access: "read"}))
|
|
rr := httptest.NewRecorder()
|
|
h.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusForbidden || reached {
|
|
t.Errorf("read token on a write row: %d reached=%v, want 403 and no handler", rr.Code, reached)
|
|
}
|
|
rr = httptest.NewRecorder()
|
|
req = req.WithContext(withAppContextForTest(req, &appContext{Access: "write"}))
|
|
h.ServeHTTP(rr, req)
|
|
if !reached {
|
|
t.Error("control: a write token did not reach the handler")
|
|
}
|
|
}
|
|
|
|
// The self-check refuses a request missing any key the helpers read.
|
|
func TestTask3401_ContextSelfCheck(t *testing.T) {
|
|
f := appAPIFixture(t, "read")
|
|
var seen *http.Request
|
|
h := f.srv.requireAppWorkspace(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { seen = r }))
|
|
grant := &AppTokenGrant{InstallID: f.in.id, WorkspaceID: f.ws.ID, AuthKind: "service", Subject: f.in.bot.ID}
|
|
req := httptest.NewRequest("GET", f.path("/me"), nil)
|
|
rctx := newChiCtx(map[string]string{"ws": f.ws.ID})
|
|
req = req.WithContext(withChiCtx(req, rctx))
|
|
req = req.WithContext(withAppContextForTest(req, &appContext{Grant: grant, InstallID: f.in.id, WorkspaceID: f.ws.ID, Access: "read", AuthKind: "service", Actor: f.in.bot}))
|
|
req = req.WithContext(WithCurrentUser(req.Context(), f.in.bot))
|
|
rr := httptest.NewRecorder()
|
|
h.ServeHTTP(rr, req)
|
|
if seen == nil {
|
|
t.Fatalf("a complete request was refused: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
if err := appContextSelfCheck(seen); err != nil {
|
|
t.Fatalf("a complete context fails the self-check: %v", err)
|
|
}
|
|
// Each key, removed, fails it.
|
|
ac := appContextFrom(seen)
|
|
broken := *ac
|
|
broken.ReadCeiling = nil
|
|
if appContextSelfCheck(seen.WithContext(withAppContextForTest(seen, &broken))) == nil {
|
|
t.Error("a missing ceiling passed the self-check")
|
|
}
|
|
if appContextSelfCheck(seen.WithContext(WithTokenAllowedWorkspaces(seen.Context(), nil))) == nil {
|
|
t.Error("a missing allow-list passed the self-check")
|
|
}
|
|
if appContextSelfCheck(seen.WithContext(WithCurrentUser(seen.Context(), &models.User{ID: "someone-else"}))) == nil {
|
|
t.Error("a current user other than the actor passed the self-check")
|
|
}
|
|
}
|
|
|
|
// Cross-workspace helpers refuse outright under app context. Each leg has a
|
|
// control without the app context that IS allowed, so the refusal is the
|
|
// app context's, not some other denial.
|
|
func TestTask3401_CrossWorkspaceHelpersRefuseApps(t *testing.T) {
|
|
mf := newMovedToFixture(t)
|
|
mf.record(mf.destB, true, 1)
|
|
mf.archiveSource()
|
|
base := func() *http.Request {
|
|
req := httptest.NewRequest("GET", "/x", nil)
|
|
ctx := WithCurrentUser(req.Context(), mf.owner)
|
|
ctx = context.WithValue(ctx, ctxResolvedWorkspaceID, mf.wsA.ID)
|
|
ctx = context.WithValue(ctx, ctxWorkspaceRole, "owner")
|
|
return req.WithContext(ctx)
|
|
}
|
|
asApp := func(r *http.Request) *http.Request {
|
|
return r.WithContext(withAppContextForTest(r, &appContext{InstallID: "inst-x"}))
|
|
}
|
|
if a := mf.srv.AuthorizeCrossWorkspaceRead(base(), mf.wsB.Slug, CrossWorkspaceWorkspaceOnlyScope()); !a.Allowed {
|
|
t.Fatalf("control: the owner's cross-workspace read was refused: %v", a.Reason)
|
|
}
|
|
if a := mf.srv.AuthorizeCrossWorkspaceRead(asApp(base()), mf.wsB.Slug, CrossWorkspaceWorkspaceOnlyScope()); a.Allowed {
|
|
t.Error("a cross-workspace read was allowed under app context")
|
|
}
|
|
now := time.Now()
|
|
source := &models.Item{ID: mf.source.ID, WorkspaceID: mf.wsA.ID, CollectionID: mf.collA.ID, DeletedAt: &now}
|
|
if got := mf.srv.movedToDestinations(base(), source); len(got) == 0 {
|
|
t.Fatal("control: the moved item names no destination")
|
|
}
|
|
if got := mf.srv.movedToDestinations(asApp(base()), source); got != nil {
|
|
t.Errorf("moved_to under app context: %v", got)
|
|
}
|
|
}
|
|
|
|
// A read token acts as a viewer whatever the bot's membership; a write token
|
|
// keeps its membership's role.
|
|
func TestTask3401_TheRoleFollowsTheAccess(t *testing.T) {
|
|
for access, want := range map[string]string{"read": "viewer", "write": "editor"} {
|
|
f := appAPIFixture(t, access)
|
|
var me AppMe
|
|
rr := appGet(f.srv, f.path("/me"), f.token)
|
|
_ = json.Unmarshal(rr.Body.Bytes(), &me)
|
|
if me.Role != want {
|
|
t.Errorf("%s token: role %q, want %q", access, me.Role, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A comment whose parent is on ANOTHER item projects parent_comment_id null.
|
|
func TestTask3401_ACrossItemParentProjectsToNull(t *testing.T) {
|
|
f := appAPIFixture(t, "read")
|
|
human := createTestUserDirect(t, f.srv, "elsewhere-3401@example.com")
|
|
elsewhere, err := f.srv.store.CreateComment(f.ws.ID, f.privateItem.ID, human.ID, models.CommentCreate{Body: "other", Author: "B", CreatedBy: "user"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := f.srv.store.DB().Exec(`UPDATE comments SET parent_id = ? WHERE id = ?`, elsewhere.ID, f.comment.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reply, err := f.srv.store.CreateComment(f.ws.ID, f.item.ID, human.ID, models.CommentCreate{Body: "reply", Author: "C", CreatedBy: "user", ParentID: f.comment.ID})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var out struct {
|
|
Comments []AppComment `json:"comments"`
|
|
}
|
|
_ = json.Unmarshal(appGet(f.srv, f.path("/items/"+f.item.ID+"/comments"), f.token).Body.Bytes(), &out)
|
|
got := map[string]*string{}
|
|
for _, c := range out.Comments {
|
|
got[c.ID] = c.ParentCommentID
|
|
}
|
|
if p, ok := got[f.comment.ID]; !ok || p != nil {
|
|
t.Errorf("a cross-item parent projected as %v, want null", p)
|
|
}
|
|
if p := got[reply.ID]; p == nil || *p != f.comment.ID {
|
|
t.Errorf("control: a same-item parent projected as %v", p)
|
|
}
|
|
}
|
|
|
|
// Lead ruling R2: every caller of the store's visibility, listed with how
|
|
// the ceiling reaches it. A new caller fails until it is reviewed.
|
|
func TestTask3401_EveryVisibilityCallerIsCeilinged(t *testing.T) {
|
|
reviewed := map[string]string{
|
|
"server.go::visibleCollectionIDsQ": "applies the request's app ceiling; the store ceilings a bot. Reached by visibleCollectionIDs and by the account export's snapshot re-check (BUG-3659, which pins the ceiling there)",
|
|
"server.go::checkItemVisibleQ": "reached from app routes only through requireItemVisible, which checks the app ceiling first; the store ceilings a bot",
|
|
"handlers_me.go::handleGetMe": "not an app route (TokenAuth refuses an app token); the store ceilings a bot",
|
|
"handlers_reports.go::reportVisibleCollections": "not an app route; the store ceilings a bot",
|
|
"handlers_collab.go::authorizeCollabAccess": "not an app route; the store ceilings a bot",
|
|
"handlers_events.go::computeSSEVisibility": "not an app route; the store ceilings a bot",
|
|
}
|
|
fset := token.NewFileSet()
|
|
files, _ := filepath.Glob("*.go")
|
|
found := map[string]bool{}
|
|
for _, file := range files {
|
|
if strings.HasSuffix(file, "_test.go") {
|
|
continue
|
|
}
|
|
src, err := os.ReadFile(file)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
af, err := parser.ParseFile(fset, file, src, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, d := range af.Decls {
|
|
fn, ok := d.(*ast.FuncDecl)
|
|
if !ok || fn.Body == nil {
|
|
continue
|
|
}
|
|
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
|
call, ok := n.(*ast.CallExpr)
|
|
if !ok {
|
|
return true
|
|
}
|
|
sel, ok := call.Fun.(*ast.SelectorExpr)
|
|
if !ok {
|
|
return true
|
|
}
|
|
switch sel.Sel.Name {
|
|
case "VisibleCollectionIDs", "VisibleCollectionIDsQ", "GuestVisibleCollectionIDs":
|
|
found[file+"::"+fn.Name.Name] = true
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
}
|
|
for site := range found {
|
|
if _, ok := reviewed[site]; !ok {
|
|
t.Errorf("%s calls the store's visibility and is not reviewed for the app ceiling", site)
|
|
}
|
|
}
|
|
for site := range reviewed {
|
|
if !found[site] {
|
|
t.Errorf("%s is reviewed but no longer calls the store's visibility; remove it", site)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The etag key is server-only and mandatory: no encryption key, no app store.
|
|
func TestTask3401_ETagKeyIsMandatory(t *testing.T) {
|
|
srv := testServer(t)
|
|
if _, err := srv.appStore(); err == nil {
|
|
t.Error("an app store was built with no encryption key")
|
|
}
|
|
f := appAPIFixture(t, "read")
|
|
a := appGet(f.srv, f.path("/items/"+f.item.ID), f.token)
|
|
b := appGet(f.srv, f.path("/items/"+f.item.ID), f.token)
|
|
var ia, ib AppItem
|
|
_ = json.Unmarshal(a.Body.Bytes(), &ia)
|
|
_ = json.Unmarshal(b.Body.Bytes(), &ib)
|
|
if ia.ETag == "" || ia.ETag != ib.ETag {
|
|
t.Errorf("etag unstable or empty: %q %q", ia.ETag, ib.ETag)
|
|
}
|
|
}
|
|
|
|
var _ = url.Values{}
|
|
|
|
func withAppContextForTest(r *http.Request, ac *appContext) context.Context {
|
|
return context.WithValue(r.Context(), appCtxKey{}, ac)
|
|
}
|
|
|
|
func newChiCtx(params map[string]string) *chi.Context {
|
|
rctx := chi.NewRouteContext()
|
|
for k, v := range params {
|
|
rctx.URLParams.Add(k, v)
|
|
}
|
|
return rctx
|
|
}
|
|
|
|
func withChiCtx(r *http.Request, rctx *chi.Context) context.Context {
|
|
return context.WithValue(r.Context(), chi.RouteCtxKey, rctx)
|
|
}
|
|
|
|
// The request-level ceiling, which a delegated token (TASK-3399) will rely
|
|
// on: the actor is a PERSON with every collection visible, and the app
|
|
// context alone narrows it, in visibleCollectionIDs and in item visibility.
|
|
func TestTask3401_TheRequestCeilingBindsAPersonActor(t *testing.T) {
|
|
f := appAPIFixture(t, "read")
|
|
person := createTestUserDirect(t, f.srv, "delegate-3401@example.com")
|
|
if err := f.srv.store.AddWorkspaceMember(f.ws.ID, person.ID, "editor"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req := httptest.NewRequest("GET", "/x", nil)
|
|
ctx := withAppContextForTest(req, &appContext{InstallID: f.in.id, ReadCeiling: []string{f.companion.ID, f.system.ID}})
|
|
ctx = WithCurrentUser(ctx, person)
|
|
ctx = WithAPITokenAuth(ctx)
|
|
req = req.WithContext(context.WithValue(ctx, ctxWorkspaceRole, "editor"))
|
|
ids, err := f.srv.visibleCollectionIDs(req, f.ws.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sort.Strings(ids)
|
|
want := []string{f.companion.ID, f.system.ID}
|
|
sort.Strings(want)
|
|
if strings.Join(ids, ",") != strings.Join(want, ",") {
|
|
t.Errorf("visible under a request ceiling = %v, want %v", ids, want)
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
if f.srv.requireItemVisible(rr, req, f.ws.ID, f.privateItem) {
|
|
t.Error("an item outside the request ceiling was visible to a person actor")
|
|
}
|
|
if !f.srv.requireItemVisible(httptest.NewRecorder(), req, f.ws.ID, f.item) {
|
|
t.Error("control: the companion item is not visible")
|
|
}
|
|
}
|
|
|
|
// codex r1 P1: a revocation that commits while a read is in flight withholds
|
|
// its data. The response is re-validated against the install (epoch, state,
|
|
// service access, client) before it is sent.
|
|
func TestTask3401_ARevocationDuringAReadWithholdsItsData(t *testing.T) {
|
|
t.Parallel()
|
|
// Each change is keyed by the install id; the ones that act on other
|
|
// rows find them through it (codex r2: the token, the membership, the
|
|
// resources themselves).
|
|
cases := map[string]string{
|
|
"rotate (epoch bump)": `UPDATE app_installs SET auth_epoch = auth_epoch + 1 WHERE id = ?`,
|
|
"disable": `UPDATE app_installs SET state = 'disabling' WHERE id = ?`,
|
|
"service access removed": `UPDATE app_installs SET service_access = NULL WHERE id = ?`,
|
|
"access lowered": `UPDATE app_installs SET service_access = 'read' WHERE id = ?`,
|
|
"token family revoked": `UPDATE oauth_access_tokens SET active = 0 WHERE request_id IN (SELECT request_id FROM app_token_bindings WHERE install_id = ?)`,
|
|
"collection access emptied": `UPDATE workspace_members SET collection_access = 'specific'
|
|
WHERE user_id = (SELECT bot_user_id FROM app_installs WHERE id = ?)`,
|
|
"bot role lowered": `UPDATE workspace_members SET role = 'viewer'
|
|
WHERE user_id = (SELECT bot_user_id FROM app_installs WHERE id = ?)`,
|
|
"companion collection deleted": `UPDATE collections SET deleted_at = '2026-01-01T00:00:00Z' WHERE via_app = ?`,
|
|
"companion released": `UPDATE collections SET via_app = NULL WHERE via_app = ?`,
|
|
"workspace deleted": `UPDATE workspaces SET deleted_at = '2026-01-01T00:00:00Z'
|
|
WHERE id = (SELECT workspace_id FROM app_installs WHERE id = ?)`,
|
|
"item deleted": `UPDATE items SET deleted_at = '2026-01-01T00:00:00Z' WHERE created_via_app = ?`,
|
|
}
|
|
for name, change := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
f := appAPIFixture(t, "write")
|
|
f.srv.appAfterHandler = func() {
|
|
if _, err := f.srv.store.DB().Exec(change, f.in.id); err != nil {
|
|
t.Error(err)
|
|
}
|
|
}
|
|
rr := appGet(f.srv, f.path("/items/"+f.item.ID), f.token)
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("%s mid-read: %d, want 401", name, rr.Code)
|
|
}
|
|
if strings.Contains(rr.Body.String(), "Login broken") || strings.Contains(rr.Body.String(), f.item.ID) {
|
|
t.Errorf("%s mid-read: the item leaked: %s", name, rr.Body.String())
|
|
}
|
|
})
|
|
}
|
|
// Control: nothing changes mid-read.
|
|
f := appAPIFixture(t, "write")
|
|
f.srv.appAfterHandler = func() {}
|
|
if rr := appGet(f.srv, f.path("/items/"+f.item.ID), f.token); rr.Code != http.StatusOK {
|
|
t.Errorf("control: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// The seam is nil in production.
|
|
func TestTask3401_AppAfterHandlerSeamIsNilInProduction(t *testing.T) {
|
|
if New(nil).appAfterHandler != nil {
|
|
t.Fatal("appAfterHandler is set in a new Server")
|
|
}
|
|
}
|
|
|
|
// codex r3 P2: on a self-host, an admin turning apps off while a read is in
|
|
// flight withholds its data, as the gate refuses any new request.
|
|
func TestTask3401_AppsTurnedOffMidReadWithholdsTheData(t *testing.T) {
|
|
f := appAPIFixtureOn(t, "read", false)
|
|
if rr := appGet(f.srv, f.path("/items/"+f.item.ID), f.token); rr.Code != http.StatusOK {
|
|
t.Fatalf("control: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
f.srv.appAfterHandler = func() {
|
|
if err := f.srv.store.SetPlatformSetting(settingAppsEnabled, "false"); err != nil {
|
|
t.Error(err)
|
|
}
|
|
}
|
|
rr := appGet(f.srv, f.path("/items/"+f.item.ID), f.token)
|
|
if rr.Code == http.StatusOK || strings.Contains(rr.Body.String(), "Login broken") {
|
|
t.Errorf("apps off mid-read: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// codex r3: a request with no re-check holder could not have registered its
|
|
// authorizations, so re-validation refuses it rather than passing with none.
|
|
func TestTask3401_RevalidateFailsClosedWithoutARecheckHolder(t *testing.T) {
|
|
f := appAPIFixture(t, "read")
|
|
ac, err := f.srv.appAdmitToken(context.Background(), f.token)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
full, err := f.srv.appAdmitWorkspace(ac)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req := httptest.NewRequest("GET", "/x", nil)
|
|
req = req.WithContext(appRequestContext(req.Context(), full))
|
|
if err := f.srv.appRevalidate(req); err == nil {
|
|
t.Error("re-validation passed a request with no re-check holder")
|
|
}
|
|
withHolder := req.WithContext(context.WithValue(req.Context(), appRecheckKey{}, &appRechecks{}))
|
|
if err := f.srv.appRevalidate(withHolder); err != nil {
|
|
t.Errorf("control: a request with a holder failed re-validation: %v", err)
|
|
}
|
|
}
|
|
|
|
// Rook's review: item visibility filters AFTER the LIMIT, so a page can come
|
|
// back short. Paging must not depend on the page's length: next_offset
|
|
// advances by the rows the window covered, and has_more is decided from the
|
|
// raw window, so a short page never ends an app's paging early.
|
|
func TestTask3401_ItemListPagingIsByWindowNotByCount(t *testing.T) {
|
|
f := appAPIFixture(t, "read")
|
|
human := createTestUserDirect(t, f.srv, "pager-3401@example.com")
|
|
for i := 0; i < 2; i++ {
|
|
if _, err := f.srv.store.CreateItem(f.ws.ID, f.companion.ID, models.ItemCreate{Title: "More " + string(rune('A'+i)), ActorUserID: human.ID}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
type page struct {
|
|
Items []AppItem `json:"items"`
|
|
HasMore bool `json:"has_more"`
|
|
NextOffset int `json:"next_offset"`
|
|
}
|
|
get := func(q string) page {
|
|
var p page
|
|
rr := appGet(f.srv, f.path("/collections/requests/items"+q), f.token)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("%s: %d %s", q, rr.Code, rr.Body.String())
|
|
}
|
|
_ = json.Unmarshal(rr.Body.Bytes(), &p)
|
|
return p
|
|
}
|
|
p1 := get("?limit=2")
|
|
if !p1.HasMore || p1.NextOffset != 2 || len(p1.Items) != 2 {
|
|
t.Errorf("page 1: has_more=%v next_offset=%d items=%d, want true/2/2", p1.HasMore, p1.NextOffset, len(p1.Items))
|
|
}
|
|
p2 := get("?limit=2&offset=2")
|
|
if p2.HasMore || p2.NextOffset != 4 || len(p2.Items) != 1 {
|
|
t.Errorf("page 2: has_more=%v next_offset=%d items=%d, want false/4/1", p2.HasMore, p2.NextOffset, len(p2.Items))
|
|
}
|
|
}
|