sqlite persistence: the store moves from db.json to dist.db (P5.2)
src/repo/db.sx persists the whole Repo to <store>/dist.db through the vendored SQLite bindings, keeping the load-whole/save-whole call shape. One table per entity; enums as lowercase variant names; list order round-trips via rowid. Enforced uniqueness: apps.slug, channels(app_id, name), tokens.token_hash; lookup indexes on releases(app_id) and artifacts(sha256) (non-unique - identical bytes may ship in several releases). save is DELETE-all + INSERT-all inside BEGIN IMMEDIATE...COMMIT with rollback on failure; every connection sets busy_timeout so the CLI and a running distd interleave safely. A store holding only a pre-SQLite db.json imports once on first load, then the file is renamed db.json.imported; a store with neither starts empty. Consumers gate on db.store_exists instead of probing db.json. The JSON read-back stays for the import path; the entity->json writers stay for distd's /api responses. Tests that parsed db.json directly now assert by querying dist.db through the SQLite bindings; tests/db_import.sx pins the import path; tests/repo_roundtrip.sx pins the SQLite round-trip. make test 22/22.
This commit is contained in:
147
tests/db_import.sx
Normal file
147
tests/db_import.sx
Normal file
@@ -0,0 +1,147 @@
|
||||
// Pinned acceptance for P5.2 — one-time import of a pre-SQLite store.
|
||||
//
|
||||
// A store laid down by the db.json era (no dist.db) must keep working:
|
||||
// the FIRST load imports every entity into `<store>/dist.db` and renames
|
||||
// the JSON file to `db.json.imported`, after which SQLite is the only
|
||||
// authority. Drives the BUILT `build/dist` binary:
|
||||
//
|
||||
// 1. A store holding ONLY an old-layout db.json (app + bundle id, two
|
||||
// releases, artifact, channel, token, audit event) answers
|
||||
// `token list` with the minted token → the import ran: dist.db
|
||||
// exists, db.json is GONE, db.json.imported holds the original.
|
||||
// 2. Every entity survived the import field-for-field where it counts
|
||||
// (queried from dist.db via the SQLite bindings).
|
||||
// 3. A follow-up write op (`release promote`) works against the
|
||||
// imported store and does NOT re-import (db.json.imported stays).
|
||||
// 4. A store with NO database at all refuses ops that need one
|
||||
// (`release promote` → exit 1, store.load).
|
||||
#import "modules/std.sx";
|
||||
#import "modules/std/json.sx";
|
||||
process :: #import "modules/std/process.sx";
|
||||
fs :: #import "modules/std/fs.sx";
|
||||
sq :: #import "../src/db/sqlite.sx";
|
||||
|
||||
STORE :: ".sx-tmp/db_import";
|
||||
EMPTY :: ".sx-tmp/db_import_empty";
|
||||
|
||||
// An old-layout db.json covering every persisted table.
|
||||
OLD_DB :: "{\"apps\":[{\"id\":\"app-old\",\"slug\":\"old-app\",\"display_name\":\"Old App\",\"bundle_ids\":[{\"platform\":\"ios\",\"value\":\"co.old.app\"}],\"owner\":\"ci\",\"visibility\":\"private\",\"created_at\":1700000000,\"updated_at\":1700000000}],\"releases\":[{\"id\":\"rel-1\",\"app_id\":\"app-old\",\"version\":\"1.0.0\",\"build\":1,\"channel\":\"beta\",\"notes\":\"\",\"created_by\":\"ci\",\"created_at\":1700000100,\"published_at\":1700000100},{\"id\":\"rel-2\",\"app_id\":\"app-old\",\"version\":\"1.0.1\",\"build\":2,\"channel\":\"beta\",\"notes\":\"\",\"created_by\":\"ci\",\"created_at\":1700000200,\"published_at\":1700000200}],\"artifacts\":[{\"id\":\"rel-1-android_apk\",\"app_id\":\"app-old\",\"release_id\":\"rel-1\",\"platform\":\"android_apk\",\"filename\":\"old.apk\",\"content_type\":\"application/vnd.android.package-archive\",\"size_bytes\":5,\"sha256\":\"55a008aa634d45313ef0a758624e0d2a356c156e507f28a2c60d19d38893af09\",\"storage_key\":\"55a008aa634d45313ef0a758624e0d2a356c156e507f28a2c60d19d38893af09\",\"metadata\":\"\",\"validation_status\":\"valid\"}],\"channels\":[{\"app_id\":\"app-old\",\"name\":\"beta\",\"current_release_id\":\"rel-2\",\"policy\":\"manual\",\"rollout_percent\":100}],\"tokens\":[{\"id\":\"tok-abcdefabcdef\",\"name\":\"legacy-ci\",\"token_hash\":\"55a008aa634d45313ef0a758624e0d2a356c156e507f28a2c60d19d38893af09\",\"scopes\":\"publish\",\"app_slug\":\"\",\"channel\":\"\",\"created_at\":1700000300,\"expires_at\":0,\"last_used_at\":0,\"revoked_at\":0}],\"audit_events\":[{\"id\":\"evt-publish-rel-1\",\"actor\":\"ci\",\"action\":\"release.publish\",\"target_type\":\"release\",\"target_id\":\"rel-1\",\"metadata\":\"\",\"created_at\":1700000100}]}";
|
||||
|
||||
get :: (o: Object, key: string) -> Value {
|
||||
i := 0;
|
||||
while i < o.len {
|
||||
if o.items[i].key == key { return o.items[i].val; }
|
||||
i += 1;
|
||||
}
|
||||
process.assert(false, concat("missing json key: ", key));
|
||||
dummy : Value = .null_;
|
||||
return dummy;
|
||||
}
|
||||
get_str :: (o: Object, key: string) -> string { return get(o, key).str; }
|
||||
get_obj :: (o: Object, key: string) -> Object { return get(o, key).object; }
|
||||
get_arr :: (o: Object, key: string) -> Array { return get(o, key).array; }
|
||||
|
||||
// One-row scalar queries over `<STORE>/dist.db` ("" = unbound binding).
|
||||
db_open_ro :: () -> sq.Sqlite {
|
||||
c, oe := sq.Sqlite.open_v2(path_join(STORE, "dist.db"), sq.SQLITE_OPEN_READONLY);
|
||||
process.assert(!oe, "dist.db must open as a SQLite database");
|
||||
c.busy_timeout(2000);
|
||||
return c;
|
||||
}
|
||||
|
||||
q_text :: (sql: string, p1: string) -> string {
|
||||
c := db_open_ro();
|
||||
st, pe := c.prepare(sql);
|
||||
process.assert(!pe, concat("prepare must succeed: ", sql));
|
||||
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
|
||||
rc, se := st.step();
|
||||
process.assert(!se, concat("step must succeed: ", sql));
|
||||
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
|
||||
out := st.column_text(0);
|
||||
st.finalize();
|
||||
c.close();
|
||||
return out;
|
||||
}
|
||||
|
||||
q_int :: (sql: string, p1: string) -> i64 {
|
||||
c := db_open_ro();
|
||||
st, pe := c.prepare(sql);
|
||||
process.assert(!pe, concat("prepare must succeed: ", sql));
|
||||
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
|
||||
rc, se := st.step();
|
||||
process.assert(!se, concat("step must succeed: ", sql));
|
||||
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
|
||||
out := st.column_int64(0);
|
||||
st.finalize();
|
||||
c.close();
|
||||
return out;
|
||||
}
|
||||
|
||||
main :: () -> i32 {
|
||||
gpa := GPA.init();
|
||||
arena := Arena.init(xx gpa, 1 << 20);
|
||||
defer arena.deinit();
|
||||
|
||||
process.run(concat("rm -rf ", STORE));
|
||||
process.run(concat("rm -rf ", EMPTY));
|
||||
process.run(concat("mkdir -p ", STORE));
|
||||
process.run(concat("mkdir -p ", EMPTY));
|
||||
process.run(concat(concat(concat("printf '%s' '", OLD_DB), "' > "), path_join(STORE, "db.json")));
|
||||
|
||||
// ── 1. First load imports: db.json -> dist.db + db.json.imported ──
|
||||
tl := process.run("build/dist token list --local-store .sx-tmp/db_import --json 2>/dev/null");
|
||||
process.assert(tl != null and tl!.exit_code == 0, "token list over a db.json-only store must exit 0");
|
||||
lv, le := parse(tl!.stdout, xx arena);
|
||||
if le { process.assert(false, "token list stdout must be one JSON object"); return 1; }
|
||||
toks := get_arr(lv.object, "tokens");
|
||||
process.assert(toks.len == 1, "the legacy token is listed");
|
||||
process.assert(get_str(toks.items[0].object, "id") == "tok-abcdefabcdef", "legacy token id survives");
|
||||
|
||||
process.assert(fs.exists(path_join(STORE, "dist.db")), "import created dist.db");
|
||||
process.assert(!fs.exists(path_join(STORE, "db.json")), "import consumed db.json");
|
||||
process.assert(fs.exists(path_join(STORE, "db.json.imported")), "the original is kept as db.json.imported");
|
||||
print(" first load imported db.json into dist.db\n");
|
||||
|
||||
// ── 2. Every entity survived the import ──────────────────────────
|
||||
process.assert(q_int("SELECT COUNT(*) FROM apps", "") == 1, "imported: one app");
|
||||
process.assert(q_text("SELECT slug FROM apps", "") == "old-app", "imported: app slug");
|
||||
process.assert(q_int("SELECT COUNT(*) FROM app_bundle_ids", "") == 1, "imported: one bundle id");
|
||||
process.assert(q_text("SELECT value FROM app_bundle_ids WHERE platform = ?1", "ios") == "co.old.app",
|
||||
"imported: iOS bundle id value");
|
||||
process.assert(q_int("SELECT COUNT(*) FROM releases", "") == 2, "imported: both releases");
|
||||
process.assert(q_int("SELECT COUNT(*) FROM artifacts", "") == 1, "imported: the artifact");
|
||||
process.assert(q_text("SELECT validation_status FROM artifacts", "") == "valid",
|
||||
"imported: artifact status");
|
||||
process.assert(q_text("SELECT current_release_id FROM channels WHERE name = ?1", "beta") == "rel-2",
|
||||
"imported: channel pointer");
|
||||
process.assert(q_text("SELECT token_hash FROM tokens WHERE id = ?1", "tok-abcdefabcdef")
|
||||
== "55a008aa634d45313ef0a758624e0d2a356c156e507f28a2c60d19d38893af09",
|
||||
"imported: token hash at rest");
|
||||
process.assert(q_int("SELECT COUNT(*) FROM audit_events WHERE action = ?1", "release.publish") == 1,
|
||||
"imported: the audit event");
|
||||
print(" imported store carries every entity\n");
|
||||
|
||||
// ── 3. A write op works on the imported store, no re-import ──────
|
||||
pr := process.run("build/dist release promote --app old-app --channel beta --release rel-1 --local-store .sx-tmp/db_import --json 2>/dev/null");
|
||||
process.assert(pr != null and pr!.exit_code == 0, "promote on the imported store must exit 0");
|
||||
process.assert(q_text("SELECT current_release_id FROM channels WHERE name = ?1", "beta") == "rel-1",
|
||||
"promote moved the imported channel pointer");
|
||||
process.assert(fs.exists(path_join(STORE, "db.json.imported")), "db.json.imported is not consumed again");
|
||||
process.assert(!fs.exists(path_join(STORE, "db.json")), "no db.json reappears");
|
||||
print(" imported store accepts writes; import ran exactly once\n");
|
||||
|
||||
// ── 4. A store with NO database refuses ops that need one ────────
|
||||
pn := process.run("build/dist release promote --app old-app --channel beta --release rel-1 --local-store .sx-tmp/db_import_empty --json 2>/dev/null");
|
||||
process.assert(pn != null and pn!.exit_code == 1, "promote on an empty store must exit 1");
|
||||
nv, ne := parse(pn!.stdout, xx arena);
|
||||
if ne { process.assert(false, "empty-store promote stdout must be one JSON object"); return 1; }
|
||||
process.assert(get_str(get_obj(nv.object, "error"), "code") == "store.load",
|
||||
"empty-store promote names store.load");
|
||||
process.assert(!fs.exists(path_join(EMPTY, "dist.db")), "a refused op creates no database");
|
||||
print(" empty store: ops that need state refuse with store.load\n");
|
||||
|
||||
process.run(concat("rm -rf ", STORE));
|
||||
process.run(concat("rm -rf ", EMPTY));
|
||||
print("db_import: ALL CASES PASS\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -9,17 +9,19 @@
|
||||
// * exit code 1 (command failed; NOT the parser's EX_USAGE 64),
|
||||
// * stdout under `--json` is a SINGLE JSON object
|
||||
// `{"status":"error","error":{"code":<dotted code>,"message":...}}`,
|
||||
// * nothing is persisted: a fresh store gains no db.json.
|
||||
// * nothing is persisted: a fresh store gains no dist.db.
|
||||
//
|
||||
// The no-partial-state crux is then asserted against a NON-EMPTY store:
|
||||
// publish version A successfully, fail version B on a digest mismatch into
|
||||
// the SAME store, and require db.json byte-state unchanged — one release,
|
||||
// the SAME store, and require the store state unchanged — one release,
|
||||
// channel still pointing at A (no partially-published release, no moved
|
||||
// channel pointer).
|
||||
// channel pointer). Store state is queried from `<store>/dist.db` via the
|
||||
// SQLite bindings.
|
||||
#import "modules/std.sx";
|
||||
#import "modules/std/json.sx";
|
||||
process :: #import "modules/std/process.sx";
|
||||
fs :: #import "modules/std/fs.sx";
|
||||
sq :: #import "../src/db/sqlite.sx";
|
||||
|
||||
STORE :: ".sx-tmp/publish_fail";
|
||||
MDIR :: ".sx-tmp/publish_fail_m";
|
||||
@@ -54,6 +56,42 @@ publish_cmd :: (mpath: string, store: string) -> string {
|
||||
return concat(c, " --json 2>/dev/null");
|
||||
}
|
||||
|
||||
// One-row scalar queries over `<STORE>/dist.db` ("" = unbound binding).
|
||||
db_open_ro :: () -> sq.Sqlite {
|
||||
c, oe := sq.Sqlite.open_v2(path_join(STORE, "dist.db"), sq.SQLITE_OPEN_READONLY);
|
||||
process.assert(!oe, "dist.db must open as a SQLite database");
|
||||
c.busy_timeout(2000);
|
||||
return c;
|
||||
}
|
||||
|
||||
q_text :: (sql: string, p1: string) -> string {
|
||||
c := db_open_ro();
|
||||
st, pe := c.prepare(sql);
|
||||
process.assert(!pe, concat("prepare must succeed: ", sql));
|
||||
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
|
||||
rc, se := st.step();
|
||||
process.assert(!se, concat("step must succeed: ", sql));
|
||||
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
|
||||
out := st.column_text(0);
|
||||
st.finalize();
|
||||
c.close();
|
||||
return out;
|
||||
}
|
||||
|
||||
q_int :: (sql: string, p1: string) -> i64 {
|
||||
c := db_open_ro();
|
||||
st, pe := c.prepare(sql);
|
||||
process.assert(!pe, concat("prepare must succeed: ", sql));
|
||||
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
|
||||
rc, se := st.step();
|
||||
process.assert(!se, concat("step must succeed: ", sql));
|
||||
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
|
||||
out := st.column_int64(0);
|
||||
st.finalize();
|
||||
c.close();
|
||||
return out;
|
||||
}
|
||||
|
||||
// Write `body` to `path` via the shell (single-quoted, so the JSON's double
|
||||
// quotes pass through literally).
|
||||
write_file :: (path: string, body: string) {
|
||||
@@ -97,19 +135,19 @@ main :: () -> i32 {
|
||||
write_file(path_join(MDIR, "not_json.json"), NOT_JSON);
|
||||
|
||||
// ── each failure class: exit 1 + the precise dotted code, into a
|
||||
// fresh per-case store that must gain NO db.json ────────────────
|
||||
// fresh per-case store that must gain NO database ────────────────
|
||||
assert_fails("digest mismatch", path_join(MDIR, "bad_digest.json"), concat(STORE, "-digest"), "validation.digest_mismatch", xx arena);
|
||||
assert_fails("size mismatch", path_join(MDIR, "bad_size.json"), concat(STORE, "-size"), "validation.size_mismatch", xx arena);
|
||||
assert_fails("unknown platform", path_join(MDIR, "bad_platform.json"), concat(STORE, "-platform"), "manifest.unknown_platform", xx arena);
|
||||
assert_fails("missing artifact", path_join(MDIR, "no_artifact.json"), concat(STORE, "-missing"), "manifest.missing_artifact", xx arena);
|
||||
assert_fails("malformed manifest",path_join(MDIR, "not_json.json"), concat(STORE, "-badjson"), "manifest.bad_json", xx arena);
|
||||
|
||||
process.assert(!fs.exists(concat(STORE, "-digest/db.json")),
|
||||
"failed publish into a fresh store must not create db.json");
|
||||
process.assert(!fs.exists(concat(STORE, "-digest/dist.db")),
|
||||
"failed publish into a fresh store must not create dist.db");
|
||||
|
||||
// ── no-partial-state against a NON-EMPTY store ────────────────────
|
||||
// Publish A successfully, then fail B on a digest mismatch into the
|
||||
// SAME store: db.json must be unchanged (one release, channel → A).
|
||||
// SAME store: the db must be unchanged (one release, channel → A).
|
||||
ra := process.run(publish_cmd(path_join(MDIR, "good_a.json"), STORE));
|
||||
process.assert(ra != null, "spawn publish A failed");
|
||||
process.assert(ra!.exit_code == 0, "publish A must exit 0");
|
||||
@@ -118,17 +156,11 @@ main :: () -> i32 {
|
||||
process.assert(rb != null, "spawn failing publish B failed");
|
||||
process.assert(rb!.exit_code == 1, "publish B must exit 1 (digest mismatch)");
|
||||
|
||||
db_bytes := fs.read_file(path_join(STORE, "db.json"));
|
||||
process.assert(db_bytes != null, "db.json from publish A must still exist");
|
||||
dv, de := parse(db_bytes!, xx arena);
|
||||
if de { process.assert(false, "db.json must be valid JSON"); return 1; }
|
||||
dbo := dv.object;
|
||||
process.assert(get_arr(dbo, "releases").len == 1, "after failed B: db still has ONE release (A)");
|
||||
chans := get_arr(dbo, "channels");
|
||||
process.assert(chans.len == 1, "after failed B: one channel");
|
||||
process.assert(get_str(chans.items[0].object, "current_release_id") == "rel-acme-app-1.2.3",
|
||||
process.assert(q_int("SELECT COUNT(*) FROM releases", "") == 1, "after failed B: db still has ONE release (A)");
|
||||
process.assert(q_int("SELECT COUNT(*) FROM channels", "") == 1, "after failed B: one channel");
|
||||
process.assert(q_text("SELECT current_release_id FROM channels", "") == "rel-acme-app-1.2.3",
|
||||
"after failed B: channel still points at A (no moved pointer)");
|
||||
print(" non-empty store: failed publish left db.json unchanged\n");
|
||||
print(" non-empty store: failed publish left the db unchanged\n");
|
||||
|
||||
process.run(concat("rm -rf ", concat(STORE, "-digest")));
|
||||
process.run(concat("rm -rf ", concat(STORE, "-size")));
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
// 2. the emitted release id / artifact ids / sha256 / local URLs MATCH the
|
||||
// store: each `<store>/objects/<sha256>` exists and re-hashes (std.hash)
|
||||
// to its own key, and each url is `file://<abs-store>/objects/<sha256>`.
|
||||
// 3. `<store>/db.json` (re-parsed via std.json) records the release, both
|
||||
// artifacts (storage_key == sha256, validation_status valid), the
|
||||
// channel pointer (current_release_id == the release), and an audit
|
||||
// event per upload/publish/promotion.
|
||||
// 3. `<store>/dist.db` (queried through the SQLite bindings) records the
|
||||
// release, both artifacts (storage_key == sha256, validation_status
|
||||
// valid), the channel pointer (current_release_id == the release), and
|
||||
// an audit event per upload/publish/promotion.
|
||||
//
|
||||
// This FAILS against the pre-P3.4a stub (which rejects --manifest /
|
||||
// --local-store as unknown flags, exiting 64, and writes no store) and
|
||||
@@ -22,6 +22,7 @@
|
||||
process :: #import "modules/std/process.sx";
|
||||
fs :: #import "modules/std/fs.sx";
|
||||
hash :: #import "modules/std/hash.sx";
|
||||
sq :: #import "../src/db/sqlite.sx";
|
||||
|
||||
cstd :: #library "c";
|
||||
c_getcwd :: (buf: [*]u8, size: usize) -> *u8 #foreign cstd "getcwd";
|
||||
@@ -64,18 +65,44 @@ rehashes_to :: (path: string, want: string) -> bool {
|
||||
return view == want;
|
||||
}
|
||||
|
||||
// Count audit events whose "action" equals `action`.
|
||||
count_action :: (events: Array, action: string) -> i64 {
|
||||
c : i64 = 0;
|
||||
i := 0;
|
||||
while i < events.len {
|
||||
eo := events.items[i].object;
|
||||
if get_str(eo, "action") == action { c += 1; }
|
||||
i += 1;
|
||||
}
|
||||
// Open the store database read-only (asserts it exists and opens).
|
||||
db_open_ro :: () -> sq.Sqlite {
|
||||
c, oe := sq.Sqlite.open_v2(path_join(STORE_REL, "dist.db"), sq.SQLITE_OPEN_READONLY);
|
||||
process.assert(!oe, "dist.db must open as a SQLite database");
|
||||
c.busy_timeout(2000);
|
||||
return c;
|
||||
}
|
||||
|
||||
// One-row TEXT scalar with one optional text binding ("" = unbound).
|
||||
q_text :: (sql: string, p1: string) -> string {
|
||||
c := db_open_ro();
|
||||
st, pe := c.prepare(sql);
|
||||
process.assert(!pe, concat("prepare must succeed: ", sql));
|
||||
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
|
||||
rc, se := st.step();
|
||||
process.assert(!se, concat("step must succeed: ", sql));
|
||||
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
|
||||
out := st.column_text(0);
|
||||
st.finalize();
|
||||
c.close();
|
||||
return out;
|
||||
}
|
||||
|
||||
// One-row INTEGER scalar with one optional text binding ("" = unbound).
|
||||
q_int :: (sql: string, p1: string) -> i64 {
|
||||
c := db_open_ro();
|
||||
st, pe := c.prepare(sql);
|
||||
process.assert(!pe, concat("prepare must succeed: ", sql));
|
||||
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
|
||||
rc, se := st.step();
|
||||
process.assert(!se, concat("step must succeed: ", sql));
|
||||
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
|
||||
out := st.column_int64(0);
|
||||
st.finalize();
|
||||
c.close();
|
||||
return out;
|
||||
}
|
||||
|
||||
main :: () -> i32 {
|
||||
gpa := GPA.init();
|
||||
arena := Arena.init(xx gpa, 1 << 20);
|
||||
@@ -129,41 +156,31 @@ main :: () -> i32 {
|
||||
}
|
||||
print(" {} artifacts stored + re-hash to their keys\n", arts.len);
|
||||
|
||||
// ── 3. db.json records the published aggregate ──────────────────────
|
||||
db_bytes := fs.read_file(path_join(STORE_REL, "db.json"));
|
||||
process.assert(db_bytes != null, "db.json must exist under the store");
|
||||
dv, de := parse(db_bytes!, xx arena);
|
||||
if de { process.assert(false, "db.json must be valid JSON"); return 1; }
|
||||
dbo := dv.object;
|
||||
// ── 3. dist.db records the published aggregate ──────────────────────
|
||||
process.assert(q_int("SELECT COUNT(*) FROM releases", "") == 1, "db: one release");
|
||||
process.assert(q_text("SELECT id FROM releases", "") == rel_id, "db: release id matches");
|
||||
|
||||
db_rels := get_arr(dbo, "releases");
|
||||
process.assert(db_rels.len == 1, "db: one release");
|
||||
process.assert(get_str(db_rels.items[0].object, "id") == rel_id, "db: release id matches");
|
||||
process.assert(q_int("SELECT COUNT(*) FROM artifacts", "") == 2, "db: two artifacts");
|
||||
process.assert(q_int("SELECT COUNT(*) FROM artifacts WHERE storage_key = sha256", "") == 2,
|
||||
"db: storage_key == sha256 (content-addressed)");
|
||||
process.assert(q_int("SELECT COUNT(*) FROM artifacts WHERE validation_status = 'valid'", "") == 2,
|
||||
"db: artifact validation passed");
|
||||
process.assert(q_int("SELECT COUNT(*) FROM artifacts WHERE release_id = ?1", rel_id) == 2,
|
||||
"db: artifacts belong to the release");
|
||||
|
||||
db_arts := get_arr(dbo, "artifacts");
|
||||
process.assert(db_arts.len == 2, "db: two artifacts");
|
||||
j := 0;
|
||||
while j < db_arts.len {
|
||||
dao := db_arts.items[j].object;
|
||||
process.assert(get_str(dao, "storage_key") == get_str(dao, "sha256"),
|
||||
"db: storage_key == sha256 (content-addressed)");
|
||||
process.assert(get_str(dao, "validation_status") == "valid",
|
||||
"db: artifact validation passed");
|
||||
process.assert(get_str(dao, "release_id") == rel_id, "db: artifact belongs to the release");
|
||||
j += 1;
|
||||
}
|
||||
process.assert(q_int("SELECT COUNT(*) FROM channels", "") == 1, "db: one channel");
|
||||
process.assert(q_text("SELECT name FROM channels", "") == "stable", "db: channel name");
|
||||
process.assert(q_text("SELECT current_release_id FROM channels", "") == rel_id,
|
||||
"db: channel points at the release");
|
||||
|
||||
db_chans := get_arr(dbo, "channels");
|
||||
process.assert(db_chans.len == 1, "db: one channel");
|
||||
ch := db_chans.items[0].object;
|
||||
process.assert(get_str(ch, "name") == "stable", "db: channel name");
|
||||
process.assert(get_str(ch, "current_release_id") == rel_id, "db: channel points at the release");
|
||||
|
||||
db_events := get_arr(dbo, "audit_events");
|
||||
process.assert(count_action(db_events, "artifact.upload") == 2, "db: one upload event per artifact");
|
||||
process.assert(count_action(db_events, "release.publish") == 1, "db: one publish event");
|
||||
process.assert(count_action(db_events, "channel.promote") == 1, "db: one promotion event");
|
||||
print(" db.json records release/artifacts/channel + {} audit events\n", db_events.len);
|
||||
process.assert(q_int("SELECT COUNT(*) FROM audit_events WHERE action = ?1", "artifact.upload") == 2,
|
||||
"db: one upload event per artifact");
|
||||
process.assert(q_int("SELECT COUNT(*) FROM audit_events WHERE action = ?1", "release.publish") == 1,
|
||||
"db: one publish event");
|
||||
process.assert(q_int("SELECT COUNT(*) FROM audit_events WHERE action = ?1", "channel.promote") == 1,
|
||||
"db: one promotion event");
|
||||
print(" dist.db records release/artifacts/channel + {} audit events\n",
|
||||
q_int("SELECT COUNT(*) FROM audit_events", ""));
|
||||
|
||||
process.run(concat("rm -rf ", STORE_REL));
|
||||
print("publish_happy: ALL CASES PASS\n");
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
// Regression for P3.4a-001 — `dist ci publish` must LOAD an existing
|
||||
// `<store>/db.json` before publishing, so separate CLI invocations SHARE
|
||||
// Regression for P3.4a-001 — `dist ci publish` must LOAD the existing
|
||||
// store database before publishing, so separate CLI invocations SHARE
|
||||
// state through the store (not start from an empty Repo and clobber it).
|
||||
//
|
||||
// Drives the BUILT `build/dist` binary (via `process.run`, like
|
||||
// publish_happy.sx) twice into ONE store and asserts cross-invocation
|
||||
// persistence:
|
||||
// persistence (store state queried from `<store>/dist.db` via the SQLite
|
||||
// bindings):
|
||||
//
|
||||
// 1. Publish version A (1.2.3) into a fresh store → db.json has the release.
|
||||
// 1. Publish version A (1.2.3) into a fresh store → the db has the release.
|
||||
// 2. Publish a DIFFERENT version B (1.2.4) of the SAME app into the SAME
|
||||
// store → exit 0, and db.json now records BOTH releases under ONE app
|
||||
// store → exit 0, and the db now records BOTH releases under ONE app
|
||||
// (the app is FOUND, not duplicated); the channel points at the latest
|
||||
// release B; both content-addressed objects exist.
|
||||
// 3. Re-publishing the SAME release id (A again) into the same store FAILS
|
||||
// (exit != 0 — the P2.3 integrity transaction rejects the duplicate
|
||||
// release id) and leaves db.json UNCHANGED (still two releases).
|
||||
// release id) and leaves the db UNCHANGED (still two releases).
|
||||
//
|
||||
// FAIL-BEFORE / PASS-AFTER: against the pre-fix publish (which never reads
|
||||
// db.json and so begins every invocation from an EMPTY Repo) step 2 CLOBBERS
|
||||
// A — db.json ends with one release, not two — and step 3 "succeeds" (exit 0)
|
||||
// and overwrites. Both assertions fail. After the load-then-merge fix they
|
||||
// pass. Fresh store per run.
|
||||
// prior state and so begins every invocation from an EMPTY Repo) step 2
|
||||
// CLOBBERS A — the db ends with one release, not two — and step 3 "succeeds"
|
||||
// (exit 0) and overwrites. Both assertions fail. After the load-then-merge
|
||||
// fix they pass. Fresh store per run.
|
||||
#import "modules/std.sx";
|
||||
#import "modules/std/json.sx";
|
||||
process :: #import "modules/std/process.sx";
|
||||
fs :: #import "modules/std/fs.sx";
|
||||
sq :: #import "../src/db/sqlite.sx";
|
||||
|
||||
STORE :: ".sx-tmp/publish_persist";
|
||||
MDIR :: ".sx-tmp/publish_persist_m";
|
||||
@@ -52,26 +54,64 @@ get_str :: (o: Object, key: string) -> string { return get(o, key).str; }
|
||||
get_obj :: (o: Object, key: string) -> Object { return get(o, key).object; }
|
||||
get_arr :: (o: Object, key: string) -> Array { return get(o, key).array; }
|
||||
|
||||
// Count audit events whose "action" equals `action`.
|
||||
count_action :: (events: Array, action: string) -> i64 {
|
||||
c : i64 = 0;
|
||||
i := 0;
|
||||
while i < events.len {
|
||||
eo := events.items[i].object;
|
||||
if get_str(eo, "action") == action { c += 1; }
|
||||
i += 1;
|
||||
}
|
||||
// Open the store database read-only (asserts it exists and opens).
|
||||
db_open_ro :: () -> sq.Sqlite {
|
||||
c, oe := sq.Sqlite.open_v2(path_join(STORE, "dist.db"), sq.SQLITE_OPEN_READONLY);
|
||||
process.assert(!oe, "dist.db must open as a SQLite database");
|
||||
c.busy_timeout(2000);
|
||||
return c;
|
||||
}
|
||||
|
||||
// True iff `releases` (a db.json array) contains a release with id `id`.
|
||||
has_release :: (releases: Array, id: string) -> bool {
|
||||
i := 0;
|
||||
while i < releases.len {
|
||||
if get_str(releases.items[i].object, "id") == id { return true; }
|
||||
i += 1;
|
||||
// One-row TEXT scalar with one optional text binding ("" = unbound).
|
||||
q_text :: (sql: string, p1: string) -> string {
|
||||
c := db_open_ro();
|
||||
st, pe := c.prepare(sql);
|
||||
process.assert(!pe, concat("prepare must succeed: ", sql));
|
||||
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
|
||||
rc, se := st.step();
|
||||
process.assert(!se, concat("step must succeed: ", sql));
|
||||
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
|
||||
out := st.column_text(0);
|
||||
st.finalize();
|
||||
c.close();
|
||||
return out;
|
||||
}
|
||||
|
||||
// One-row INTEGER scalar with one optional text binding ("" = unbound).
|
||||
q_int :: (sql: string, p1: string) -> i64 {
|
||||
c := db_open_ro();
|
||||
st, pe := c.prepare(sql);
|
||||
process.assert(!pe, concat("prepare must succeed: ", sql));
|
||||
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
|
||||
rc, se := st.step();
|
||||
process.assert(!se, concat("step must succeed: ", sql));
|
||||
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
|
||||
out := st.column_int64(0);
|
||||
st.finalize();
|
||||
c.close();
|
||||
return out;
|
||||
}
|
||||
|
||||
// True iff a release row with this id exists.
|
||||
has_release :: (id: string) -> bool {
|
||||
return q_int("SELECT COUNT(*) FROM releases WHERE id = ?1", id) == 1;
|
||||
}
|
||||
|
||||
// Every artifact's content-addressed object exists under `<store>/objects/`.
|
||||
assert_objects_exist :: () {
|
||||
c := db_open_ro();
|
||||
st, pe := c.prepare("SELECT sha256 FROM artifacts ORDER BY rowid");
|
||||
process.assert(!pe, "artifact digest query must prepare");
|
||||
while true {
|
||||
rc, se := st.step();
|
||||
process.assert(!se, "artifact digest query must step");
|
||||
if rc != sq.SQLITE_ROW { break; }
|
||||
sha := st.column_text(0);
|
||||
process.assert(fs.exists(path_join(STORE, concat("objects/", sha))),
|
||||
"after B: object exists at objects/<sha256>");
|
||||
}
|
||||
return false;
|
||||
st.finalize();
|
||||
c.close();
|
||||
}
|
||||
|
||||
// `build/dist ci publish` for `mpath` into the shared store, JSON mode,
|
||||
@@ -89,15 +129,6 @@ write_file :: (path: string, body: string) {
|
||||
process.run(cmd);
|
||||
}
|
||||
|
||||
// Parse `<STORE>/db.json` into its root object (re-read fresh each call).
|
||||
load_db :: (scratch: Allocator) -> Object {
|
||||
db_bytes := fs.read_file(path_join(STORE, "db.json"));
|
||||
process.assert(db_bytes != null, "db.json must exist under the store");
|
||||
dv, de := parse(db_bytes!, scratch);
|
||||
if de { process.assert(false, "db.json must be valid JSON"); dummy : Object = .{}; return dummy; }
|
||||
return dv.object;
|
||||
}
|
||||
|
||||
main :: () -> i32 {
|
||||
gpa := GPA.init();
|
||||
arena := Arena.init(xx gpa, 1 << 20);
|
||||
@@ -122,9 +153,8 @@ main :: () -> i32 {
|
||||
if ea { process.assert(false, "publish A stdout must be one JSON object"); return 1; }
|
||||
process.assert(get_str(get_obj(va.object, "release"), "id") == rel_a, "A release id");
|
||||
|
||||
db1 := load_db(xx arena);
|
||||
process.assert(get_arr(db1, "releases").len == 1, "after A: db has one release");
|
||||
process.assert(get_arr(db1, "apps").len == 1, "after A: db has one app");
|
||||
process.assert(q_int("SELECT COUNT(*) FROM releases", "") == 1, "after A: db has one release");
|
||||
process.assert(q_int("SELECT COUNT(*) FROM apps", "") == 1, "after A: db has one app");
|
||||
print(" A published; db has 1 release\n");
|
||||
|
||||
// ── 2. Publish a DIFFERENT version B into the SAME store ────────────
|
||||
@@ -136,34 +166,23 @@ main :: () -> i32 {
|
||||
if eb { process.assert(false, "publish B stdout must be one JSON object"); return 1; }
|
||||
process.assert(get_str(get_obj(vb.object, "release"), "id") == rel_b, "B release id");
|
||||
|
||||
db2 := load_db(xx arena);
|
||||
|
||||
// The crux: BOTH releases under ONE app (app found, not duplicated).
|
||||
db2_rels := get_arr(db2, "releases");
|
||||
process.assert(db2_rels.len == 2, "after B: db records BOTH releases (no clobber)");
|
||||
process.assert(has_release(db2_rels, rel_a), "after B: release A still present");
|
||||
process.assert(has_release(db2_rels, rel_b), "after B: release B present");
|
||||
process.assert(get_arr(db2, "apps").len == 1, "after B: still ONE app (found, not duplicated)");
|
||||
process.assert(q_int("SELECT COUNT(*) FROM releases", "") == 2, "after B: db records BOTH releases (no clobber)");
|
||||
process.assert(has_release(rel_a), "after B: release A still present");
|
||||
process.assert(has_release(rel_b), "after B: release B present");
|
||||
process.assert(q_int("SELECT COUNT(*) FROM apps", "") == 1, "after B: still ONE app (found, not duplicated)");
|
||||
|
||||
// Four artifacts (two per release); channel promoted to the latest (B).
|
||||
process.assert(get_arr(db2, "artifacts").len == 4, "after B: four artifacts (two per release)");
|
||||
db2_chans := get_arr(db2, "channels");
|
||||
process.assert(db2_chans.len == 1, "after B: one channel");
|
||||
process.assert(get_str(db2_chans.items[0].object, "current_release_id") == rel_b,
|
||||
process.assert(q_int("SELECT COUNT(*) FROM artifacts", "") == 4, "after B: four artifacts (two per release)");
|
||||
process.assert(q_int("SELECT COUNT(*) FROM channels", "") == 1, "after B: one channel");
|
||||
process.assert(q_text("SELECT current_release_id FROM channels", "") == rel_b,
|
||||
"after B: channel promoted to the latest release");
|
||||
|
||||
// Each artifact's content-addressed object exists on disk.
|
||||
db2_arts := get_arr(db2, "artifacts");
|
||||
k := 0;
|
||||
while k < db2_arts.len {
|
||||
sha := get_str(db2_arts.items[k].object, "sha256");
|
||||
process.assert(fs.exists(path_join(STORE, concat("objects/", sha))),
|
||||
"after B: object exists at objects/<sha256>");
|
||||
k += 1;
|
||||
}
|
||||
assert_objects_exist();
|
||||
|
||||
// Audit accumulated across both publishes (>= 2 publish events).
|
||||
process.assert(count_action(get_arr(db2, "audit_events"), "release.publish") == 2,
|
||||
// Audit accumulated across both publishes.
|
||||
process.assert(q_int("SELECT COUNT(*) FROM audit_events WHERE action = ?1", "release.publish") == 2,
|
||||
"after B: one publish event per release");
|
||||
print(" B accumulated; db has 2 releases under 1 app\n");
|
||||
|
||||
@@ -173,9 +192,8 @@ main :: () -> i32 {
|
||||
res_dup := rdup!;
|
||||
process.assert(res_dup.exit_code != 0, "re-publishing the same release id must FAIL (duplicate)");
|
||||
|
||||
db3 := load_db(xx arena);
|
||||
process.assert(get_arr(db3, "releases").len == 2, "after duplicate: db UNCHANGED (still two releases)");
|
||||
process.assert(get_arr(db3, "apps").len == 1, "after duplicate: still one app");
|
||||
process.assert(q_int("SELECT COUNT(*) FROM releases", "") == 2, "after duplicate: db UNCHANGED (still two releases)");
|
||||
process.assert(q_int("SELECT COUNT(*) FROM apps", "") == 1, "after duplicate: still one app");
|
||||
print(" duplicate release id rejected; db unchanged\n");
|
||||
|
||||
process.run(concat("rm -rf ", STORE));
|
||||
|
||||
@@ -7,21 +7,25 @@
|
||||
// 1. Publish release A (1.2.3, channel beta) then B (1.2.4, beta) into
|
||||
// one store → beta points at B.
|
||||
// 2. `release rollback --app --channel beta` → exit 0, JSON
|
||||
// `rolled_back` from B to A; db.json's beta points at A; a
|
||||
// `rolled_back` from B to A; the store's beta channel points at A; a
|
||||
// `channel.rollback` audit event (actor "cli") is recorded.
|
||||
// 3. `release promote --release <B>` → exit 0, JSON `promoted` with
|
||||
// previous_release_id A; beta points at B again; a `channel.promote`
|
||||
// audit event by actor "cli" is recorded (distinct from the publish
|
||||
// pipeline's "ci" promote events).
|
||||
// 4. Promoting an UNKNOWN release id → exit 1 + JSON error
|
||||
// (`promote.unknown_release`); db.json unchanged (beta still → B).
|
||||
// (`promote.unknown_release`); the store unchanged (beta still → B).
|
||||
// 5. Rollback again (B → A), then rollback at the EARLIEST release →
|
||||
// exit 1 + `rollback.no_previous`; beta still → A. A failed op never
|
||||
// moves the pointer.
|
||||
//
|
||||
// Store-side state is asserted by QUERYING `<store>/dist.db` through the
|
||||
// SQLite bindings.
|
||||
#import "modules/std.sx";
|
||||
#import "modules/std/json.sx";
|
||||
process :: #import "modules/std/process.sx";
|
||||
fs :: #import "modules/std/fs.sx";
|
||||
sq :: #import "../src/db/sqlite.sx";
|
||||
|
||||
STORE :: ".sx-tmp/release_ops";
|
||||
MDIR :: ".sx-tmp/release_ops_m";
|
||||
@@ -46,38 +50,55 @@ get_str :: (o: Object, key: string) -> string { return get(o, key).str; }
|
||||
get_obj :: (o: Object, key: string) -> Object { return get(o, key).object; }
|
||||
get_arr :: (o: Object, key: string) -> Array { return get(o, key).array; }
|
||||
|
||||
// Count audit events matching (actor, action) — distinguishes the CLI's
|
||||
// channel events from the publish pipeline's "ci" ones.
|
||||
count_actor_action :: (events: Array, actor: string, action: string) -> i64 {
|
||||
c : i64 = 0;
|
||||
i := 0;
|
||||
while i < events.len {
|
||||
eo := events.items[i].object;
|
||||
if get_str(eo, "actor") == actor and get_str(eo, "action") == action { c += 1; }
|
||||
i += 1;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
write_file :: (path: string, body: string) {
|
||||
cmd := concat(concat(concat("printf '%s' '", body), "' > "), path);
|
||||
process.run(cmd);
|
||||
}
|
||||
|
||||
load_db :: (scratch: Allocator) -> Object {
|
||||
db_bytes := fs.read_file(path_join(STORE, "db.json"));
|
||||
process.assert(db_bytes != null, "db.json must exist under the store");
|
||||
dv, de := parse(db_bytes!, scratch);
|
||||
if de { process.assert(false, "db.json must be valid JSON"); dummy : Object = .{}; return dummy; }
|
||||
return dv.object;
|
||||
// Open the store database read-only (asserts it exists and opens).
|
||||
db_open_ro :: () -> sq.Sqlite {
|
||||
c, oe := sq.Sqlite.open_v2(path_join(STORE, "dist.db"), sq.SQLITE_OPEN_READONLY);
|
||||
process.assert(!oe, "dist.db must open as a SQLite database");
|
||||
c.busy_timeout(2000);
|
||||
return c;
|
||||
}
|
||||
|
||||
// The beta channel's current_release_id, read fresh from db.json.
|
||||
beta_pointer :: (scratch: Allocator) -> string {
|
||||
dbo := load_db(scratch);
|
||||
chans := get_arr(dbo, "channels");
|
||||
process.assert(chans.len == 1, "store has exactly one channel");
|
||||
return get_str(chans.items[0].object, "current_release_id");
|
||||
// One-row TEXT scalar with up to two text bindings ("" = unbound).
|
||||
q_text :: (sql: string, p1: string, p2: string) -> string {
|
||||
c := db_open_ro();
|
||||
st, pe := c.prepare(sql);
|
||||
process.assert(!pe, concat("prepare must succeed: ", sql));
|
||||
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
|
||||
if p2.len > 0 { st.bind_text(2, p2) catch { process.assert(false, "bind 2 failed"); }; }
|
||||
rc, se := st.step();
|
||||
process.assert(!se, concat("step must succeed: ", sql));
|
||||
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
|
||||
out := st.column_text(0);
|
||||
st.finalize();
|
||||
c.close();
|
||||
return out;
|
||||
}
|
||||
|
||||
// One-row INTEGER scalar with up to two text bindings ("" = unbound).
|
||||
q_int :: (sql: string, p1: string, p2: string) -> i64 {
|
||||
c := db_open_ro();
|
||||
st, pe := c.prepare(sql);
|
||||
process.assert(!pe, concat("prepare must succeed: ", sql));
|
||||
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
|
||||
if p2.len > 0 { st.bind_text(2, p2) catch { process.assert(false, "bind 2 failed"); }; }
|
||||
rc, se := st.step();
|
||||
process.assert(!se, concat("step must succeed: ", sql));
|
||||
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
|
||||
out := st.column_int64(0);
|
||||
st.finalize();
|
||||
c.close();
|
||||
return out;
|
||||
}
|
||||
|
||||
// The beta channel's current_release_id, read fresh from dist.db.
|
||||
beta_pointer :: () -> string {
|
||||
process.assert(q_int("SELECT COUNT(*) FROM channels", "", "") == 1, "store has exactly one channel");
|
||||
return q_text("SELECT current_release_id FROM channels", "", "");
|
||||
}
|
||||
|
||||
publish_cmd :: (mpath: string) -> string {
|
||||
@@ -110,7 +131,7 @@ main :: () -> i32 {
|
||||
process.assert(ra != null and ra!.exit_code == 0, "publish A must exit 0");
|
||||
rb := process.run(publish_cmd(path_join(MDIR, "b.json")));
|
||||
process.assert(rb != null and rb!.exit_code == 0, "publish B must exit 0");
|
||||
process.assert(beta_pointer(xx arena) == REL_B, "after publishes: beta -> B");
|
||||
process.assert(beta_pointer() == REL_B, "after publishes: beta -> B");
|
||||
print(" published A then B; beta -> B\n");
|
||||
|
||||
// ── 2. Rollback: beta moves B -> A, audited ──────────────────────
|
||||
@@ -123,9 +144,8 @@ main :: () -> i32 {
|
||||
process.assert(get_str(ro, "status") == "rolled_back", "rollback json status");
|
||||
process.assert(get_str(ro, "from_release_id") == REL_B, "rollback json from B");
|
||||
process.assert(get_str(get_obj(ro, "to"), "id") == REL_A, "rollback json to A");
|
||||
process.assert(beta_pointer(xx arena) == REL_A, "after rollback: beta -> A");
|
||||
db2 := load_db(xx arena);
|
||||
process.assert(count_actor_action(get_arr(db2, "audit_events"), "cli", "channel.rollback") == 1,
|
||||
process.assert(beta_pointer() == REL_A, "after rollback: beta -> A");
|
||||
process.assert(q_int("SELECT COUNT(*) FROM audit_events WHERE actor = ?1 AND action = ?2", "cli", "channel.rollback") == 1,
|
||||
"rollback recorded one cli channel.rollback audit event");
|
||||
print(" rollback: beta B -> A, audited\n");
|
||||
|
||||
@@ -139,9 +159,8 @@ main :: () -> i32 {
|
||||
process.assert(get_str(po, "status") == "promoted", "promote json status");
|
||||
process.assert(get_str(get_obj(po, "release"), "id") == REL_B, "promote json release B");
|
||||
process.assert(get_str(po, "previous_release_id") == REL_A, "promote json previous A");
|
||||
process.assert(beta_pointer(xx arena) == REL_B, "after promote: beta -> B");
|
||||
db3 := load_db(xx arena);
|
||||
process.assert(count_actor_action(get_arr(db3, "audit_events"), "cli", "channel.promote") == 1,
|
||||
process.assert(beta_pointer() == REL_B, "after promote: beta -> B");
|
||||
process.assert(q_int("SELECT COUNT(*) FROM audit_events WHERE actor = ?1 AND action = ?2", "cli", "channel.promote") == 1,
|
||||
"promote recorded one cli channel.promote audit event");
|
||||
print(" promote: beta -> B (was A), audited\n");
|
||||
|
||||
@@ -156,13 +175,13 @@ main :: () -> i32 {
|
||||
process.assert(get_str(no, "status") == "error", "promote-unknown json status error");
|
||||
process.assert(get_str(get_obj(no, "error"), "code") == "promote.unknown_release",
|
||||
"promote-unknown json names the code");
|
||||
process.assert(beta_pointer(xx arena) == REL_B, "after failed promote: beta unchanged (-> B)");
|
||||
process.assert(beta_pointer() == REL_B, "after failed promote: beta unchanged (-> B)");
|
||||
print(" promote unknown release: exit 1 + JSON error, beta unchanged\n");
|
||||
|
||||
// ── 5. Rollback to the earliest, then once more → no_previous ────
|
||||
r2 := process.run(ROLLBACK_CMD);
|
||||
process.assert(r2 != null and r2!.exit_code == 0, "second rollback must exit 0 (B -> A)");
|
||||
process.assert(beta_pointer(xx arena) == REL_A, "after second rollback: beta -> A");
|
||||
process.assert(beta_pointer() == REL_A, "after second rollback: beta -> A");
|
||||
|
||||
r3 := process.run(ROLLBACK_CMD);
|
||||
process.assert(r3 != null, "spawn third rollback failed");
|
||||
@@ -172,7 +191,7 @@ main :: () -> i32 {
|
||||
o3 := v3.object;
|
||||
process.assert(get_str(get_obj(o3, "error"), "code") == "rollback.no_previous",
|
||||
"no-previous json names the code");
|
||||
process.assert(beta_pointer(xx arena) == REL_A, "after failed rollback: beta unchanged (-> A)");
|
||||
process.assert(beta_pointer() == REL_A, "after failed rollback: beta unchanged (-> A)");
|
||||
print(" rollback at earliest: exit 1 + no_previous, beta unchanged\n");
|
||||
|
||||
process.run(concat("rm -rf ", STORE));
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
// * happy path: exit 0; stdout is the publish JSON (status published,
|
||||
// release id, per-artifact sha256 equal to an independent digest of
|
||||
// the fixture); the SERVER's store gained objects/<sha>, the release,
|
||||
// the channel pointer, and `token:<name>` audit actors.
|
||||
// the channel pointer, and `token:<name>` audit actors (store state
|
||||
// queried from `<store>/dist.db` via the SQLite bindings).
|
||||
// * duplicate version: exit 1, the server's transaction.integrity code
|
||||
// passed through verbatim.
|
||||
// * wrong secret: exit 1, auth.unknown_token.
|
||||
@@ -21,6 +22,7 @@
|
||||
process :: #import "modules/std/process.sx";
|
||||
fs :: #import "modules/std/fs.sx";
|
||||
hash :: #import "modules/std/hash.sx";
|
||||
sq :: #import "../src/db/sqlite.sx";
|
||||
|
||||
STORE :: ".sx-tmp/remote_publish";
|
||||
MDIR :: ".sx-tmp/remote_publish_m";
|
||||
@@ -54,6 +56,42 @@ parse_body :: (body: string, what: string, scratch: Allocator) -> Object {
|
||||
return v.object;
|
||||
}
|
||||
|
||||
// One-row scalar queries over `<STORE>/dist.db` ("" = unbound binding).
|
||||
db_open_ro :: () -> sq.Sqlite {
|
||||
c, oe := sq.Sqlite.open_v2(path_join(STORE, "dist.db"), sq.SQLITE_OPEN_READONLY);
|
||||
process.assert(!oe, "dist.db must open as a SQLite database");
|
||||
c.busy_timeout(2000);
|
||||
return c;
|
||||
}
|
||||
|
||||
q_text :: (sql: string, p1: string) -> string {
|
||||
c := db_open_ro();
|
||||
st, pe := c.prepare(sql);
|
||||
process.assert(!pe, concat("prepare must succeed: ", sql));
|
||||
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
|
||||
rc, se := st.step();
|
||||
process.assert(!se, concat("step must succeed: ", sql));
|
||||
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
|
||||
out := st.column_text(0);
|
||||
st.finalize();
|
||||
c.close();
|
||||
return out;
|
||||
}
|
||||
|
||||
q_int :: (sql: string, p1: string) -> i64 {
|
||||
c := db_open_ro();
|
||||
st, pe := c.prepare(sql);
|
||||
process.assert(!pe, concat("prepare must succeed: ", sql));
|
||||
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
|
||||
rc, se := st.step();
|
||||
process.assert(!se, concat("step must succeed: ", sql));
|
||||
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
|
||||
out := st.column_int64(0);
|
||||
st.finalize();
|
||||
c.close();
|
||||
return out;
|
||||
}
|
||||
|
||||
// Run a remote publish with `server` and `token`; returns the run result.
|
||||
remote_publish :: (server: string, token: string) -> ?process.ProcessResult {
|
||||
cmd := concat("build/dist ci publish --manifest ", path_join(MDIR, "m.json"));
|
||||
@@ -124,14 +162,11 @@ main :: () -> i32 {
|
||||
|
||||
process.assert(fs.exists(path_join(STORE, concat("objects/", expect_sha))),
|
||||
"the SERVER's store holds the uploaded object");
|
||||
dbo := parse_body(fs.read_file(path_join(STORE, "db.json"))!, "db.json", xx arena);
|
||||
rels := get_arr(dbo, "releases");
|
||||
process.assert(rels.len == 1, "server db records the release");
|
||||
process.assert(get_str(rels.items[0].object, "created_by") == "token:ci-remote",
|
||||
process.assert(q_int("SELECT COUNT(*) FROM releases", "") == 1, "server db records the release");
|
||||
process.assert(q_text("SELECT created_by FROM releases", "") == "token:ci-remote",
|
||||
"release created_by carries the token actor");
|
||||
chans := get_arr(dbo, "channels");
|
||||
process.assert(chans.len == 1, "server db records the channel");
|
||||
process.assert(get_str(chans.items[0].object, "current_release_id") == "rel-acme-app-2.0.0",
|
||||
process.assert(q_int("SELECT COUNT(*) FROM channels", "") == 1, "server db records the channel");
|
||||
process.assert(q_text("SELECT current_release_id FROM channels", "") == "rel-acme-app-2.0.0",
|
||||
"beta points at the published release");
|
||||
print(" remote publish: CI contract round trip ok\n");
|
||||
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
// Acceptance for P2.3 (round-trip) — the in-memory repository persisted to
|
||||
// `<root>/db.json` via `std.json` and reloaded into a FRESH repository.
|
||||
// Acceptance for P5.2 (round-trip) — the in-memory repository persisted to
|
||||
// `<root>/dist.db` via the vendored SQLite and reloaded into a FRESH
|
||||
// repository.
|
||||
//
|
||||
// Asserts:
|
||||
// 1. Every entity (app + bundle ids, release, two artifacts, channel,
|
||||
// audit event) survives save -> reload field-for-field.
|
||||
// 2. db.json is valid JSON, re-parseable by `std.json` (re-parse it).
|
||||
// 3. Re-saving the reloaded repo yields BYTE-IDENTICAL db.json — the
|
||||
// stable (insertion-order) key-order guarantee.
|
||||
// 2. The store file is a real SQLite database: dist.db exists (and no
|
||||
// db.json is written), and an independent SQL query over it sees the
|
||||
// saved rows.
|
||||
// 3. Re-saving the reloaded repo into a second root reloads equal again
|
||||
// (save -> load is idempotent on the model).
|
||||
// Uses a fresh `<root>` under `.sx-tmp/` and cleans up. Exits 0 only if
|
||||
// every assertion holds (process.assert aborts otherwise).
|
||||
#import "modules/std.sx";
|
||||
#import "modules/std/json.sx";
|
||||
#import "modules/std/fs.sx";
|
||||
process :: #import "modules/std/process.sx";
|
||||
sq :: #import "../src/db/sqlite.sx";
|
||||
#import "../src/domain/platform.sx";
|
||||
#import "../src/domain/app.sx";
|
||||
#import "../src/domain/release.sx";
|
||||
@@ -143,18 +146,20 @@ main :: () -> i32 {
|
||||
serr := false;
|
||||
save(repo, root) catch { serr = true; };
|
||||
process.assert(!serr, "save must succeed");
|
||||
process.assert(fs.exists(path_join(root, "db.json")), "db.json must exist after save");
|
||||
process.assert(fs.exists(path_join(root, "dist.db")), "dist.db must exist after save");
|
||||
process.assert(!fs.exists(path_join(root, "db.json")), "save must not write a db.json");
|
||||
|
||||
// ── 2. db.json is valid JSON re-parseable by std.json ────────────
|
||||
raw := fs.read_file(path_join(root, "db.json"));
|
||||
process.assert(raw != null, "db.json must be readable");
|
||||
bytes := raw!;
|
||||
gpa := GPA.init();
|
||||
arena := Arena.init(xx gpa, 65536);
|
||||
_, perr := parse(bytes, xx arena);
|
||||
process.assert(!perr, "db.json must be valid JSON re-parseable by std.json");
|
||||
arena.deinit();
|
||||
print(" db.json is valid JSON ({} bytes)\n", bytes.len);
|
||||
// ── 2. dist.db is a real SQLite database (independent SQL read) ──
|
||||
conn, oe := sq.Sqlite.open_v2(path_join(root, "dist.db"), sq.SQLITE_OPEN_READONLY);
|
||||
process.assert(!oe, "dist.db must open as a SQLite database");
|
||||
st, pe := conn.prepare("SELECT COUNT(*) FROM artifacts");
|
||||
process.assert(!pe, "artifacts must be queryable");
|
||||
rc, se := st.step();
|
||||
process.assert(!se, "count query must step");
|
||||
process.assert(rc == sq.SQLITE_ROW and st.column_int64(0) == 2, "SQL sees both artifact rows");
|
||||
st.finalize();
|
||||
conn.close();
|
||||
print(" dist.db is a queryable SQLite database\n");
|
||||
|
||||
// ── 1. Reload into a FRESH repo and compare field-for-field ──────
|
||||
repo2, lerr := load(root);
|
||||
@@ -192,14 +197,19 @@ main :: () -> i32 {
|
||||
process.assert(sga.id == "app_01", "slug lookup returns the right app");
|
||||
print(" reloaded model equals original (every field)\n");
|
||||
|
||||
// ── 3. Re-save the reloaded repo -> byte-identical db.json ───────
|
||||
// ── 3. Re-save the reloaded repo -> reloads equal again ──────────
|
||||
serr2 := false;
|
||||
save(repo2, root2) catch { serr2 = true; };
|
||||
process.assert(!serr2, "re-save must succeed");
|
||||
raw2 := fs.read_file(path_join(root2, "db.json"));
|
||||
process.assert(raw2 != null, "re-saved db.json must be readable");
|
||||
process.assert(raw2! == bytes, "re-save is byte-identical (stable key order)");
|
||||
print(" re-save is byte-identical (stable key order)\n");
|
||||
repo3, lerr2 := load(root2);
|
||||
if lerr2 { process.assert(false, "re-load must succeed"); return 1; }
|
||||
process.assert(repo3.apps.len == 1 and app_eq(repo3.apps.items[0], app0), "app survives second round-trip");
|
||||
process.assert(repo3.releases.len == 1 and release_eq(repo3.releases.items[0], rel0), "release survives second round-trip");
|
||||
process.assert(repo3.artifacts.len == 2 and artifact_eq(repo3.artifacts.items[0], apk0)
|
||||
and artifact_eq(repo3.artifacts.items[1], ipa0), "artifacts survive second round-trip in order");
|
||||
process.assert(repo3.channels.len == 1 and channel_eq(repo3.channels.items[0], chan0), "channel survives second round-trip");
|
||||
process.assert(repo3.audit_events.len == 1 and audit_eq(repo3.audit_events.items[0], ev0), "audit event survives second round-trip");
|
||||
print(" save -> load is idempotent on the model\n");
|
||||
|
||||
// ── cleanup ──────────────────────────────────────────────────────
|
||||
process.run(concat("rm -rf ", root));
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
// (artifact.app_id != release.app_id), (6) channel-NAME mismatch
|
||||
// (chan.name != release.channel), (7) release-id collision
|
||||
// (release.id already exists).
|
||||
// 8. Persist + reload: the rolled-back state is what hits db.json — the
|
||||
// 8. Persist + reload: the rolled-back state is what hits the store — the
|
||||
// reloaded channel still points at rel_00 and the attempted releases
|
||||
// are absent.
|
||||
// Uses a fresh `<root>` under `.sx-tmp/` and cleans up.
|
||||
@@ -250,7 +250,7 @@ main :: () -> i32 {
|
||||
rcv := rc!;
|
||||
process.assert(rcv.current_release_id == "rel_00", "persisted: channel points at a release that exists");
|
||||
process.assert(repo2.get_release(rcv.current_release_id) != null, "persisted: channel target is a real release (no dangling)");
|
||||
print(" persisted db.json reflects the rolled-back state\n");
|
||||
print(" persisted store reflects the rolled-back state\n");
|
||||
|
||||
process.run(concat("rm -rf ", root));
|
||||
print("repo_transaction: ALL CASES PASS\n");
|
||||
|
||||
@@ -16,12 +16,13 @@
|
||||
// * any other path → JSON error http.not_found
|
||||
//
|
||||
// FRESHNESS is asserted by publishing a SECOND version while the server
|
||||
// is running: the next /api/apps/<slug> must list both releases (db.json
|
||||
// is reloaded per request — no stale cache).
|
||||
// is running: the next /api/apps/<slug> must list both releases (the
|
||||
// store database is reloaded per request — no stale cache).
|
||||
#import "modules/std.sx";
|
||||
#import "modules/std/json.sx";
|
||||
process :: #import "modules/std/process.sx";
|
||||
fs :: #import "modules/std/fs.sx";
|
||||
sq :: #import "../src/db/sqlite.sx";
|
||||
|
||||
STORE :: ".sx-tmp/server_http";
|
||||
MDIR :: ".sx-tmp/server_http_m";
|
||||
@@ -162,10 +163,17 @@ main :: () -> i32 {
|
||||
print(" api routes ok\n");
|
||||
|
||||
// ── download: bytes identical to the source fixture ───────────────
|
||||
db_bytes := fs.read_file(path_join(STORE, "db.json"));
|
||||
process.assert(db_bytes != null, "db.json must exist");
|
||||
dbo := parse_body(db_bytes!, "db.json", xx arena);
|
||||
sha := get_str(get_arr(dbo, "artifacts").items[0].object, "sha256");
|
||||
// The published artifact's digest, read from the store database.
|
||||
conn, oe := sq.Sqlite.open_v2(path_join(STORE, "dist.db"), sq.SQLITE_OPEN_READONLY);
|
||||
process.assert(!oe, "dist.db must open as a SQLite database");
|
||||
conn.busy_timeout(2000);
|
||||
stq, pe := conn.prepare("SELECT sha256 FROM artifacts ORDER BY rowid");
|
||||
process.assert(!pe, "artifact digest query must prepare");
|
||||
src, se := stq.step();
|
||||
process.assert(!se and src == sq.SQLITE_ROW, "store must record the artifact");
|
||||
sha := stq.column_text(0);
|
||||
stq.finalize();
|
||||
conn.close();
|
||||
|
||||
dl := process.run(concat(concat(concat(concat("curl -s -m 2 -o .sx-tmp/server_http_dl.bin ", BASE), "/download/"), sha), " && cmp -s .sx-tmp/server_http_dl.bin examples/fixtures/acme-1.2.3-android.apk && echo SAME"));
|
||||
process.assert(dl != null, "download curl spawn failed");
|
||||
|
||||
@@ -22,11 +22,15 @@
|
||||
// * channel: promote / rollback move the pointer exactly like their
|
||||
// CLI twins; unknown release id is 404.
|
||||
// * methods: anything but GET/POST is 405.
|
||||
//
|
||||
// Store-side state is asserted by QUERYING `<store>/dist.db` through the
|
||||
// SQLite bindings.
|
||||
#import "modules/std.sx";
|
||||
#import "modules/std/json.sx";
|
||||
process :: #import "modules/std/process.sx";
|
||||
fs :: #import "modules/std/fs.sx";
|
||||
hash :: #import "modules/std/hash.sx";
|
||||
sq :: #import "../src/db/sqlite.sx";
|
||||
|
||||
STORE :: ".sx-tmp/server_write";
|
||||
PORT :: "18793";
|
||||
@@ -103,35 +107,42 @@ err_code :: (body: string, what: string, scratch: Allocator) -> string {
|
||||
return get_str(get_obj(parse_body(body, what, scratch), "error"), "code");
|
||||
}
|
||||
|
||||
load_db :: (scratch: Allocator) -> Object {
|
||||
b := fs.read_file(path_join(STORE, "db.json"));
|
||||
process.assert(b != null, "db.json must exist under the store");
|
||||
return parse_body(b!, "db.json", scratch);
|
||||
// Open the store database read-only (asserts it exists and opens).
|
||||
db_open_ro :: () -> sq.Sqlite {
|
||||
c, oe := sq.Sqlite.open_v2(path_join(STORE, "dist.db"), sq.SQLITE_OPEN_READONLY);
|
||||
process.assert(!oe, "dist.db must open as a SQLite database");
|
||||
c.busy_timeout(2000);
|
||||
return c;
|
||||
}
|
||||
|
||||
// current_release_id of channel `name`, "" when the channel doesn't exist.
|
||||
channel_pointer :: (name: string, scratch: Allocator) -> string {
|
||||
chans := get_arr(load_db(scratch), "channels");
|
||||
i := 0;
|
||||
while i < chans.len {
|
||||
co := chans.items[i].object;
|
||||
if get_str(co, "name") == name { return get_str(co, "current_release_id"); }
|
||||
i += 1;
|
||||
}
|
||||
return "";
|
||||
channel_pointer :: (name: string) -> string {
|
||||
c := db_open_ro();
|
||||
st, pe := c.prepare("SELECT current_release_id FROM channels WHERE name = ?1");
|
||||
process.assert(!pe, "channel query must prepare");
|
||||
st.bind_text(1, name) catch { process.assert(false, "channel bind failed"); };
|
||||
rc, se := st.step();
|
||||
process.assert(!se, "channel query must step");
|
||||
out := "";
|
||||
if rc == sq.SQLITE_ROW { out = st.column_text(0); }
|
||||
st.finalize();
|
||||
c.close();
|
||||
return out;
|
||||
}
|
||||
|
||||
// last_used_at of the token named `name`.
|
||||
token_last_used :: (name: string, scratch: Allocator) -> i64 {
|
||||
toks := get_arr(load_db(scratch), "tokens");
|
||||
i := 0;
|
||||
while i < toks.len {
|
||||
to := toks.items[i].object;
|
||||
if get_str(to, "name") == name { return get_int(to, "last_used_at"); }
|
||||
i += 1;
|
||||
}
|
||||
process.assert(false, concat("token not in db.json: ", name));
|
||||
return -1;
|
||||
// last_used_at of the token named `name` (asserts the token exists).
|
||||
token_last_used :: (name: string) -> i64 {
|
||||
c := db_open_ro();
|
||||
st, pe := c.prepare("SELECT last_used_at FROM tokens WHERE name = ?1");
|
||||
process.assert(!pe, "token query must prepare");
|
||||
st.bind_text(1, name) catch { process.assert(false, "token bind failed"); };
|
||||
rc, se := st.step();
|
||||
process.assert(!se, "token query must step");
|
||||
process.assert(rc == sq.SQLITE_ROW, concat("token not in the store: ", name));
|
||||
out := st.column_int64(0);
|
||||
st.finalize();
|
||||
c.close();
|
||||
return out;
|
||||
}
|
||||
|
||||
// JSON body for a release POST referencing `sha` (single android artifact).
|
||||
@@ -151,7 +162,7 @@ main :: () -> i32 {
|
||||
process.run("pkill -f 'dist server run --local-store .sx-tmp/server_write' 2>/dev/null");
|
||||
process.run(concat("rm -rf ", STORE));
|
||||
|
||||
// ── tokens (this also creates db.json on the fresh store) ─────────
|
||||
// ── tokens (this also creates dist.db on the fresh store) ─────────
|
||||
publisher := mint("--name publisher", xx arena);
|
||||
reader := mint("--name reader --scope read", xx arena);
|
||||
wrong_app := mint("--name wrong-app --app other-app", xx arena);
|
||||
@@ -228,7 +239,7 @@ main :: () -> i32 {
|
||||
rb := parse_body(post("/api/apps/acme-app/releases", publisher, release_body("1.2.3", "beta", sha)), "release", xx arena);
|
||||
process.assert(get_str(rb, "status") == "published", "release json status published");
|
||||
process.assert(get_str(get_obj(rb, "release"), "id") == "rel-acme-app-1.2.3", "release id");
|
||||
process.assert(channel_pointer("beta", xx arena) == "rel-acme-app-1.2.3", "beta points at the new release");
|
||||
process.assert(channel_pointer("beta") == "rel-acme-app-1.2.3", "beta points at the new release");
|
||||
|
||||
det := parse_body(fetch("/api/apps/acme-app"), "detail", xx arena);
|
||||
process.assert(get_arr(det, "releases").len == 1, "GET reflects the POSTed release (per-request reload)");
|
||||
@@ -243,7 +254,7 @@ main :: () -> i32 {
|
||||
"unknown object is 404");
|
||||
process.assert(err_code(post("/api/apps/acme-app/releases", publisher, release_body("9.9.9", "nightly", UNKNOWN)), "unknown-object body", xx arena) == "api.unknown_object",
|
||||
"unknown object names api.unknown_object");
|
||||
process.assert(channel_pointer("nightly", xx arena) == "", "aborted publish leaves no channel behind");
|
||||
process.assert(channel_pointer("nightly") == "", "aborted publish leaves no channel behind");
|
||||
|
||||
process.assert(post_code("/api/apps/acme-app/releases", publisher, release_body("1.2.3", "beta", sha)) == "409",
|
||||
"duplicate release id is 409");
|
||||
@@ -252,23 +263,23 @@ main :: () -> i32 {
|
||||
// ── channel ops mirror the CLI ─────────────────────────────────────
|
||||
p2 := parse_body(post("/api/apps/acme-app/releases", publisher, release_body("1.2.4", "beta", sha)), "release B", xx arena);
|
||||
process.assert(get_str(p2, "status") == "published", "second release published");
|
||||
process.assert(channel_pointer("beta", xx arena) == "rel-acme-app-1.2.4", "beta -> 1.2.4");
|
||||
process.assert(channel_pointer("beta") == "rel-acme-app-1.2.4", "beta -> 1.2.4");
|
||||
|
||||
rbk := parse_body(post("/api/apps/acme-app/channels/beta/rollback", publisher, "-d ''"), "rollback", xx arena);
|
||||
process.assert(get_str(rbk, "status") == "rolled_back", "rollback json status");
|
||||
process.assert(channel_pointer("beta", xx arena) == "rel-acme-app-1.2.3", "rollback moved beta back");
|
||||
process.assert(channel_pointer("beta") == "rel-acme-app-1.2.3", "rollback moved beta back");
|
||||
|
||||
pm := parse_body(post("/api/apps/acme-app/channels/beta/promote", publisher, "-d '{\"release_id\":\"rel-acme-app-1.2.4\"}'"), "promote", xx arena);
|
||||
process.assert(get_str(pm, "status") == "promoted", "promote json status");
|
||||
process.assert(channel_pointer("beta", xx arena) == "rel-acme-app-1.2.4", "promote moved beta forward");
|
||||
process.assert(channel_pointer("beta") == "rel-acme-app-1.2.4", "promote moved beta forward");
|
||||
|
||||
process.assert(post_code("/api/apps/acme-app/channels/beta/promote", publisher, "-d '{\"release_id\":\"rel-nope\"}'") == "404",
|
||||
"promoting an unknown release is 404");
|
||||
print(" channel ops: promote/rollback mirror the CLI\n");
|
||||
|
||||
// ── last-used stamping + method gate ───────────────────────────────
|
||||
process.assert(token_last_used("publisher", xx arena) > 0, "publisher token got last_used_at stamped");
|
||||
process.assert(token_last_used("reader", xx arena) == 0, "refused token is never stamped");
|
||||
process.assert(token_last_used("publisher") > 0, "publisher token got last_used_at stamped");
|
||||
process.assert(token_last_used("reader") == 0, "refused token is never stamped");
|
||||
|
||||
mc := process.run(concat(concat("curl -s -m 2 -o /dev/null -w '%{http_code}' -X DELETE ", BASE), "/healthz"));
|
||||
process.assert(mc != null and mc!.stdout == "405", "non-GET/POST method is 405");
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
// scope match, and the unscoped-matches-anything rules.
|
||||
// * validate_token — each invalid form rejected with its SPECIFIC tag.
|
||||
// * mark_token_used — last-used stamping through the repo.
|
||||
// * db.json round trip — tokens persist field-for-field, and an absent
|
||||
// `tokens` member (a db.json from before tokens existed) loads as zero
|
||||
// * store round trip — tokens persist field-for-field, and an absent
|
||||
// `tokens` member (an import-path db.json from before tokens existed) loads as zero
|
||||
// tokens instead of BadShape.
|
||||
#import "modules/std.sx";
|
||||
#import "../src/domain/platform.sx";
|
||||
@@ -213,7 +213,7 @@ tok_mark_used_shim :: (repo: *Repo, id: string, now: i64) -> bool {
|
||||
|
||||
// ── persistence ──────────────────────────────────────────────────────
|
||||
|
||||
// db.json with no `tokens` member loads as zero tokens (compat with
|
||||
// An import-path db.json with no `tokens` member loads as zero tokens (compat with
|
||||
// pre-token layouts), not BadShape.
|
||||
NO_TOKENS_DB :: "{\"apps\":[],\"releases\":[],\"artifacts\":[],\"channels\":[],\"audit_events\":[]}";
|
||||
|
||||
|
||||
@@ -3,24 +3,28 @@
|
||||
//
|
||||
// Drives the BUILT `build/dist` binary through the slice contract:
|
||||
//
|
||||
// 1. `token create` on a FRESH store (no db.json yet) → exit 0; the JSON
|
||||
// carries the raw secret (`dist_` + 64 hex) exactly once; db.json
|
||||
// 1. `token create` on a FRESH store (no database yet) → exit 0; the JSON
|
||||
// carries the raw secret (`dist_` + 64 hex) exactly once; dist.db
|
||||
// stores ONLY sha256(secret) — the secret's bytes appear nowhere under
|
||||
// the store — plus a `token.create` audit event.
|
||||
// 2. A second token with `--scope read --expires-in 60` → expires_at is
|
||||
// created_at + 60.
|
||||
// 3. `token list` → both tokens "active"; no secret in the output.
|
||||
// 4. `token revoke` → exit 0; db.json gains revoked_at + a `token.revoke`
|
||||
// 4. `token revoke` → exit 0; dist.db gains revoked_at + a `token.revoke`
|
||||
// audit event; list now shows "revoked".
|
||||
// 5. Revoking an UNKNOWN id → exit 1 + `token.unknown`; revoking AGAIN →
|
||||
// exit 1 + `token.already_revoked`; both leave db.json unchanged.
|
||||
// exit 1 + `token.already_revoked`; both leave dist.db unchanged.
|
||||
// 6. A publish into the same store still works (the model with tokens
|
||||
// round-trips through the publish pipeline's load/save).
|
||||
//
|
||||
// Store-side state is asserted by QUERYING `<store>/dist.db` through the
|
||||
// SQLite bindings — independent of the db.sx load path under test.
|
||||
#import "modules/std.sx";
|
||||
#import "modules/std/json.sx";
|
||||
process :: #import "modules/std/process.sx";
|
||||
fs :: #import "modules/std/fs.sx";
|
||||
hash :: #import "modules/std/hash.sx";
|
||||
sq :: #import "../src/db/sqlite.sx";
|
||||
|
||||
STORE :: ".sx-tmp/token_ops";
|
||||
MDIR :: ".sx-tmp/token_ops_m";
|
||||
@@ -54,41 +58,52 @@ contains :: (haystack: string, needle: string) -> bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
count_actor_action :: (events: Array, actor: string, action: string) -> i64 {
|
||||
c : i64 = 0;
|
||||
i := 0;
|
||||
while i < events.len {
|
||||
eo := events.items[i].object;
|
||||
if get_str(eo, "actor") == actor and get_str(eo, "action") == action { c += 1; }
|
||||
i += 1;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
// The raw bytes of the store database (for "secret appears nowhere" and
|
||||
// byte-identity assertions).
|
||||
db_bytes :: () -> string {
|
||||
b := fs.read_file(path_join(STORE, "db.json"));
|
||||
process.assert(b != null, "db.json must exist under the store");
|
||||
b := fs.read_file(path_join(STORE, "dist.db"));
|
||||
process.assert(b != null, "dist.db must exist under the store");
|
||||
return b!;
|
||||
}
|
||||
|
||||
load_db :: (scratch: Allocator) -> Object {
|
||||
dv, de := parse(db_bytes(), scratch);
|
||||
if de { process.assert(false, "db.json must be valid JSON"); dummy : Object = .{}; return dummy; }
|
||||
return dv.object;
|
||||
// Open the store database read-only (asserts it exists and opens).
|
||||
db_open_ro :: () -> sq.Sqlite {
|
||||
c, oe := sq.Sqlite.open_v2(path_join(STORE, "dist.db"), sq.SQLITE_OPEN_READONLY);
|
||||
process.assert(!oe, "dist.db must open as a SQLite database");
|
||||
c.busy_timeout(2000);
|
||||
return c;
|
||||
}
|
||||
|
||||
// The db.json token entry with this id.
|
||||
db_token :: (id: string, scratch: Allocator) -> Object {
|
||||
toks := get_arr(load_db(scratch), "tokens");
|
||||
i := 0;
|
||||
while i < toks.len {
|
||||
to := toks.items[i].object;
|
||||
if get_str(to, "id") == id { return to; }
|
||||
i += 1;
|
||||
}
|
||||
process.assert(false, concat("token not in db.json: ", id));
|
||||
dummy : Object = .{};
|
||||
return dummy;
|
||||
// One-row TEXT scalar with up to two text bindings ("" = unbound).
|
||||
q_text :: (sql: string, p1: string, p2: string) -> string {
|
||||
c := db_open_ro();
|
||||
st, pe := c.prepare(sql);
|
||||
process.assert(!pe, concat("prepare must succeed: ", sql));
|
||||
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
|
||||
if p2.len > 0 { st.bind_text(2, p2) catch { process.assert(false, "bind 2 failed"); }; }
|
||||
rc, se := st.step();
|
||||
process.assert(!se, concat("step must succeed: ", sql));
|
||||
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
|
||||
out := st.column_text(0);
|
||||
st.finalize();
|
||||
c.close();
|
||||
return out;
|
||||
}
|
||||
|
||||
// One-row INTEGER scalar with up to two text bindings ("" = unbound).
|
||||
q_int :: (sql: string, p1: string, p2: string) -> i64 {
|
||||
c := db_open_ro();
|
||||
st, pe := c.prepare(sql);
|
||||
process.assert(!pe, concat("prepare must succeed: ", sql));
|
||||
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
|
||||
if p2.len > 0 { st.bind_text(2, p2) catch { process.assert(false, "bind 2 failed"); }; }
|
||||
rc, se := st.step();
|
||||
process.assert(!se, concat("step must succeed: ", sql));
|
||||
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
|
||||
out := st.column_int64(0);
|
||||
st.finalize();
|
||||
c.close();
|
||||
return out;
|
||||
}
|
||||
|
||||
// The "status" of token `id` in `token list --json` output.
|
||||
@@ -139,14 +154,12 @@ main :: () -> i32 {
|
||||
process.assert(get_str(t1, "scopes") == "publish", "default scope is publish");
|
||||
process.assert(get_int(t1, "expires_at") == 0, "default expiry is never");
|
||||
|
||||
dt1 := db_token(id1, xx arena);
|
||||
stored_hash := get_str(dt1, "token_hash");
|
||||
stored_hash := q_text("SELECT token_hash FROM tokens WHERE id = ?1", id1, "");
|
||||
d := hash.sha256_hex(secret);
|
||||
expect_hash := string.{ ptr = @d[0], len = 64 };
|
||||
process.assert(stored_hash == expect_hash, "db stores sha256(secret) as token_hash");
|
||||
process.assert(!contains(db_bytes(), secret), "the raw secret appears nowhere under the store");
|
||||
db1 := load_db(xx arena);
|
||||
process.assert(count_actor_action(get_arr(db1, "audit_events"), "cli", "token.create") == 1,
|
||||
process.assert(q_int("SELECT COUNT(*) FROM audit_events WHERE actor = ?1 AND action = ?2", "cli", "token.create") == 1,
|
||||
"create recorded one cli token.create audit event");
|
||||
print(" create: secret shown once, only sha256 at rest, audited\n");
|
||||
|
||||
@@ -177,12 +190,10 @@ main :: () -> i32 {
|
||||
rv, re := parse(rr!.stdout, xx arena);
|
||||
if re { process.assert(false, "revoke stdout must be one JSON object"); return 1; }
|
||||
process.assert(get_str(rv.object, "status") == "revoked", "revoke json status");
|
||||
dt1b := db_token(id1, xx arena);
|
||||
revoked_at := get_int(dt1b, "revoked_at");
|
||||
revoked_at := q_int("SELECT revoked_at FROM tokens WHERE id = ?1", id1, "");
|
||||
process.assert(revoked_at >= created1, "db records revoked_at");
|
||||
process.assert(list_status(id1, xx arena) == "revoked", "token 1 listed revoked");
|
||||
db4 := load_db(xx arena);
|
||||
process.assert(count_actor_action(get_arr(db4, "audit_events"), "cli", "token.revoke") == 1,
|
||||
process.assert(q_int("SELECT COUNT(*) FROM audit_events WHERE actor = ?1 AND action = ?2", "cli", "token.revoke") == 1,
|
||||
"revoke recorded one cli token.revoke audit event");
|
||||
print(" revoke: status flips, audited\n");
|
||||
|
||||
@@ -201,17 +212,16 @@ main :: () -> i32 {
|
||||
if ae { process.assert(false, "double-revoke stdout must be one JSON object"); return 1; }
|
||||
process.assert(get_str(get_obj(av.object, "error"), "code") == "token.already_revoked",
|
||||
"double-revoke json names token.already_revoked");
|
||||
process.assert(db_bytes() == before, "failed revokes leave db.json byte-identical");
|
||||
print(" failures: unknown + double revoke exit 1, db.json untouched\n");
|
||||
process.assert(db_bytes() == before, "failed revokes leave dist.db byte-identical");
|
||||
print(" failures: unknown + double revoke exit 1, dist.db untouched\n");
|
||||
|
||||
// ── 6. Publish coexists with tokens in the same store ────────────
|
||||
pc := concat("build/dist ci publish --manifest ", path_join(MDIR, "m.json"));
|
||||
pc = concat(pc, " --local-store .sx-tmp/token_ops --json 2>/dev/null");
|
||||
rp := process.run(pc);
|
||||
process.assert(rp != null and rp!.exit_code == 0, "publish into a tokened store must exit 0");
|
||||
db6 := load_db(xx arena);
|
||||
process.assert(get_arr(db6, "tokens").len == 2, "publish preserved both tokens");
|
||||
process.assert(get_arr(db6, "releases").len == 1, "publish landed its release");
|
||||
process.assert(q_int("SELECT COUNT(*) FROM tokens", "", "") == 2, "publish preserved both tokens");
|
||||
process.assert(q_int("SELECT COUNT(*) FROM releases", "", "") == 1, "publish landed its release");
|
||||
print(" publish: round-trips the model with tokens intact\n");
|
||||
|
||||
process.run(concat("rm -rf ", STORE));
|
||||
|
||||
Reference in New Issue
Block a user