package sola; import io.quarkus.test.junit.QuarkusTest; import io.restassured.response.Response; import jakarta.inject.Inject; import org.junit.jupiter.api.*; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import sola.store.control.Store; import sola.identity.control.Access; import sola.identity.Requirement; import static sola.identity.Requirement.Rn.*; import java.util.*; import java.util.concurrent.*; import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.*; import static org.junit.jupiter.api.Assertions.*; @QuarkusTest class ZeroTest { static final String KEY="synthetic-owner-key-not-a-real-credential"; static final String FACTOR="synthetic-factor-key-not-a-real-credential"; static final String OWNER="@owner:example.test"; @Inject Store store; @Inject Access access; @BeforeEach void resetChallenges() throws Exception { access.pending(FACTOR); store.write(c -> { Store.execute(c,"DELETE FROM v5_challenges");return null; }); } static Map challenge() { return given().auth().oauth2(KEY).contentType("application/json").body(Map.of("label","Synthetic CLI")) .post("/api/auth/challenges").then().statusCode(200).extract().as(Map.class); } static Response prove(Map c,boolean yes,String proof) { return given().auth().oauth2(FACTOR).contentType("application/json").body(Map.of("subject",OWNER,"proofId",proof,"approved",yes)) .post("/api/auth/challenges/"+c.get("challengeId")+"/proof"); } static Response redeem(Map c,String token) { return given().auth().oauth2(token).contentType("application/json").body(Map.of("challengeId",c.get("challengeId"))).post("/api/auth/token"); } static String owner() { var c=challenge();prove(c,true,"matrix:$"+UUID.randomUUID()).then().statusCode(204); return redeem(c,c.get("pollToken").toString()).then().statusCode(200).extract().path("accessToken"); } static Response submit(String token,Object body) { return given().auth().oauth2(token).contentType("application/json").body(body).post("/api/iteration"); } @ParameterizedTest(name="identity R5.1: {0} cannot access owner APIs") @ValueSource(strings={"synthetic-owner-key-not-a-real-credential","synthetic-factor-key-not-a-real-credential","invalid"}) @Requirement(R5_1) void factorsAreNotAccessTokens(String key) { for(var path:List.of("/api/me","/api/iteration","/api/history","/api/zero/manifest")) given().auth().oauth2(key).get(path).then().statusCode(401).header("Cache-Control","no-store"); } @Test @DisplayName("identity R5.2: only the owner key starts a challenge") @Requirement(R5_2) void challengeRequiresFirstFactor() { given().auth().oauth2(FACTOR).contentType("application/json").body(Map.of("label","test")).post("/api/auth/challenges").then().statusCode(401); given().get("/api/login/magic-link").then().statusCode(404); } @Test @DisplayName("identity R5.3: bound concurrent pending challenges") @Requirement(R5_3) void onlyOnePending() { challenge(); given().auth().oauth2(KEY).contentType("application/json").body(Map.of("label","second")).post("/api/auth/challenges").then().statusCode(409); } @Test @DisplayName("identity R6.1: reaction proof and initiating-client secret are both required") @Requirement(R6_1) void requiresBothProofs() { var c=challenge();var poll=c.get("pollToken").toString(); redeem(c,poll).then().statusCode(202).body("status",equalTo("pending")); given().auth().oauth2(KEY).get("/api/auth/challenges/pending").then().statusCode(401); given().auth().oauth2(KEY).contentType("application/json").body(Map.of("subject",OWNER,"proofId","matrix:$wrong","approved",true)) .post("/api/auth/challenges/"+c.get("challengeId")+"/proof").then().statusCode(401); prove(c,true,"matrix:$correct").then().statusCode(204); redeem(c,FACTOR).then().statusCode(401); redeem(c,poll).then().statusCode(200).body("owner",equalTo(OWNER)); redeem(c,poll).then().statusCode(401); } @Test @DisplayName("identity R6.2: rejection never issues a grant") @Requirement(R6_2) void denial() { var c=challenge();prove(c,false,"matrix:$denied").then().statusCode(204); redeem(c,c.get("pollToken").toString()).then().statusCode(403); } @Test @DisplayName("identity R6.3: expired challenges cannot be proved or redeemed") @Requirement(R6_3) void expiry() throws Exception { var c=challenge(); store.write(db -> {Store.execute(db,"UPDATE v5_challenges SET expires_at=0 WHERE id=?",c.get("challengeId"));return null;}); prove(c,true,"matrix:$late").then().statusCode(409); redeem(c,c.get("pollToken").toString()).then().statusCode(401); } @Test @DisplayName("identity R6.4: a proof cannot cross challenges or owners") @Requirement(R6_4) void replayAndSubject() { var c=challenge(); given().auth().oauth2(FACTOR).contentType("application/json").body(Map.of("subject","@stranger:example.test","proofId","matrix:$bad","approved",true)) .post("/api/auth/challenges/"+c.get("challengeId")+"/proof").then().statusCode(400); prove(c,true,"matrix:$once").then().statusCode(204); prove(c,true,"matrix:$twice").then().statusCode(409); var next=challenge();prove(next,true,"matrix:$once").then().statusCode(409); } @Test @DisplayName("identity R6.5: racing redemptions issue exactly one grant") @Requirement(R6_5) void concurrentRedemption() throws Exception { var c=challenge();prove(c,true,"matrix:$race").then().statusCode(204); try(var pool=Executors.newVirtualThreadPerTaskExecutor()) { var futures=new ArrayList>(); for(int i=0;i<6;i++) futures.add(pool.submit(() -> redeem(c,c.get("pollToken").toString()).statusCode())); var codes=new ArrayList();for(var f:futures) codes.add(f.get()); assertEquals(1,Collections.frequency(codes,200));assertEquals(5,Collections.frequency(codes,401)); } } @Test @DisplayName("identity R7.1: expiring, hashed, revocable grants") @Requirement(R7_1) void grantLifetime() throws Exception { var token=owner();var original=token; given().auth().oauth2(token).get("/api/me").then().statusCode(200).body("owner",equalTo(OWNER)); try(var c=store.connect()) { assertTrue(Store.query(c,"SELECT token_hash FROM v5_grants").stream().noneMatch(row -> original.equals(row.get("token_hash")))); } given().auth().oauth2(token).post("/auth/logout").then().statusCode(204); given().auth().oauth2(token).get("/api/me").then().statusCode(401); token=owner();var expired=token; store.write(c -> {Store.execute(c,"UPDATE v5_grants SET expires_at=0 WHERE token_hash=?",Access.hash(expired));return null;}); given().auth().oauth2(token).get("/api/me").then().statusCode(401); } @ParameterizedTest(name="iteration R1.2: invalid request {0}") @ValueSource(strings={"{}","{\"prompt\":\" \"}","{\"prompt\":\"x\",\"changes\":[]}","{\"prompt\":\"x\",\"autoRun\":\"yes\"}"}) void rejectsInvalidBeforeQueue(String body) { submit(owner(),body).then().statusCode(400); } @Test @DisplayName("iteration R1.1/R1.3: queue only by default, no unavailable execution") void queuesWithoutRunner() { var token=owner();var id=submit(token,Map.of("prompt","synthetic queue")).then().statusCode(202).body("iteration.status",equalTo("queued")).extract().path("iteration.requestId"); given().auth().oauth2(token).post("/api/iteration/"+id+"/run").then().statusCode(503); given().auth().oauth2(token).get("/api/iteration/"+id).then().statusCode(200).body("status",equalTo("queued")); submit(token,Map.of("prompt","unavailable","autoRun",true)).then().statusCode(503); } @Test @DisplayName("iteration R1.5: concurrent retry yields one request; changed payload conflicts") void idempotency() throws Exception { var token=owner();var key=UUID.randomUUID().toString(); try(var pool=Executors.newVirtualThreadPerTaskExecutor()) { var futures=new ArrayList>(); for(int i=0;i<5;i++) futures.add(pool.submit(() -> given().auth().oauth2(token).header("Idempotency-Key",key).contentType("application/json") .body(Map.of("prompt","idempotent")).post("/api/iteration").then().statusCode(202).extract().path("iteration.requestId"))); var ids=new HashSet();for(var f:futures) ids.add(f.get());assertEquals(1,ids.size()); } given().auth().oauth2(token).header("Idempotency-Key",key).contentType("application/json").body(Map.of("prompt","changed")).post("/api/iteration").then().statusCode(409); } @Test @DisplayName("S2: another owner's records and artifacts remain inaccessible") void isolatesOwner() throws Exception { var token=owner();var id=UUID.randomUUID().toString(); store.write(c -> {Store.execute(c,"INSERT INTO v5_iterations VALUES (?,?,?,'queued',?,?)",id,"other","private","now","now");return null;}); for(var suffix:List.of("","/patch")) given().auth().oauth2(token).get("/api/iteration/"+id+suffix).then().statusCode(404); given().auth().oauth2(token).post("/api/iteration/"+id+"/cancel").then().statusCode(404); given().auth().oauth2(token).get("/api/iteration").then().body("iterations.requestId",not(hasItem(id))); } }