mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
The hub already abstracted authentication — AuthProvider, with BuiltinAuth as the built-in implementation — and then reached around that seam three times: Accounts() was declared on neither interface, admin.go type-asserted *BuiltinAuth (five handlers silently degraded to 404/empty under any other provider), and organizations were not on the seam at all. That last gap had teeth. A deployment whose identities come from elsewhere had no way to own its orgs, so the code that did own them wrote into the hub's OrgDB from the side — and nothing stopped the hub from inventing an org that the identity system had never heard of. One did: a hub-created org held every project while the mirrored one sat empty, and no sync path could see the difference. Directory (directory.go) is where organizations live now. LocalDirectory wraps today's OrgDB unchanged — same last-owner protection, same normEmail, same "o-"+randHex(4) ids, same file/SQL persistence — so a self-hosted hub behaves exactly as before. A deployment whose orgs are owned elsewhere implements the same interface, returns ErrManagedElsewhere from the write half, and the handlers answer 409 with ManageURL. The hub never learns why a write was refused, only where to send the user. Two rules shape the interface. Reads are on the request path: Role runs on every project request, including the /store/* endpoints a device hits every few seconds with a token that carries no identity claims, so an implementation backed by a remote system answers from its own cache — and that cache is its business, not the hub's. Writes are optional, because "this hub owns its orgs" is a deployment fact, not a code path. - Server.Orgs *OrgDB becomes Server.Dir Directory: 28 call sites, 8 nil-checks, one writeDirErr helper for the 409 translation. - /api/orgs gains manage_url per org — the destination of the account menu's Settings entry. The client follows a link and never branches on which kind of hub it is talking to. - Org administration becomes a real route, /orgs/<id>, retiring one of the two URL-less panels CLAUDE.md grandfathers. When a directory's ManageURL is not hub-local, the SPA fallback redirects there instead — so a hub that cannot administer its orgs cannot paint a console whose every control 409s. - Accounts() moves onto AuthProvider. admin.go's type assertion becomes an optional AccountApprover, and a provider without one now answers 503 rather than an empty approval queue: "no queue here" and "queue is empty" are different answers and only one of them was true. Two reviews drove the rest. The architecture review caught a browser page load that could delete org members (a display read ran the full membership reconcile, and a 200 with an empty user list evicted everyone), one write site that escaped the 409 translation, and a webhook that could wedge an event stream behind an unappliable event. The design review, over eight rounds, caught the org page rendering live controls on a hub that cannot use them, a share link made unrevokable by a long filename, nine keyboard tab stops parked off-screen behind a closed drawer, and — five separate times — a fix of mine that looked right in the source and did nothing in the browser. Conformance tests run both a writable and a read-only implementation against one contract; the seat, prune, and out-of-order regressions each have a test written to fail against the old code.
147 lines
5.1 KiB
Go
147 lines
5.1 KiB
Go
package webapp
|
|
|
|
import (
|
|
"errors"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// Every Directory implementation has to behave the same way from the hub's
|
|
// side, whether it owns its organizations or mirrors someone else's. This is
|
|
// the contract, run against each implementation — the same shape as
|
|
// db_conformance_test does for MetaStore backends.
|
|
//
|
|
// A read-only directory (one whose orgs live in an external identity system)
|
|
// is a first-class case: it answers reads normally and refuses writes with
|
|
// ErrManagedElsewhere, which is what lets the hub answer 409 without knowing
|
|
// anything about the system on the other side.
|
|
|
|
// readOnlyDir wraps a directory and refuses every write, the way an
|
|
// implementation backed by an external identity system does.
|
|
type readOnlyDir struct{ Directory }
|
|
|
|
func (readOnlyDir) Create(string, string) (Org, error) { return Org{}, ErrManagedElsewhere }
|
|
func (readOnlyDir) Rename(string, string) error { return ErrManagedElsewhere }
|
|
func (readOnlyDir) AddMember(string, string, string) error { return ErrManagedElsewhere }
|
|
func (readOnlyDir) SetRole(string, string, string) error { return ErrManagedElsewhere }
|
|
func (readOnlyDir) RemoveMember(string, string) error { return ErrManagedElsewhere }
|
|
func (readOnlyDir) CreateInvite(string, string, time.Duration) (OrgInvite, error) {
|
|
return OrgInvite{}, ErrManagedElsewhere
|
|
}
|
|
func (readOnlyDir) ManageURL(orgID string) string { return "https://elsewhere.example/" + orgID }
|
|
|
|
func TestDirectoryConformance(t *testing.T) {
|
|
t.Run("LocalDirectory", func(t *testing.T) {
|
|
db, err := OpenOrgDB(filepath.Join(t.TempDir(), "orgs.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dirReads(t, LocalDirectory{OrgDB: db}, true)
|
|
})
|
|
|
|
t.Run("read-only directory", func(t *testing.T) {
|
|
db, err := OpenOrgDB(filepath.Join(t.TempDir(), "orgs.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Seed through the underlying store: an external directory's contents
|
|
// arrive by mirroring, not through the hub's write path.
|
|
o, err := db.Create("acme", "alice@x.io")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dir := readOnlyDir{Directory: LocalDirectory{OrgDB: db}}
|
|
dirReads(t, dir, false)
|
|
|
|
// Reads still work on the seeded org...
|
|
if got := dir.Role(o.ID, "alice@x.io"); got != RoleOwner {
|
|
t.Errorf("Role = %q, want owner", got)
|
|
}
|
|
// ...and every write is refused the same recognizable way.
|
|
for name, err := range map[string]error{
|
|
"Rename": dir.Rename(o.ID, "nope"),
|
|
"AddMember": dir.AddMember(o.ID, "bob@x.io", RoleMember),
|
|
"SetRole": dir.SetRole(o.ID, "alice@x.io", RoleMember),
|
|
"RemoveMember": dir.RemoveMember(o.ID, "alice@x.io"),
|
|
} {
|
|
if !errors.Is(err, ErrManagedElsewhere) {
|
|
t.Errorf("%s err = %v, want ErrManagedElsewhere", name, err)
|
|
}
|
|
}
|
|
if _, err := dir.Create("other", "alice@x.io"); !errors.Is(err, ErrManagedElsewhere) {
|
|
t.Errorf("Create err = %v, want ErrManagedElsewhere", err)
|
|
}
|
|
if _, err := dir.CreateInvite(o.ID, "alice@x.io", 0); !errors.Is(err, ErrManagedElsewhere) {
|
|
t.Errorf("CreateInvite err = %v, want ErrManagedElsewhere", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// dirReads exercises the read surface every implementation must answer, and
|
|
// the writes only an owning directory supports.
|
|
func dirReads(t *testing.T, dir Directory, writable bool) {
|
|
t.Helper()
|
|
|
|
if writable {
|
|
o, err := dir.Create("acme", "alice@x.io")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, ok := dir.Get(o.ID); !ok || got.Name != "acme" {
|
|
t.Fatalf("Get = %+v ok=%v", got, ok)
|
|
}
|
|
if got := dir.Role(o.ID, "ALICE@x.io"); got != RoleOwner {
|
|
t.Errorf("Role is not email-normalized: %q", got)
|
|
}
|
|
if err := dir.AddMember(o.ID, "bob@x.io", RoleMember); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The invariants live in the implementation, not in the caller.
|
|
if err := dir.RemoveMember(o.ID, "alice@x.io"); err == nil {
|
|
t.Error("removing the last owner must be refused")
|
|
}
|
|
if err := dir.SetRole(o.ID, "alice@x.io", RoleMember); err == nil {
|
|
t.Error("demoting the last owner must be refused")
|
|
}
|
|
inv, err := dir.CreateInvite(o.ID, "alice@x.io", time.Hour)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !dir.ValidInvite(inv.Token) {
|
|
t.Error("fresh invite is not valid")
|
|
}
|
|
if got := dir.ListInvites(o.ID); len(got) != 1 {
|
|
t.Errorf("ListInvites = %d, want 1", len(got))
|
|
}
|
|
if _, ok := dir.Redeem(inv.Token); !ok {
|
|
t.Error("Redeem failed")
|
|
}
|
|
dir.RecordInviteUse(inv.Token)
|
|
if !dir.RevokeInvite(inv.Token) {
|
|
t.Error("RevokeInvite failed")
|
|
}
|
|
if dir.ValidInvite(inv.Token) {
|
|
t.Error("revoked invite is still valid")
|
|
}
|
|
if len(dir.OrgsFor("bob@x.io")) != 1 {
|
|
t.Error("OrgsFor missed the org bob belongs to")
|
|
}
|
|
}
|
|
|
|
// Unknown ids are answered, never panicked on — Role in particular runs on
|
|
// every project request including device sync.
|
|
if got := dir.Role("o-nope", "nobody@x.io"); got != "" {
|
|
t.Errorf("Role of a non-member = %q, want \"\"", got)
|
|
}
|
|
if _, ok := dir.Get("o-nope"); ok {
|
|
t.Error("Get of an unknown org reported ok")
|
|
}
|
|
if dir.ValidInvite("not-a-token") {
|
|
t.Error("bogus invite reported valid")
|
|
}
|
|
if dir.ManageURL("o-1234") == "" {
|
|
t.Error("ManageURL must always give the client somewhere to go")
|
|
}
|
|
}
|