package oltp
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// The binding round-trips with its secret, the table never holds the
// plaintext, the key file is made 0600 beside the database on first
// use and a reopen reads the same key; a probe stamps the row and an
// unbind removes it.
func TestBucketBindingSealsTheSecret(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "oltp.db")
d, err := Open(path)
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, sealKeyFile)); !os.IsNotExist(err) {
t.Fatalf("FAIL: opening wrote %s before anything was sealed", sealKeyFile)
}
if _, ok, err := d.Bucket("w"); err != nil || ok {
t.Fatalf("FAIL: unbound repo answered %v %v", ok, err)
}
in := BucketBinding{Endpoint: "https://acct.r2.cloudflarestorage.com", Bucket: "files", Region: "auto",
AccessKey: "AK", Secret: "s3cret-key-material", PublicBase: "https://files.example"}
if err := d.BindBucket("w", in); err != nil {
t.Fatal(err)
}
st, err := os.Stat(filepath.Join(dir, sealKeyFile))
if err != nil {
t.Fatalf("FAIL: no key file: %v", err)
}
if st.Mode().Perm() != 0o600 {
t.Errorf("FAIL: key file mode %o, want 600", st.Mode().Perm())
}
var stored string
if err := d.db.QueryRow(`SELECT secret_enc FROM bucket WHERE repo = 'w'`).Scan(&stored); err != nil {
t.Fatal(err)
}
if strings.Contains(stored, in.Secret) || stored == "" {
t.Fatalf("FAIL: secret_enc holds the plaintext: %q", stored)
}
got, ok, err := d.Bucket("w")
if err != nil || !ok {
t.Fatalf("FAIL: Bucket: %v %v", ok, err)
}
if got.Secret != in.Secret || got.Endpoint != in.Endpoint || got.LimitBytes != DefaultBucketLimit || got.PublicBase != in.PublicBase {
t.Fatalf("FAIL: round trip %+v", got)
}
at := time.Date(2026, 9, 11, 10, 0, 0, 0, time.UTC)
if err := d.SetBucketProbe("w", at, "PUT returned 403 AccessDenied"); err != nil {
t.Fatal(err)
}
if got, _, _ = d.Bucket("w"); got.ProbedAt != "2026-09-11T10:00:00Z" || got.ProbeErr == "" {
t.Fatalf("FAIL: probe not recorded: %+v", got)
}
d.Close()
// A reopen reads the same key file and opens what the first
// process sealed.
d2, err := Open(path)
if err != nil {
t.Fatal(err)
}
defer d2.Close()
if got, _, err = d2.Bucket("w"); err != nil || got.Secret != in.Secret {
t.Fatalf("FAIL: after reopen: %v %+v", err, got)
}
if err := d2.UnbindBucket("w"); err != nil {
t.Fatal(err)
}
if _, ok, _ := d2.Bucket("w"); ok {
t.Fatal("FAIL: still bound after unbind")
}
if err := d2.BindBucket("w", BucketBinding{Endpoint: "https://x", Bucket: "b", AccessKey: "k"}); err == nil {
t.Fatal("FAIL: a binding without a secret was accepted")
}
t.Log("ok: sealed at rest, unsealed on read, key file 0600 and stable across reopen")
}
// A replaced key file makes the stored secret unreadable, and the
// error says so rather than answering garbage.
func TestBucketSecretNamesALostKey(t *testing.T) {
dir := t.TempDir()
d, err := Open(filepath.Join(dir, "oltp.db"))
if err != nil {
t.Fatal(err)
}
if err := d.BindBucket("w", BucketBinding{Endpoint: "https://x", Bucket: "b", AccessKey: "k", Secret: "s"}); err != nil {
t.Fatal(err)
}
d.Close()
if err := os.Remove(filepath.Join(dir, sealKeyFile)); err != nil {
t.Fatal(err)
}
d, err = Open(filepath.Join(dir, "oltp.db"))
if err != nil {
t.Fatal(err)
}
defer d.Close()
_, _, err = d.Bucket("w")
if err == nil || !strings.Contains(err.Error(), sealKeyFile) {
t.Fatalf("FAIL: want an error naming the key file, got %v", err)
}
t.Logf("ok: %v", err)
}