Files
verdande/internal/store/access_test.go
T
Kristian WindandClaude Opus 5 b44db32bf7 feat(auth): Argon2id passwords, opaque tokens, TOTP and recovery codes
Password hashes are self-describing PHC strings, so the cost parameters
can be raised later without locking anyone out: NeedsRehash reports which
hashes to upgrade at the next login, when the plaintext is briefly at hand.

Session, invite, reset and API tokens are stored only as SHA-256 hashes;
the plaintext exists once, in the link or the reply. Unsalted is correct
here and only here — the input is 256 bits of randomness that has to be
found by value in a WHERE clause.

feat(store): project roles with viewer/editor/owner and explicit denial

Roles are strictly ordered so every check is "at least X". Access to a
task is access to its project: being the assignee or the author confers
nothing, or removing someone from a project would leave them holding
tasks inside it. Checks take a Querier so they can run in the same
transaction as the write they guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:00:28 +02:00

335 lines
11 KiB
Go

package store
import (
"context"
"database/sql"
"errors"
"testing"
)
// The permission matrix, stated once. Everything below checks against this.
func TestRoleCapabilities(t *testing.T) {
cases := []struct {
role Role
view, edit, manage, ok bool
}{
{RoleOwner, true, true, true, true},
{RoleEditor, true, true, false, true},
{RoleViewer, true, false, false, true},
// Anything that is not one of the three is not a lesser role — it is none.
{Role(""), false, false, false, false},
{Role("admin"), false, false, false, false},
{Role("OWNER"), false, false, false, false},
{Role("editor "), false, false, false, false},
}
for _, tc := range cases {
t.Run(string(tc.role), func(t *testing.T) {
if got := tc.role.Valid(); got != tc.ok {
t.Errorf("Valid() = %v, want %v", got, tc.ok)
}
if got := tc.role.CanView(); got != tc.view {
t.Errorf("CanView() = %v, want %v", got, tc.view)
}
if got := tc.role.CanEdit(); got != tc.edit {
t.Errorf("CanEdit() = %v, want %v", got, tc.edit)
}
if got := tc.role.CanManage(); got != tc.manage {
t.Errorf("CanManage() = %v, want %v", got, tc.manage)
}
})
}
}
func TestRoleOrdering(t *testing.T) {
if !RoleOwner.AtLeast(RoleEditor) || !RoleOwner.AtLeast(RoleViewer) {
t.Error("owner does not outrank editor and viewer")
}
if !RoleEditor.AtLeast(RoleViewer) {
t.Error("editor does not outrank viewer")
}
if RoleViewer.AtLeast(RoleEditor) || RoleEditor.AtLeast(RoleOwner) {
t.Error("a lesser role satisfied a greater minimum")
}
if RoleOwner.AtLeast(Role("nonsense")) {
t.Error("an unknown minimum was satisfied; it must never be")
}
}
// seedAccess builds one shared project with a member of each role, plus an outsider.
func seedAccess(t *testing.T, db *DB) {
t.Helper()
stmts := []string{
`INSERT INTO users (id, email, name, password_hash, created_at, updated_at) VALUES
('owner', 'owner@example.com', 'Owner', 'x', 0, 0),
('editor', 'editor@example.com', 'Editor', 'x', 0, 0),
('viewer', 'viewer@example.com', 'Viewer', 'x', 0, 0),
('outsider','outsider@example.com','Outsider','x', 0, 0)`,
`INSERT INTO projects (id, name, owner_id, created_at, updated_at) VALUES
('shared', 'Delt projekt', 'owner', 0, 0),
('private', 'Privat projekt', 'owner', 0, 0)`,
`INSERT INTO project_members (project_id, user_id, role, added_at) VALUES
('shared', 'editor', 'editor', 0),
('shared', 'viewer', 'viewer', 0)`,
`INSERT INTO tasks (id, project_id, content, created_by, created_at, updated_at) VALUES
('task-shared', 'shared', 'delt opgave', 'owner', 0, 0),
('task-private', 'private', 'privat opgave', 'owner', 0, 0)`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("seed: %v", err)
}
}
}
func TestProjectRole(t *testing.T) {
db := openTest(t)
seedAccess(t, db)
ctx := context.Background()
cases := []struct {
user, project string
want Role
wantErr bool
}{
// The owner is the owner without needing a project_members row.
{"owner", "shared", RoleOwner, false},
{"editor", "shared", RoleEditor, false},
{"viewer", "shared", RoleViewer, false},
{"owner", "private", RoleOwner, false},
// An external user sees only what has been shared with them.
{"outsider", "shared", "", true},
{"outsider", "private", "", true},
{"editor", "private", "", true},
{"viewer", "private", "", true},
// An id that does not exist is indistinguishable from one you cannot see.
{"owner", "no-such-project", "", true},
{"no-such-user", "shared", "", true},
}
for _, tc := range cases {
t.Run(tc.user+"/"+tc.project, func(t *testing.T) {
got, err := ProjectRole(ctx, db, tc.project, tc.user)
if tc.wantErr {
if !errors.Is(err, ErrNoAccess) {
t.Fatalf("err = %v, want ErrNoAccess", err)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Errorf("role = %q, want %q", got, tc.want)
}
})
}
}
// The central promise of the sharing model: a viewer can read and cannot write.
func TestViewerCannotMutate(t *testing.T) {
db := openTest(t)
seedAccess(t, db)
ctx := context.Background()
if _, err := RequireProjectRole(ctx, db, "shared", "viewer", RoleViewer); err != nil {
t.Errorf("viewer cannot view the project they were given: %v", err)
}
if _, err := RequireProjectRole(ctx, db, "shared", "viewer", RoleEditor); !errors.Is(err, ErrNoAccess) {
t.Errorf("viewer passed an editor check: %v", err)
}
if _, err := RequireProjectRole(ctx, db, "shared", "viewer", RoleOwner); !errors.Is(err, ErrNoAccess) {
t.Errorf("viewer passed an owner check: %v", err)
}
}
func TestEditorCannotManageTheProject(t *testing.T) {
db := openTest(t)
seedAccess(t, db)
ctx := context.Background()
if _, err := RequireProjectRole(ctx, db, "shared", "editor", RoleEditor); err != nil {
t.Errorf("editor cannot edit: %v", err)
}
// Renaming, inviting, deleting: owner only.
if _, err := RequireProjectRole(ctx, db, "shared", "editor", RoleOwner); !errors.Is(err, ErrNoAccess) {
t.Errorf("editor passed an owner check: %v", err)
}
}
// An external user must see nothing but the projects actually shared with them.
func TestExternalUserSeesOnlySharedProjects(t *testing.T) {
db := openTest(t)
seedAccess(t, db)
ctx := context.Background()
for _, project := range []string{"shared", "private"} {
if _, err := ProjectRole(ctx, db, project, "outsider"); !errors.Is(err, ErrNoAccess) {
t.Errorf("outsider reached project %q: %v", project, err)
}
}
// Once invited, and only then.
_, err := db.Exec(`INSERT INTO project_members (project_id, user_id, role, added_at)
VALUES ('shared', 'outsider', 'viewer', 0)`)
if err != nil {
t.Fatal(err)
}
if role, err := ProjectRole(ctx, db, "shared", "outsider"); err != nil || role != RoleViewer {
t.Errorf("after being invited: role %q, err %v", role, err)
}
if _, err := ProjectRole(ctx, db, "private", "outsider"); !errors.Is(err, ErrNoAccess) {
t.Error("being invited to one project granted access to another")
}
}
// Access to a task is access to its project, and nothing else confers it.
func TestTaskAccessFollowsItsProject(t *testing.T) {
db := openTest(t)
seedAccess(t, db)
ctx := context.Background()
if role, err := TaskRole(ctx, db, "task-shared", "viewer"); err != nil || role != RoleViewer {
t.Errorf("viewer on a task in a shared project: role %q, err %v", role, err)
}
if _, err := TaskRole(ctx, db, "task-private", "editor"); !errors.Is(err, ErrNoAccess) {
t.Error("a task in an unshared project was reachable")
}
if _, err := TaskRole(ctx, db, "no-such-task", "owner"); !errors.Is(err, ErrNoAccess) {
t.Error("a task that does not exist did not report as inaccessible")
}
}
// Being assigned a task, or having created one, is not a form of membership.
// Otherwise removing somebody from a project would leave them holding the tasks.
func TestAssigneeAndAuthorGetNoStanding(t *testing.T) {
db := openTest(t)
seedAccess(t, db)
ctx := context.Background()
_, err := db.Exec(`INSERT INTO tasks (id, project_id, content, assignee_id, created_by, created_at, updated_at)
VALUES ('assigned', 'private', 'til outsider', 'outsider', 'outsider', 0, 0)`)
if err != nil {
t.Fatal(err)
}
if _, err := TaskRole(ctx, db, "assigned", "outsider"); !errors.Is(err, ErrNoAccess) {
t.Error("being the assignee and author granted access to the project")
}
}
// A project in the trash is not readable until it is explicitly restored.
func TestTrashedProjectIsNotAccessible(t *testing.T) {
db := openTest(t)
seedAccess(t, db)
ctx := context.Background()
if _, err := db.Exec(`UPDATE projects SET deleted_at = 1 WHERE id = 'shared'`); err != nil {
t.Fatal(err)
}
for _, user := range []string{"owner", "editor", "viewer"} {
if _, err := ProjectRole(ctx, db, "shared", user); !errors.Is(err, ErrNoAccess) {
t.Errorf("%s still reached a trashed project: %v", user, err)
}
}
if _, err := TaskRole(ctx, db, "task-shared", "owner"); !errors.Is(err, ErrNoAccess) {
t.Error("a task in a trashed project was still reachable")
}
}
func TestTrashedTaskIsNotAccessible(t *testing.T) {
db := openTest(t)
seedAccess(t, db)
ctx := context.Background()
if _, err := db.Exec(`UPDATE tasks SET deleted_at = 1 WHERE id = 'task-shared'`); err != nil {
t.Fatal(err)
}
if _, err := TaskRole(ctx, db, "task-shared", "owner"); !errors.Is(err, ErrNoAccess) {
t.Error("a trashed task was still reachable")
}
}
// A role string the database holds but this build does not understand must fail
// closed. Reading it as "some access" is how a future migration becomes a breach.
func TestUnknownStoredRoleDeniesAccess(t *testing.T) {
db := openTest(t)
seedAccess(t, db)
ctx := context.Background()
// The CHECK constraint refuses this on the way in, which is the first line of
// defence; write it in behind the constraint's back to test the second.
if _, err := db.Exec(`PRAGMA ignore_check_constraints = ON`); err != nil {
t.Fatal(err)
}
_, err := db.Exec(`INSERT INTO project_members (project_id, user_id, role, added_at)
VALUES ('private', 'outsider', 'superuser', 0)`)
if err != nil {
t.Skipf("could not write an invalid role to test the fallback: %v", err)
}
if _, err := ProjectRole(ctx, db, "private", "outsider"); !errors.Is(err, ErrNoAccess) {
t.Errorf("an unrecognised role granted access: %v", err)
}
}
// Permission checks must be able to run inside the transaction that does the write.
// Checking on the connection and then writing in a transaction leaves a window in
// which a membership can be revoked between the two — so ProjectRole takes a
// Querier, which both *sql.DB and *sql.Tx satisfy.
func TestPermissionCheckWorksInsideATransaction(t *testing.T) {
db := openTest(t)
seedAccess(t, db)
ctx := context.Background()
err := db.Tx(ctx, func(tx *sql.Tx) error {
role, err := RequireProjectRole(ctx, tx, "shared", "editor", RoleEditor)
if err != nil {
return err
}
if role != RoleEditor {
t.Errorf("role in transaction = %q, want editor", role)
}
_, err = tx.ExecContext(ctx,
`INSERT INTO tasks (id, project_id, content, created_by, created_at, updated_at)
VALUES ('in-tx', 'shared', 'skrevet i transaktion', 'editor', 0, 0)`)
return err
})
if err != nil {
t.Fatalf("transaction: %v", err)
}
var n int
if err := db.QueryRow(`SELECT count(*) FROM tasks WHERE id = 'in-tx'`).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Error("the write inside the checked transaction did not land")
}
// And a denied check inside a transaction rolls the whole thing back.
err = db.Tx(ctx, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx,
`INSERT INTO tasks (id, project_id, content, created_by, created_at, updated_at)
VALUES ('rolled-back', 'shared', 'skal rulles tilbage', 'viewer', 0, 0)`); err != nil {
return err
}
_, err := RequireProjectRole(ctx, tx, "shared", "viewer", RoleEditor)
return err
})
if !errors.Is(err, ErrNoAccess) {
t.Fatalf("err = %v, want ErrNoAccess", err)
}
if err := db.QueryRow(`SELECT count(*) FROM tasks WHERE id = 'rolled-back'`).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Error("a write survived a transaction that failed its permission check")
}
}